diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 291e2ffc4..384d76ad7 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -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 { 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 { }; // 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: [ diff --git a/src/entities/text.rs b/src/entities/text.rs index f6ba95ed8..06c7bedc1 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -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). diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 2c1e36827..06f8f9ec3 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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) -> Vec { + 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, is_rtl: bool) -> Vec { 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 diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 50ec1ab85..a40c85690 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -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>>; fn shape_cache() -> &'static Mutex { @@ -901,7 +904,11 @@ fn build_shaped(_family: &str, text: &str) -> Option { 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 { 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 { 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 { 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;