Merge pull request #1629 from ramox81/codex/mtext-rtl-layout

Text layout: right-to-left runs, TEXT direction and leading spaces.
This commit is contained in:
Hakan Seven committed 2026-10-06 20:42:14 +03:00
commit 36871044a6
4 files changed
+320 -138

No files matched your search

+30 -2
View File
@@ -83,6 +83,34 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
/// click-to-select preview. Uses the exact same layout opts as `to_render`
/// so the boxes line up with the rendered glyphs.
/// The text MTEXT lays out: its stored value, except that the spaces between
/// a field's value and a right-to-left word right after it read with that
/// word — the reference draws `שלום 0 עולם` with no gap after the field
/// (the spaces end up after the word), while plain text keeps the gap.
fn display_text(t: &MText, document: &codec::CadDocument) -> String {
use crate::entities::text_support::is_rtl_char;
let value = &t.value;
if !value.chars().any(is_rtl_char) {
return value.clone();
}
let Some(spans) = codec::fields::field_spans(document, t.common.handle, value) else {
return value.clone();
};
let mut out = value.clone();
for span in spans.iter().rev() {
let rest = &out[span.end..];
let gap = rest.len() - rest.trim_start_matches(' ').len();
let after = &rest[gap..];
if gap == 0 || !after.chars().next().is_some_and(is_rtl_char) {
continue;
}
let word = after.find(|c: char| c == ' ' || c == '\\').unwrap_or(after.len());
let moved = format!("{}{}{}", &after[..word], &rest[..gap], &after[word..]);
out.replace_range(span.end.., &moved);
}
out
}
pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec<GlyphBox> {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
@@ -112,7 +140,7 @@ pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec<GlyphBox> {
};
// A field shows its stored value: fields update on their evaluation
// events (open, save, plot, regen, UPDATEFIELD), never on a redraw.
let display = t.value.clone();
let display = display_text(t, document);
let layout = layout_mtext(&MTextRenderOpts {
value: &display,
insertion: [
@@ -170,7 +198,7 @@ fn to_render(t: &MText, document: &codec::CadDocument) -> RenderEntity {
};
// A field shows its stored value: fields update on their evaluation
// events (open, save, plot, regen, UPDATEFIELD), never on a redraw.
let display = t.value.clone();
let display = display_text(t, document);
let layout = layout_mtext(&MTextRenderOpts {
value: &display,
insertion: [
+5 -30
View File
@@ -281,38 +281,13 @@ pub fn text_run_placement_at_scale(
// box one way while the strokes run the other (the bounds advance is
// always positive — it uses |width_factor|). Left keeps its 0 reference.
let sign = width_factor.signum();
let is_rtl = {
let mut strong_rtl = false;
for ch in value_for_bounds.chars() {
match unicode_bidi::bidi_class(ch) {
unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL => {
strong_rtl = true;
break;
}
unicode_bidi::BidiClass::L => {
break;
}
_ => {}
}
}
strong_rtl
};
// Left / right alignment do not depend on the text's script: the
// reference lays a line that starts with Hebrew or Arabic out from its
// left end like any other.
let ax = match t.horizontal_alignment {
HA::Left => {
if is_rtl {
b.advance * sign
} else {
0.0
}
}
HA::Left => 0.0,
HA::Center | HA::Middle => b.advance * 0.5 * sign,
HA::Right => {
if is_rtl {
0.0
} else {
b.advance * sign
}
}
HA::Right => b.advance * sign,
HA::Aligned | HA::Fit => 0.0,
};
// Vertical anchor uses the inked extent (cap / baseline geometry).
+264 -102
View File
@@ -867,6 +867,73 @@ pub fn is_rtl_char(c: char) -> bool {
/// Reorder a line's atoms into visual order (left to right) using the Unicode
/// Bidirectional Algorithm (UBA) Rule L2. If the line contains no RTL characters
/// and is not an RTL paragraph, returns the atoms unchanged.
/// Left-to-right mark: keeps the shaper's paragraph direction left to right
/// and closes a right-to-left run inside a word.
pub(crate) const LRM: char = '‎';
/// Whether a word is written right to left as a whole: right-to-left
/// letters (and their marks) only.
fn is_rtl_word(w: &str) -> bool {
w.chars().any(is_rtl_char)
&& w.chars().all(|c| {
matches!(
unicode_bidi::bidi_class(c),
unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL | unicode_bidi::BidiClass::NSM
)
})
}
/// A word as the reference draws it inside MTEXT: each run of right-to-left
/// letters reads backwards in place, everything else (digits, punctuation,
/// Latin) keeps its order — `אב_123_גד` shows `בא_123_דג`. A mark after
/// each right-to-left run keeps the shaper from carrying neutrals and
/// numbers into it.
pub(crate) fn rtl_display_word(word: String) -> String {
if !word.chars().any(is_rtl_char) || is_rtl_word(&word) {
return word;
}
let mut out = String::with_capacity(word.len() + 6);
let mut in_rtl = false;
for c in word.chars() {
let rtl = matches!(
unicode_bidi::bidi_class(c),
unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL
) || (in_rtl && unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM);
if in_rtl && !rtl {
out.push(LRM);
}
out.push(c);
in_rtl = rtl;
}
out
}
/// The reference's MTEXT line order: words keep their order left to right,
/// except that a run of right-to-left words — with the spaces between and
/// after them — reads backwards as a whole (`אבג דה abc` shows
/// ` הד גבאabc`). Each right-to-left word is itself shaped backwards.
pub fn reorder_rtl_runs(atoms: Vec<LayoutAtom>) -> Vec<LayoutAtom> {
let rtl = |a: &LayoutAtom| matches!(&a.kind, AtomKind::Word(w) if is_rtl_word(w));
if !atoms.iter().any(rtl) {
return atoms;
}
let mut atoms = atoms;
let mut i = 0;
while i < atoms.len() {
if !rtl(&atoms[i]) {
i += 1;
continue;
}
let mut end = i + 1;
while end < atoms.len() && (rtl(&atoms[end]) || matches!(atoms[end].kind, AtomKind::Space)) {
end += 1;
}
atoms[i..end].reverse();
i = end;
}
atoms
}
pub fn reorder_line_atoms(atoms: Vec<LayoutAtom>, is_rtl: bool) -> Vec<LayoutAtom> {
if atoms.len() <= 1 && !is_rtl {
return atoms;
@@ -1005,6 +1072,69 @@ pub(crate) fn cjk_break_between(prev: char, next: char) -> bool {
!CLOSING.contains(next) && !OPENING.contains(prev)
}
/// A word's font runs, in logical order: `true` for a stretch the shaper
/// draws, `false` for one the stroke font draws. With a stroke font the
/// reference draws a mixed word run by run — Latin, digits and punctuation
/// in the style's font, the letters of a script that needs shaping (with the
/// marks that close them) in a TrueType face — so `שלום|12` keeps a thin
/// bar and digits. A TrueType style shapes the whole word.
pub(crate) fn word_runs<'t>(text: &'t str, face: &Face) -> Vec<(&'t str, bool)> {
use crate::scene::text::web_font::requires_shaping;
if face.ttf_family().is_some() {
return vec![(text, true)];
}
if !requires_shaping(text) {
return vec![(text, false)];
}
let mut runs: Vec<(&str, bool)> = Vec::new();
let mut start = 0;
let mut shaped_run = None;
for (i, c) in text.char_indices() {
let mut buf = [0u8; 4];
let shaped = requires_shaping(c.encode_utf8(&mut buf))
|| (shaped_run == Some(true)
&& (c == LRM || unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM));
match shaped_run {
Some(prev) if prev != shaped => {
runs.push((&text[start..i], prev));
start = i;
}
_ => {}
}
shaped_run = Some(shaped);
}
if let Some(shaped) = shaped_run {
runs.push((&text[start..], shaped));
}
runs
}
/// The shaper's face for a word run.
fn run_family(face: &Face) -> &str {
face.ttf_family()
.unwrap_or_else(|| crate::scene::text::web_font::primary_script().family())
}
/// Advance of a stroke-font run (glyph units × `scale`).
fn stroke_run_width(face: &Face, text: &str, tracking: f32, scale: f32) -> f32 {
text.chars()
.map(|ch| match face.glyph(ch) {
Some(g) => (g.advance + face.spacing_after(ch) * tracking) * scale,
None => (6.0 + face.letter_spacing() * tracking) * scale,
})
.sum()
}
/// Width of one word run, as it is drawn.
pub(crate) fn run_width(face: &Face, text: &str, shaped: bool, tracking: f32, scale: f32) -> f32 {
if shaped {
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(run_family(face), text) {
return run.advance * scale;
}
}
stroke_run_width(face, text, tracking, scale)
}
pub fn measure_word(
text: &str,
state: &RunState,
@@ -1015,23 +1145,10 @@ pub fn measure_word(
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
let face = Face::resolve(&font_name);
let shaping_family = face.ttf_family().or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
});
if let Some(fam) = shaping_family {
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(fam, text) {
return run.advance * scale;
}
}
let mut w = 0.0_f32;
for ch in text.chars() {
w += match face.glyph(ch) {
Some(g) => (g.advance + face.spacing_after(ch) * state.tracking) * scale,
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
};
}
w
word_runs(text, &face)
.into_iter()
.map(|(run, shaped)| run_width(&face, run, shaped, state.tracking, scale))
.sum()
}
/// Per-character cells of a word laid out by the shaper — the visual
@@ -1049,17 +1166,54 @@ pub fn word_cells(
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
let face = Face::resolve(&font_name);
let family = face.ttf_family().or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
})?;
let run = crate::scene::text::ttf_glyph::shape_run(family, text)?;
Some(
crate::scene::text::ttf_glyph::char_cells(&run, text)
.into_iter()
.map(|(x0, x1)| (x0 * scale, x1 * scale))
.collect(),
)
let runs = word_runs(text, &face);
if !runs.iter().any(|(_, shaped)| *shaped) {
return None;
}
let mut cells = Vec::new();
let mut x = 0.0_f32;
for (run, shaped) in runs {
let shaped_run = shaped
.then(|| crate::scene::text::ttf_glyph::shape_run(run_family(&face), run))
.flatten();
match shaped_run {
Some(shaped_run) => {
cells.extend(
crate::scene::text::ttf_glyph::char_cells(&shaped_run, run)
.into_iter()
.zip(run.chars())
.filter(|(_, c)| *c != LRM)
.map(|((x0, x1), _)| (x + x0 * scale, x + x1 * scale)),
);
x += shaped_run.advance * scale;
}
None => {
for ch in run.chars().filter(|c| *c != LRM) {
let w = stroke_run_width(&face, ch.encode_utf8(&mut [0u8; 4]), state.tracking, scale);
cells.push((x, x + w));
x += w;
}
}
}
}
Some(cells)
}
/// The letter spacing a stroke-font word leaves after its last glyph (0 for
/// shaped words, whose advance carries no extra spacing).
fn trailing_letter_gap(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
let AtomKind::Word(text) = &atom.kind else {
return 0.0;
};
let font_name = resolve_font(&atom.state, base_font);
let face = Face::resolve(&font_name);
let Some((text, false)) = word_runs(text, &face).last().copied() else {
return 0.0;
};
text.chars()
.rev()
.find(|c| *c != LRM)
.map_or(0.0, |c| face.spacing_after(c) * atom.state.tracking * run_scale(&atom.state, entity_h, base_wf))
}
pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
@@ -1141,7 +1295,11 @@ pub fn wrap_paragraph(
AtomKind::Word(_) | AtomKind::Stack { .. } => {
let w = atom_width(&atom, entity_h, base_wf, base_font);
let max_w = line_max_w(subline_idx);
if !cur.is_empty() && cur_w + w > max_w && !after_align_tab {
let has_content = cur.iter().any(|a| !matches!(a.kind, AtomKind::Space));
// A word fits when its ink does: the letter spacing after its
// last glyph may run past the edge.
let ink_w = w - trailing_letter_gap(&atom, entity_h, base_wf, base_font);
if has_content && cur_w + ink_w > max_w && !after_align_tab {
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
cur.pop();
}
@@ -1155,7 +1313,9 @@ pub fn wrap_paragraph(
}
AtomKind::Space => {
after_align_tab = false;
if cur.is_empty() {
// A wrapped line does not start with the spaces it broke at;
// the paragraph's own leading spaces keep their width.
if cur.is_empty() && subline_idx > 0 {
continue;
}
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
@@ -1526,7 +1686,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
} else if ch == ' ' || ch == '\t' {
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
kind: AtomKind::Word(rtl_display_word(std::mem::take(&mut word))),
state: run.state.clone(),
char_offset: word_start,
});
@@ -1554,7 +1714,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
if let Some(prev) = word.chars().last() {
if cjk_break_between(prev, ch) {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
kind: AtomKind::Word(rtl_display_word(std::mem::take(&mut word))),
state: run.state.clone(),
char_offset: word_start,
});
@@ -1567,7 +1727,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
}
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(word),
kind: AtomKind::Word(rtl_display_word(word)),
state: run.state.clone(),
char_offset: word_start,
});
@@ -1603,52 +1763,24 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
}
}
// Trim leading + trailing Space atoms so line_w / cursor_start agree
// on the paragraph's visible content. Without this a stray trailing
// space measures wider than it draws and centring / right-alignment
// is off by half a space-width.
// Trailing Space atoms draw nothing; the line width that centring /
// right-alignment use leaves them out (see `line_w` below), while
// leading spaces keep their width, as in the reference application.
//
// Skipped when emitting glyph boxes (the MText editor) so a space the
// Kept when emitting glyph boxes (the MText editor) so a space the
// user just typed at the end keeps a selectable box and the caret can
// sit after it.
if !opts.want_glyph_boxes {
let first_word = atoms
.iter()
.position(|a| !matches!(a.kind, AtomKind::Space))
.unwrap_or(atoms.len());
atoms.drain(..first_word);
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
atoms.pop();
}
}
let is_rtl_para = {
let mut strong_rtl = None;
for atom in &atoms {
match &atom.kind {
AtomKind::Word(w) => {
for ch in w.chars() {
match unicode_bidi::bidi_class(ch) {
unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL => {
strong_rtl = Some(true);
break;
}
unicode_bidi::BidiClass::L => {
strong_rtl = Some(false);
break;
}
_ => {}
}
}
if strong_rtl.is_some() {
break;
}
}
_ => {}
}
}
strong_rtl.unwrap_or(false)
};
// The reference lays every MTEXT paragraph out left to right, also
// one that starts with Hebrew or Arabic: alignment follows the
// attachment, and only right-to-left runs read backwards in place
// (`reorder_rtl_runs`).
let is_rtl_para = false;
// Wrap to the column the text actually flows down, not to the block:
// measuring against the full width would let a line run across the
@@ -1678,7 +1810,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
let atoms = if opts.vertical_text {
atoms
} else {
reorder_line_atoms(atoms, is_rtl_para)
reorder_rtl_runs(atoms)
};
sub_lines.push(SubLine {
atoms,
@@ -2123,15 +2255,26 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
}
};
let visible_atoms = sub
.atoms
.iter()
.rposition(|a| !matches!(a.kind, AtomKind::Space))
.map_or(0, |i| i + 1);
let line_w = line_total_width(
&sub.atoms,
&sub.atoms[..visible_atoms],
entity_h,
base_wf,
&base_font_name,
0.0,
sub.indent_left,
&sub.tab_stops,
);
)
// Aligned on the last glyph's ink: the letter spacing after it is not
// part of the width centring / right alignment use (the reference
// puts a right-aligned line's last stroke on the edge).
- sub.atoms[..visible_atoms]
.last()
.map_or(0.0, |atom| trailing_letter_gap(atom, entity_h, base_wf, &base_font_name));
line_widths.push(line_w);
let cursor_start = if rect_w > 0.0 {
@@ -2324,41 +2467,60 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
ins_y + (line_base_y + world_dy) as f64,
];
// Glyphs from the plain word — the SDF run and the stroke
// fallback both draw clean characters.
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
tracking,
&font_name,
text,
);
let glyph_n = strokes.len();
all_strokes.push(TextStroke {
strokes,
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
// fallback both draw clean characters. A mixed word draws
// run by run (see `word_runs`), each at its own pen.
let face = Face::resolve(&font_name);
let run_scale_x = run_h / 9.0 * signed_wf.abs();
let mut run_x = 0.0_f32;
for (run_text, shaped) in word_runs(text, &face) {
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
tracking,
bold: atom.state.bold,
}),
});
&font_name,
run_text,
);
let (dx, dy) = (run_x * cos_r, run_x * sin_r);
all_strokes.push(TextStroke {
strokes,
origin: [origin[0] + dx as f64, origin[1] + dy as f64],
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: run_text.to_string(),
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
oblique,
tracking,
bold: atom.state.bold,
}),
});
run_x += run_width(&face, run_text, shaped, tracking, run_scale_x);
}
// Underline / overline / strike are lines, not glyphs; a
// run-group's strokes are suppressed by the SDF path, so
// emit the decorations in their own RUN-LESS group. Reuse
// lff's exact positions by tessellating the decorated word
// and taking the strokes it appends after the glyphs.
if atom.state.underline || atom.state.overline || atom.state.strike {
let glyph_n = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
tracking,
&font_name,
text,
)
.0
.len();
let body = decorated(text, &atom.state);
let (deco, _) = lff::tessellate_text_run(
[0.0, 0.0],
@@ -2383,7 +2545,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
}
if opts.want_glyph_boxes {
let run_h = atom.state.height_mul * entity_h;
let count = text.chars().count();
let count = text.chars().filter(|c| *c != LRM).count();
let is_rtl = text.chars().any(is_rtl_char);
if is_rtl && count > 0 {
let word_w = if tracking != atom.state.tracking {
@@ -2429,7 +2591,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
let face = Face::resolve(&font_name);
let shaped = word_cells(text, &atom.state, entity_h, base_wf, &base_font_name);
let mut cx = cursor_x;
for (ci, ch) in text.chars().enumerate() {
for (ci, ch) in text.chars().filter(|c| *c != LRM).enumerate() {
let adv = match face.glyph(ch) {
Some(g) => {
(g.advance + face.spacing_after(ch) * tracking) * scale
+21 -4
View File
@@ -603,6 +603,9 @@ pub fn char_cells(run: &ShapedRun, text: &str) -> Vec<(f32, f32)> {
.collect()
}
/// Left-to-right mark shaped ahead of every run.
const BASE_MARK: char = '‎';
type ShapeCache = HashMap<(String, String), Option<Arc<ShapedRun>>>;
fn shape_cache() -> &'static Mutex<ShapeCache> {
@@ -901,7 +904,11 @@ fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
let attrs = Attrs::new().family(Family::SansSerif);
let mut buffer = Buffer::new(&mut font_system, Metrics::new(SHAPE_FS, SHAPE_FS));
buffer.set_size(&mut font_system, None, None);
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
// A leading left-to-right mark keeps the paragraph direction left to
// right, as the reference lays text out (right-to-left runs still read
// backwards); `cells` stay relative to `text`.
let marked = format!("{BASE_MARK}{text}");
buffer.set_text(&mut font_system, &marked, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let mut glyphs = Vec::new();
@@ -910,7 +917,10 @@ fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
for run in buffer.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for glyph in run.glyphs.iter() {
cells.push((glyph.start, glyph.end, glyph.x * px_to_9, (glyph.x + glyph.w) * px_to_9));
if glyph.start >= BASE_MARK.len_utf8() {
let m = BASE_MARK.len_utf8();
cells.push((glyph.start - m, glyph.end - m, glyph.x * px_to_9, (glyph.x + glyph.w) * px_to_9));
}
let face_index = font_system
.db_mut()
.face(glyph.font_id)
@@ -978,7 +988,11 @@ fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
// No wrapping: a run is a single line.
buf.set_size(&mut fs, None, None);
buf.set_text(&mut fs, text, &attrs, Shaping::Advanced, None);
// A leading left-to-right mark keeps the paragraph direction left to
// right, as the reference lays text out (right-to-left runs still read
// backwards); `cells` stay relative to `text`.
let marked = format!("{BASE_MARK}{text}");
buf.set_text(&mut fs, &marked, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
if primary_metrics.is_none() {
@@ -1017,7 +1031,10 @@ fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
for run in buf.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for g in run.glyphs.iter() {
cells.push((g.start, g.end, g.x * px_to_9, (g.x + g.w) * px_to_9));
if g.start >= BASE_MARK.len_utf8() {
let m = BASE_MARK.len_utf8();
cells.push((g.start - m, g.end - m, g.x * px_to_9, (g.x + g.w) * px_to_9));
}
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let Some(font) = fs.get_font(g.font_id, g.font_weight) else {
continue;