mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
* M4: superscript and subscript are synthesised where the font has no glyphs for them (#1828) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Text: a zero OS/2 script offset counts as unset, and the native-glyph check sees the case-mapped characters Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Brandon Thomas
parent
e9eb7ce7f4
commit
c44cfc94af
@@ -399,6 +399,46 @@ fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
assert_eq!(size(&s, t).1, 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_are_synthesised_without_glyphs_for_them() {
|
||||
// "If it isn't provided, Figma draws a synthesized version by using the font's standard
|
||||
// version of the character, shrinking it, and positioning it accordingly" (Explore text
|
||||
// properties). The synthetic font has no sups or subs feature and no OS/2 metrics, so the
|
||||
// usual 0.65 em at 0.35 em up (superscript) or 0.14 em down (subscript) apply.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x2", json!({"fontFamily": font, "fontSize": 40}));
|
||||
let glyphs = |s: &Session| -> Vec<(f64, f64, f64)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.glyphs.iter().map(|g| (g.size, g.y, g.advance)).collect()
|
||||
};
|
||||
let plain = glyphs(&s);
|
||||
assert_eq!(plain.len(), 2);
|
||||
assert_eq!((plain[0].0, plain[0].2), (40.0, 20.0));
|
||||
assert_eq!(size(&s, t).0, 40.0);
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": true}})).unwrap();
|
||||
let sup = glyphs(&s);
|
||||
for (g, p) in sup.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.2 - 13.0).abs() < 1e-6, "shrunk to 0.65 em, the advance with it: {g:?}");
|
||||
assert!((p.1 - g.1 - 14.0).abs() < 1e-6, "raised 0.35 em: {g:?} against {p:?}");
|
||||
}
|
||||
assert!((size(&s, t).0 - 26.0).abs() < 1e-6, "the auto-width box hugs the smaller glyphs: {:?}", size(&s, t));
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": true}})).unwrap();
|
||||
let sub = glyphs(&s);
|
||||
for (g, p) in sub.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.1 - p.1 - 5.6).abs() < 1e-6, "shrunk, and lowered 0.14 em: {g:?} against {p:?}");
|
||||
}
|
||||
// Capitalised, the same: the synthesis follows the characters shaping saw.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "upper"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"subs": null, "sups": true}})).unwrap();
|
||||
for (g, p) in glyphs(&s).iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (p.1 - g.1 - 14.0).abs() < 1e-6, "upper case, superscript: {g:?} against {p:?}");
|
||||
}
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "original"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": false}})).unwrap();
|
||||
assert_eq!(glyphs(&s), plain, "off again");
|
||||
}
|
||||
|
||||
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
|
||||
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
|
||||
fn test_mono_font() -> &'static str {
|
||||
|
||||
@@ -102,6 +102,11 @@ pub struct Face {
|
||||
pub underline_pos: f64,
|
||||
pub underline_thickness: f64,
|
||||
pub strike_pos: f64,
|
||||
/// Synthesised superscript and subscript (the OS/2 table's values, or the usual ones without
|
||||
/// it): the glyph size as a fraction of the em, and the baseline shift up in font units
|
||||
/// (negative for a subscript, which sits below).
|
||||
pub superscript: (f64, f64),
|
||||
pub subscript: (f64, f64),
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// OpenType GSUB feature tags the face provides (e.g. `smcp`).
|
||||
gsub_features: Vec<[u8; 4]>,
|
||||
@@ -179,6 +184,17 @@ impl Face {
|
||||
let colr = has_table(b"COLR");
|
||||
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
|
||||
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
|
||||
// Superscript and subscript sizes and offsets: the OS/2 table's, else the usual 0.65 em,
|
||||
// 0.35 em up and 0.14 em down (what common fonts carry); a zero counts as unset, as some
|
||||
// fonts leave these fields.
|
||||
let os2 = f.os2().ok();
|
||||
let script = |size: Option<i16>, offset: Option<i16>, up: bool, usual_offset: f64| -> (f64, f64) {
|
||||
let size = size.map(f64::from).filter(|v| *v > 0.0).map_or(0.65, |v| v / upem);
|
||||
let offset = offset.map(f64::from).filter(|v| *v != 0.0).unwrap_or(usual_offset * upem);
|
||||
(size, if up { offset } else { -offset })
|
||||
};
|
||||
let superscript = script(os2.as_ref().map(|t| t.y_superscript_y_size()), os2.as_ref().map(|t| t.y_superscript_y_offset()), true, 0.35);
|
||||
let subscript = script(os2.as_ref().map(|t| t.y_subscript_y_size()), os2.as_ref().map(|t| t.y_subscript_y_offset()), false, 0.14);
|
||||
let weight = {
|
||||
let w = attrs.weight.value();
|
||||
if w.is_finite() && w > 0.0 { w.round() as u16 } else { figcraft_doc::style_weight(&style) }
|
||||
@@ -200,6 +216,8 @@ impl Face {
|
||||
underline_pos: under.0,
|
||||
underline_thickness: under.1.max(upem * 0.02),
|
||||
strike_pos: strike,
|
||||
superscript,
|
||||
subscript,
|
||||
shaper,
|
||||
gsub_features,
|
||||
track,
|
||||
|
||||
+59
-33
@@ -1418,39 +1418,65 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
|
||||
// Variable fonts: the axis values the style asks for (named instance, implied weight, custom).
|
||||
let values = face.axis_values(style);
|
||||
let coords = face.coords(&values);
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new();
|
||||
if let Some(hb) = face.hb() {
|
||||
let instance = (!coords.is_empty()).then(|| {
|
||||
let vars = face
|
||||
.axes
|
||||
.iter()
|
||||
.zip(&values)
|
||||
.filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 }));
|
||||
ShaperInstance::from_variations(&hb, vars)
|
||||
});
|
||||
let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (u, src, _) in &mapped {
|
||||
buf.add(*u, *src as u32);
|
||||
}
|
||||
let rtl = seg.level % 2 == 1;
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
}
|
||||
// Right-to-left glyphs come out in visual order; items are kept in logical order (lines
|
||||
// put runs back in visual order).
|
||||
if rtl {
|
||||
raw.reverse();
|
||||
}
|
||||
} else {
|
||||
for (u, src, _) in &mapped {
|
||||
let gid = face.glyph_for(*u);
|
||||
raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0));
|
||||
let shape = |feats: &[Feature]| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
let mut raw = Vec::new();
|
||||
if let Some(hb) = face.hb() {
|
||||
let instance = (!coords.is_empty()).then(|| {
|
||||
let vars = face
|
||||
.axes
|
||||
.iter()
|
||||
.zip(&values)
|
||||
.filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 }));
|
||||
ShaperInstance::from_variations(&hb, vars)
|
||||
});
|
||||
let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (u, src, _) in &mapped {
|
||||
buf.add(*u, *src as u32);
|
||||
}
|
||||
let rtl = seg.level % 2 == 1;
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
}
|
||||
// Right-to-left glyphs come out in visual order; items are kept in logical order
|
||||
// (lines put runs back in visual order).
|
||||
if rtl {
|
||||
raw.reverse();
|
||||
}
|
||||
} else {
|
||||
for (u, src, _) in &mapped {
|
||||
let gid = face.glyph_for(*u);
|
||||
raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0));
|
||||
}
|
||||
}
|
||||
raw
|
||||
};
|
||||
let mut raw = shape(&feats);
|
||||
// Superscript or subscript (`sups` / `subs` on the node): the face's own glyphs where it has
|
||||
// the feature and every letter or digit here got one; otherwise synthesised from the standard
|
||||
// glyphs, shrunk and shifted ("Figma draws a synthesized version by using the font's standard
|
||||
// version of the character, shrinking it, and positioning it accordingly"; a run mixing
|
||||
// available and unavailable glyphs is synthesised whole, "for consistency").
|
||||
let script = [(b"sups", true), (b"subs", false)].into_iter().find(|(tag, _)| feats.iter().any(|f| f.tag == Tag::new(tag) && f.value != 0));
|
||||
// The glyph a cluster's character has on its own, after the case mapping shaping saw.
|
||||
let plain_gid = |cl: usize| mapped.iter().find(|(_, src, _)| *src == cl).map(|(u, _, _)| face.glyph_for(*u));
|
||||
let unchanged = |raw: &[(u32, u32, i32, i32, i32)]| {
|
||||
raw.iter().any(|&(gid, cl, ..)| {
|
||||
let cl = cl as usize;
|
||||
chars.get(cl).is_some_and(|c| c.is_alphanumeric()) && plain_gid(cl) == Some(gid)
|
||||
})
|
||||
};
|
||||
let synth = script.filter(|(tag, _)| !face.has_feature(tag) || unchanged(&raw));
|
||||
if let Some((tag, _)) = synth
|
||||
&& face.has_feature(tag)
|
||||
{
|
||||
let plain: Vec<Feature> = feats.iter().filter(|f| f.tag != Tag::new(tag)).cloned().collect();
|
||||
raw = shape(&plain);
|
||||
}
|
||||
let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript });
|
||||
let n = raw.len();
|
||||
for gi in 0..n {
|
||||
let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue };
|
||||
@@ -1459,7 +1485,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
|
||||
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
|
||||
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
|
||||
let ch = chars.get(cl).copied().unwrap_or(' ');
|
||||
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 };
|
||||
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale;
|
||||
let spacing = if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 };
|
||||
// The glyph's own advance and what shaping added to it (kerning) each land on the grid.
|
||||
let own = face.advance_at(gid, &coords);
|
||||
@@ -1472,7 +1498,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
|
||||
adv,
|
||||
spacing,
|
||||
dx: f64::from(xo) * k * scale,
|
||||
dy: f64::from(yo) * k * scale,
|
||||
dy: f64::from(yo) * k * scale + script_shift * k,
|
||||
char_start: cl,
|
||||
char_end: cend,
|
||||
style: seg.st_idx,
|
||||
|
||||
@@ -157,6 +157,49 @@ fn small_caps_native_or_synthesised() {
|
||||
assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_native_or_synthesised_and_never_mixed() {
|
||||
// "Figma tries to use any superscript/subscript glyphs provided by the font. If it isn't
|
||||
// provided, Figma draws a synthesized version by using the font's standard version of the
|
||||
// character, shrinking it, and positioning it accordingly." and a layer mixing both "will all
|
||||
// update to the synthesized form for consistency" (Explore text properties).
|
||||
let Some(face) = inter() else { return };
|
||||
let mut t = TextData::new("x2");
|
||||
t.style.font_size = 20.0;
|
||||
let plain = layout(&t, None, None);
|
||||
t.features.insert("sups".into(), true);
|
||||
let sup = layout(&t, None, None);
|
||||
let at = |l: &TextLayout, i: usize| l.glyphs.iter().find(|g| g.char_start == i).cloned().unwrap();
|
||||
let native = |i: usize| at(&sup, i).gid != at(&plain, i).gid && (at(&sup, i).size - 20.0).abs() < 1e-9;
|
||||
let synthesised = |i: usize| at(&sup, i).gid == at(&plain, i).gid && at(&sup, i).size < 20.0 && at(&sup, i).y < at(&plain, i).y;
|
||||
assert!((native(0) && native(1)) || (synthesised(0) && synthesised(1)), "both glyphs the same way: {:?} {:?}", at(&sup, 0), at(&sup, 1));
|
||||
if !face.has_feature(b"sups") {
|
||||
assert!(synthesised(0) && synthesised(1), "no sups in the face: synthesised");
|
||||
}
|
||||
t.features.insert("sups".into(), false);
|
||||
t.features.insert("subs".into(), true);
|
||||
let sub = layout(&t, None, None);
|
||||
let g = at(&sub, 1);
|
||||
let p = at(&plain, 1);
|
||||
assert!((g.gid != p.gid && (g.size - 20.0).abs() < 1e-9) || (g.size < 20.0 && g.y > p.y), "subscript: native, or shrunk and lowered: {g:?}");
|
||||
// Capitalised, the same holds: the check against the face's own glyphs sees the characters
|
||||
// shaping saw (an X, not an x), so a face with a superscript 2 and no superscript X
|
||||
// synthesises both rather than mixing them.
|
||||
t.features.clear();
|
||||
t.style.case = figcraft_doc::TextCase::Upper;
|
||||
let plain_up = layout(&t, None, None);
|
||||
t.features.insert("sups".into(), true);
|
||||
let up = layout(&t, None, None);
|
||||
let native_up = |i: usize| at(&up, i).gid != at(&plain_up, i).gid && (at(&up, i).size - 20.0).abs() < 1e-9;
|
||||
let synth_up = |i: usize| at(&up, i).gid == at(&plain_up, i).gid && at(&up, i).size < 20.0 && at(&up, i).y < at(&plain_up, i).y;
|
||||
assert!(
|
||||
(native_up(0) && native_up(1)) || (synth_up(0) && synth_up(1)),
|
||||
"upper case: both glyphs the same way: {:?} {:?}",
|
||||
at(&up, 0),
|
||||
at(&up, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_case_across_runs() {
|
||||
let Some(face) = inter() else { return };
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user