M4: superscript and subscript are synthesised where the font has no glyphs for them (#1828) (#1868)

* 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:
Trevor Brown
2026-11-02 05:30:50 -05:00
committed by GitHub
co-authored by Claude Fable 5.1 Brandon Thomas
parent e9eb7ce7f4
commit c44cfc94af
5 changed files with 163 additions and 34 deletions
+40
View File
@@ -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 {
+18
View File
@@ -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
View File
@@ -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,
+43
View File
@@ -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
View File
File diff suppressed because one or more lines are too long