Skip to main content

text_typeset/layout/
block.rs

1use crate::font::registry::FontRegistry;
2use crate::font::resolve::{ResolvedFont, resolve_font};
3use crate::layout::line::LayoutLine;
4use crate::layout::paragraph::{Alignment, Hyphenator, RunOrder, break_into_lines};
5use crate::shaping::run::{ShapedGlyph, ShapedRun};
6use crate::shaping::shaper::{
7    BidiParagraph, FontMetricsPx, TextDirection, analyze_paragraph, font_metrics_px, shape_text,
8    shape_text_with_fallback, to_harfrust_features,
9};
10
11/// Computed layout for a single block (paragraph).
12#[derive(Clone)]
13pub struct BlockLayout {
14    pub block_id: usize,
15    /// Document character position of the block start.
16    pub position: usize,
17    /// Laid out lines within the block.
18    pub lines: Vec<LayoutLine>,
19    /// Top edge relative to document start (set by flow layout).
20    pub y: f32,
21    /// Total height: top_margin + sum(line heights) + bottom_margin.
22    pub height: f32,
23    pub top_margin: f32,
24    pub bottom_margin: f32,
25    pub left_margin: f32,
26    pub right_margin: f32,
27    /// Shaped list marker (positioned to the left of the content area).
28    /// None if the block is not a list item.
29    pub list_marker: Option<ShapedListMarker>,
30    /// The paragraph's *resolved* base direction — never `Auto`.
31    ///
32    /// `BlockLayoutParams::base_direction` may ask for auto-detection;
33    /// this is what the bidi analysis actually settled on. Selection
34    /// painting and caret motion need the answer, not the question.
35    pub base_direction: TextDirection,
36    /// Block background color (RGBA). None means transparent.
37    pub background_color: Option<[f32; 4]>,
38}
39
40/// A shaped list marker ready for rendering.
41#[derive(Clone)]
42pub struct ShapedListMarker {
43    pub run: ShapedRun,
44    /// X position of the marker (relative to block left edge, before content indent).
45    pub x: f32,
46}
47
48/// Parameters extracted from text-document's BlockFormat / TextFormat.
49/// This is a plain struct so block layout doesn't depend on text-document types.
50#[derive(Clone)]
51pub struct BlockLayoutParams {
52    pub block_id: usize,
53    pub position: usize,
54    pub text: String,
55    pub fragments: Vec<FragmentParams>,
56    pub alignment: Alignment,
57    pub top_margin: f32,
58    pub bottom_margin: f32,
59    pub left_margin: f32,
60    pub right_margin: f32,
61    pub text_indent: f32,
62    /// List marker text (e.g., "1.", "•", "a)"). Empty if not a list item.
63    pub list_marker: String,
64    /// Additional left indent for list items (in pixels).
65    pub list_indent: f32,
66    /// Tab stop positions in pixels from the left margin.
67    pub tab_positions: Vec<f32>,
68    /// The paragraph's base reading direction.
69    ///
70    /// `Auto` applies UAX #9 rules P2/P3 (first strong character wins).
71    /// An explicit direction overrides that, which is what a stored
72    /// per-block direction is for: auto-detection reads an Arabic
73    /// paragraph opening with a Latin acronym as left-to-right.
74    ///
75    /// Drives three things: which direction each bidi run shapes with,
76    /// the visual order of a line's runs, and — when `alignment` is
77    /// `Start`/`End` — which edge the text sits against.
78    pub base_direction: TextDirection,
79    /// Line height multiplier. 1.0 = normal (from font metrics), 1.5 = 150%, 2.0 = double.
80    /// None means use font metrics (ascent + descent + leading).
81    pub line_height_multiplier: Option<f32>,
82    /// If true, prevent line wrapping. The entire block is one long line.
83    pub non_breakable_lines: bool,
84    /// Hyphenation during line wrapping (`None` = off). See
85    /// [`crate::types::Hyphenation`].
86    pub hyphenation: Option<crate::types::Hyphenation>,
87    /// Checkbox marker: None = no checkbox, Some(false) = unchecked, Some(true) = checked.
88    pub checkbox: Option<bool>,
89    /// Block background color (RGBA). None means transparent.
90    pub background_color: Option<[f32; 4]>,
91}
92
93/// A text fragment with its formatting parameters.
94#[derive(Clone)]
95pub struct FragmentParams {
96    pub text: String,
97    /// **Byte** offset of this fragment's first character inside the
98    /// owning block's text. Lifted into glyph clusters by
99    /// `paragraph::flatten_runs` so glyph clusters
100    /// can be compared directly against `unicode-linebreak` break
101    /// positions (also bytes) and against the block-level text used
102    /// for `byte_offset_to_char_offset` conversion. Hosts threading
103    /// text-document `FragmentContent` through the bridge must
104    /// translate the char-based `FragmentContent::offset` into bytes
105    /// before assigning here.
106    pub offset: usize,
107    pub length: usize,
108    pub font_family: Option<String>,
109    pub font_weight: Option<u32>,
110    pub font_bold: Option<bool>,
111    pub font_italic: Option<bool>,
112    pub font_point_size: Option<u32>,
113    pub underline_style: crate::types::UnderlineStyle,
114    pub overline: bool,
115    pub strikeout: bool,
116    pub is_link: bool,
117    /// Extra space added after each glyph (in pixels). From TextFormat::letter_spacing.
118    pub letter_spacing: f32,
119    /// Extra space added after space glyphs (in pixels). From TextFormat::word_spacing.
120    pub word_spacing: f32,
121    /// Text foreground color (RGBA). None means default (black).
122    pub foreground_color: Option<[f32; 4]>,
123    /// Underline color (RGBA). None means use foreground_color.
124    pub underline_color: Option<[f32; 4]>,
125    /// Text-level background highlight color (RGBA). None means transparent.
126    pub background_color: Option<[f32; 4]>,
127    /// Hyperlink destination URL.
128    pub anchor_href: Option<String>,
129    /// Tooltip text.
130    pub tooltip: Option<String>,
131    /// Vertical alignment (normal, superscript, subscript).
132    pub vertical_alignment: crate::types::VerticalAlignment,
133    /// If Some, this fragment represents an inline image placeholder.
134    pub image_name: Option<String>,
135    /// Image width in pixels. Only meaningful when image_name is Some.
136    pub image_width: f32,
137    /// Image height in pixels. Only meaningful when image_name is Some.
138    pub image_height: f32,
139    /// If Some, this fragment is a footnote reference and this is the marker
140    /// to draw at its position — a number, usually, but the host decides.
141    ///
142    /// The fragment still occupies exactly the **one** `U+FFFC` character its
143    /// `text` holds, however many glyphs the marker shapes to. That mismatch is
144    /// the whole point and the whole hazard: the marker is *presentation*, and
145    /// the document must not grow a character because a footnote reached two
146    /// digits. So the marker is shaped on its own and every resulting glyph is
147    /// pinned to the sentinel's cluster, which is what stops the caret landing
148    /// between the `1` and the `2` of note twelve.
149    ///
150    /// Distinct from [`Self::image_name`] rather than folded in with it: an
151    /// image reserves a box of a size the host measured, a marker reserves
152    /// whatever its own glyphs advance to, and only the latter needs a font.
153    pub footnote_marker: Option<String>,
154    /// Discretionary OpenType features to toggle during shaping. Empty =
155    /// font defaults. See [`crate::types::FontFeature`].
156    pub features: Vec<crate::types::FontFeature>,
157}
158
159/// The directional slices of one fragment, in logical order.
160///
161/// Intersects the fragment's byte span with the paragraph's bidi runs and
162/// yields `(span, direction, level)` for each overlap. A fragment lying
163/// wholly inside one bidi run — the overwhelmingly common case — yields a
164/// single slice covering it, so uniform text costs nothing extra.
165///
166/// Falls back to one `Auto` slice when the analysis produced no runs
167/// (empty block text), which preserves the pre-bidi behaviour.
168fn fragment_bidi_slices(
169    bidi: &BidiParagraph,
170    frag: &FragmentParams,
171) -> Vec<(std::ops::Range<usize>, TextDirection, u8)> {
172    let frag_end = frag.offset + frag.text.len();
173
174    if bidi.runs.is_empty() {
175        return vec![(frag.offset..frag_end, TextDirection::Auto, 0)];
176    }
177
178    let mut slices = Vec::new();
179    for run in &bidi.runs {
180        let start = run.byte_range.start.max(frag.offset);
181        let end = run.byte_range.end.min(frag_end);
182        if start < end {
183            slices.push((start..end, run.direction, run.level));
184        }
185    }
186
187    // A fragment outside the analysed text (a host bug, or a zero-length
188    // fragment) still deserves a shot at shaping rather than vanishing.
189    if slices.is_empty() {
190        slices.push((frag.offset..frag_end, TextDirection::Auto, 0));
191    }
192    slices
193}
194
195/// Lay out a single block: resolve fonts, shape fragments, break into lines.
196///
197/// `scale_factor` is the device pixel ratio. Layout output is always in
198/// logical pixels; the scale factor affects shaping/rasterization precision.
199pub fn layout_block(
200    registry: &FontRegistry,
201    params: &BlockLayoutParams,
202    available_width: f32,
203    scale_factor: f32,
204    font_scale: f32,
205) -> BlockLayout {
206    let effective_left_margin = params.left_margin + params.list_indent;
207    let content_width = (available_width - effective_left_margin - params.right_margin).max(0.0);
208
209    // Resolve the paragraph's bidi structure once, over the whole block
210    // text. It has to be the whole text: the algorithm's resolution of a
211    // neutral character (a space, a comma) depends on the strong
212    // characters on *both* sides of it, which a per-fragment analysis
213    // cannot see.
214    let bidi = analyze_paragraph(&params.text, params.base_direction);
215    let base_direction = bidi.base_direction();
216
217    // Resolve fonts and shape each fragment
218    let mut shaped_runs = Vec::new();
219    let mut default_metrics: Option<FontMetricsPx> = None;
220
221    for frag in &params.fragments {
222        // Inline image: create a synthetic run with one placeholder glyph
223        if let Some(ref image_name) = frag.image_name {
224            // An image is a neutral character in the bidi algorithm, so
225            // it takes the level of whatever run covers its offset.
226            // Hardcoding 0 left it at paragraph level inside RTL prose,
227            // where it broke the contiguous run rule L2 reverses and so
228            // stayed on the wrong side of the text around it.
229            let (image_direction, image_level) = fragment_bidi_slices(&bidi, frag)
230                .first()
231                .map(|(_, d, l)| (*d, *l))
232                .unwrap_or((TextDirection::LeftToRight, 0));
233            let image_glyph = ShapedGlyph {
234                glyph_id: 0,
235                cluster: 0,
236                x_advance: frag.image_width,
237                y_advance: 0.0,
238                x_offset: 0.0,
239                y_offset: 0.0,
240                font_face_id: crate::types::FontFaceId(0),
241            };
242            let run = ShapedRun {
243                font_face_id: crate::types::FontFaceId(0),
244                size_px: 0.0,
245                weight: 400,
246                glyphs: vec![image_glyph],
247                advance_width: frag.image_width,
248                text_range: frag.offset..frag.offset + frag.text.len(),
249                direction: image_direction,
250                bidi_level: image_level,
251                underline_style: frag.underline_style,
252                overline: false,
253                strikeout: false,
254                is_link: frag.is_link,
255                foreground_color: None,
256                underline_color: None,
257                background_color: None,
258                anchor_href: frag.anchor_href.clone(),
259                tooltip: frag.tooltip.clone(),
260                vertical_alignment: crate::types::VerticalAlignment::Normal,
261                image_name: Some(image_name.clone()),
262                image_height: frag.image_height,
263            };
264            shaped_runs.push(run);
265            continue;
266        }
267
268        // Scale font size for superscript/subscript
269        let font_point_size = match frag.vertical_alignment {
270            crate::types::VerticalAlignment::SuperScript
271            | crate::types::VerticalAlignment::SubScript => frag
272                .font_point_size
273                .map(|s| ((s as f32 * 0.65) as u32).max(1)),
274            crate::types::VerticalAlignment::Normal => frag.font_point_size,
275        };
276
277        let resolved = resolve_font(
278            registry,
279            frag.font_family.as_deref(),
280            frag.font_weight,
281            frag.font_bold,
282            frag.font_italic,
283            font_point_size,
284            scale_factor,
285            font_scale,
286        );
287
288        if let Some(resolved) = resolved {
289            // Capture default metrics from the first resolved font
290            if default_metrics.is_none() {
291                default_metrics = font_metrics_px(registry, &resolved);
292            }
293
294            let features = to_harfrust_features(&frag.features);
295
296            // A footnote reference: shape the *marker* and pin it to the
297            // sentinel.
298            //
299            // The fragment's own text is one `U+FFFC`, three bytes wide and one
300            // character long; the marker is whatever the host wants drawn there.
301            // Shaping the marker through the ordinary path below is not an
302            // option — that path slices `frag.text` by byte range, so a marker
303            // longer than the sentinel would read past it, and a marker shorter
304            // than it would leave a coverage gap. Shaping it standalone and
305            // then overwriting the run's own coordinates is the same graft
306            // `append_hyphen` performs for a hyphen that is likewise not in the
307            // text.
308            //
309            // Every glyph is collapsed onto the sentinel's cluster. Clusters are
310            // what hit-testing, caret movement and line breaking all read, so
311            // one shared value makes the marker atomic in all three at once: a
312            // click anywhere in it resolves to the sentinel, the caret cannot
313            // stop inside it, and a line cannot break within it.
314            //
315            // Deliberately no `image_height`: that field is what makes
316            // `break_into_lines` inflate a line to fit, and an inflated line is
317            // then excluded from the interline multiplier. A marker is smaller
318            // than the text it rides on and must stay an ordinary line, or a
319            // footnote would quietly change the leading of the paragraph
320            // holding it.
321            if let Some(marker) = frag.footnote_marker.as_deref() {
322                let (direction, level) = fragment_bidi_slices(&bidi, frag)
323                    .first()
324                    .map(|(_, d, l)| (*d, *l))
325                    .unwrap_or((TextDirection::LeftToRight, 0));
326
327                if let Some(mut run) =
328                    shape_text_with_fallback(registry, &resolved, marker, 0, direction, &features)
329                {
330                    // Cluster 0, not `frag.offset`: clusters are **run-local**
331                    // here and `flatten_runs` lifts them by `text_range.start`
332                    // on the way out. Pinning them to the absolute offset
333                    // instead double-counts it, which puts every stop past the
334                    // end of the run — the caret after a footnote then lands
335                    // wherever the *next* run happens to start, and a marker of
336                    // two digits measures the same as one of one. The image
337                    // branch above uses the same `cluster: 0` for the same
338                    // reason.
339                    for glyph in &mut run.glyphs {
340                        glyph.cluster = 0;
341                    }
342                    run.text_range = frag.offset..frag.offset + frag.text.len();
343                    run.bidi_level = level;
344                    run.underline_style = frag.underline_style;
345                    run.overline = frag.overline;
346                    run.strikeout = frag.strikeout;
347                    run.is_link = frag.is_link;
348                    run.foreground_color = frag.foreground_color;
349                    run.underline_color = frag.underline_color;
350                    run.background_color = frag.background_color;
351                    run.anchor_href = frag.anchor_href.clone();
352                    run.tooltip = frag.tooltip.clone();
353                    run.vertical_alignment = frag.vertical_alignment;
354                    shaped_runs.push(run);
355                }
356                continue;
357            }
358
359            // Shape each directional slice of the fragment separately. A
360            // fragment is a *formatting* span (one bold/italic/font run),
361            // which has nothing to do with where the text changes
362            // direction — so shaping a fragment as a single unit is what
363            // left mixed Arabic/Latin prose in raw logical order. Cutting
364            // it at the bidi boundaries gives every run one uniform
365            // direction, and `break_into_lines` reorders them per line.
366            for (span, direction, level) in fragment_bidi_slices(&bidi, frag) {
367                let local = (span.start - frag.offset)..(span.end - frag.offset);
368                let Some(piece) = frag.text.get(local) else {
369                    // The host's fragment offsets disagree with the block
370                    // text it also supplied. Nothing good can come of
371                    // guessing which is right; skip the slice rather than
372                    // panic on a bad byte index.
373                    continue;
374                };
375                if piece.is_empty() {
376                    continue;
377                }
378
379                if let Some(mut run) = shape_text_with_fallback(
380                    registry, &resolved, piece, span.start, direction, &features,
381                ) {
382                    run.bidi_level = level;
383                    run.underline_style = frag.underline_style;
384                    run.overline = frag.overline;
385                    run.strikeout = frag.strikeout;
386                    run.is_link = frag.is_link;
387                    run.foreground_color = frag.foreground_color;
388                    run.underline_color = frag.underline_color;
389                    run.background_color = frag.background_color;
390                    run.anchor_href = frag.anchor_href.clone();
391                    run.tooltip = frag.tooltip.clone();
392                    run.vertical_alignment = frag.vertical_alignment;
393
394                    // Both of these map `glyph.cluster` back into the text
395                    // they were shaped from, so they get the slice — not
396                    // the whole fragment.
397                    if frag.letter_spacing != 0.0 || frag.word_spacing != 0.0 {
398                        apply_spacing(&mut run, piece, frag.letter_spacing, frag.word_spacing);
399                    }
400                    if !params.tab_positions.is_empty() {
401                        apply_tab_stops(&mut run, piece, &params.tab_positions);
402                    }
403
404                    shaped_runs.push(run);
405                }
406            }
407        }
408    }
409
410    // Fallback metrics if no fragments resolved
411    let metrics =
412        default_metrics.unwrap_or_else(|| get_default_metrics(registry, scale_factor, font_scale));
413
414    // Non-breakable lines: use infinite width to prevent wrapping
415    let wrap_width = if params.non_breakable_lines {
416        f32::INFINITY
417    } else {
418        content_width
419    };
420
421    // Hyphenation: a hyphen glyph shaped in the default font, supplied only
422    // when enabled and wrapping is in effect.
423    let hyphenator = params
424        .hyphenation
425        .filter(|_| !params.non_breakable_lines)
426        .and_then(|h| {
427            shape_hyphen(registry, scale_factor, font_scale).map(|glyph| Hyphenator {
428                glyph,
429                language: h.language,
430            })
431        });
432
433    // Break shaped runs into lines
434    let mut lines = break_into_lines(
435        shaped_runs,
436        &params.text,
437        wrap_width,
438        params.alignment,
439        params.text_indent,
440        &metrics,
441        hyphenator,
442        RunOrder::Logical(base_direction),
443    );
444
445    // Apply line height multiplier.
446    //
447    // The multiplier expresses "N times the font's own line box", so it must
448    // stretch only that font-driven space. A line holding an inline image
449    // taller than the font's ascent already reports `line_height` inflated to
450    // fit the picture (`break_into_lines`, paragraph.rs), and multiplying THAT
451    // adds a fraction of the *image's* height as blank space below it — a 1.5×
452    // interline setting turned a 200px image into ~100px of dead space before
453    // the next block, which is the "huge gap under an image" report.
454    //
455    // So the multiplier is applied only to lines that were not image-inflated,
456    // which leaves it working in both directions. Comparing against a *scaled*
457    // natural height instead would silently cancel any multiplier below 1.0 for
458    // every line in the block, image or not — and sub-1.0 line heights arrive
459    // unclamped from imported HTML (`line-height: 0.8`) and from djot's
460    // `{line_height=800}`.
461    let line_height_mul = params.line_height_multiplier.unwrap_or(1.0).max(0.1);
462    let natural_line_height = metrics.ascent + metrics.descent + metrics.leading;
463
464    // Compute y positions for each line (relative to block content top)
465    let mut y = 0.0f32;
466    for line in &mut lines {
467        if line_height_mul != 1.0 && line.line_height <= natural_line_height + f32::EPSILON {
468            line.line_height *= line_height_mul;
469        }
470        line.y = y + line.ascent; // y is the baseline position
471        y += line.line_height;
472    }
473
474    let content_height = y;
475    let total_height = params.top_margin + content_height + params.bottom_margin;
476
477    // Shape list marker or checkbox marker
478    let list_marker = if params.checkbox.is_some() {
479        shape_checkbox_marker(registry, &metrics, params, scale_factor, font_scale)
480    } else if !params.list_marker.is_empty() {
481        shape_list_marker(registry, &metrics, params, scale_factor, font_scale)
482    } else {
483        None
484    };
485
486    BlockLayout {
487        block_id: params.block_id,
488        position: params.position,
489        base_direction,
490        lines,
491        y: 0.0, // set by flow layout
492        height: total_height,
493        top_margin: params.top_margin,
494        bottom_margin: params.bottom_margin,
495        left_margin: effective_left_margin,
496        right_margin: params.right_margin,
497        list_marker,
498        background_color: params.background_color,
499    }
500}
501
502/// A resolved paint-only color overlay span for one character range of a block.
503///
504/// `char_start`/`char_end` are **block-relative character offsets** — the same
505/// space as the post-layout `ShapedGlyph::cluster` values (see
506/// `break_into_lines`, which converts clusters to char offsets). Each field is
507/// `None` when the overlay does not override it (the base run's value is kept).
508/// Applying paint spans never changes glyph geometry, advances, or line breaks
509/// — only color / decoration attributes — so the layout does not reflow.
510#[derive(Clone, Debug, Default, PartialEq)]
511pub struct PaintSpan {
512    pub char_start: usize,
513    pub char_end: usize,
514    pub foreground_color: Option<[f32; 4]>,
515    pub underline_color: Option<[f32; 4]>,
516    pub background_color: Option<[f32; 4]>,
517    pub underline_style: Option<crate::types::UnderlineStyle>,
518    pub overline: Option<bool>,
519    pub strikeout: Option<bool>,
520}
521
522/// The effective set of overrides for one glyph, used to group consecutive
523/// glyphs that share the same paint result into a single output run.
524#[derive(Clone, Default, PartialEq)]
525struct PaintOverride {
526    foreground_color: Option<[f32; 4]>,
527    underline_color: Option<[f32; 4]>,
528    background_color: Option<[f32; 4]>,
529    underline_style: Option<crate::types::UnderlineStyle>,
530    overline: Option<bool>,
531    strikeout: Option<bool>,
532}
533
534impl PaintOverride {
535    fn is_noop(&self) -> bool {
536        *self == PaintOverride::default()
537    }
538
539    /// Merge the overlapping spans covering `char_off` (last span wins per
540    /// field). Overlay spans from `extract_paint_spans` are already disjoint,
541    /// but last-wins keeps this correct for arbitrary inputs.
542    fn for_char(char_off: usize, spans: &[PaintSpan]) -> Self {
543        let mut o = PaintOverride::default();
544        for s in spans {
545            if s.char_start <= char_off && char_off < s.char_end {
546                if s.foreground_color.is_some() {
547                    o.foreground_color = s.foreground_color;
548                }
549                if s.underline_color.is_some() {
550                    o.underline_color = s.underline_color;
551                }
552                if s.background_color.is_some() {
553                    o.background_color = s.background_color;
554                }
555                if s.underline_style.is_some() {
556                    o.underline_style = s.underline_style;
557                }
558                if s.overline.is_some() {
559                    o.overline = s.overline;
560                }
561                if s.strikeout.is_some() {
562                    o.strikeout = s.strikeout;
563                }
564            }
565        }
566        o
567    }
568
569    /// Apply this override onto a positioned run segment, writing color /
570    /// decoration fields on BOTH the shaped run and its duplicated
571    /// `RunDecorations` (the renderer reads glyph color from the former and
572    /// decoration rects from the latter). `None` fields keep the base value.
573    fn apply(&self, run: &mut crate::layout::line::PositionedRun) {
574        if let Some(c) = self.foreground_color {
575            run.shaped_run.foreground_color = Some(c);
576            run.decorations.foreground_color = Some(c);
577        }
578        if let Some(c) = self.underline_color {
579            run.shaped_run.underline_color = Some(c);
580            run.decorations.underline_color = Some(c);
581        }
582        if let Some(c) = self.background_color {
583            run.shaped_run.background_color = Some(c);
584            run.decorations.background_color = Some(c);
585        }
586        if let Some(s) = self.underline_style {
587            run.shaped_run.underline_style = s;
588            run.decorations.underline_style = s;
589        }
590        if let Some(b) = self.overline {
591            run.shaped_run.overline = b;
592            run.decorations.overline = b;
593        }
594        if let Some(b) = self.strikeout {
595            run.shaped_run.strikeout = b;
596            run.decorations.strikeout = b;
597        }
598    }
599}
600
601/// Apply paint-only color spans to a base [`BlockLayout`], returning a recolored
602/// clone. The base is left untouched.
603///
604/// The result has byte-identical glyph positions, advances, line breaks, line
605/// widths, and block height to `base` — only color / decoration attributes
606/// differ. This is the "recolor without reshape/reflow" fast path: a run is
607/// split into segments at paint-span boundaries (snapped to glyph/cluster
608/// boundaries, never mid-cluster) and each segment's color fields are set.
609/// Splitting a run never alters any glyph advance, so line widths are preserved.
610///
611/// Empty `spans` returns an exact (color-preserving) clone of `base`.
612pub fn apply_paint_spans(base: &BlockLayout, spans: &[PaintSpan]) -> BlockLayout {
613    let mut out = base.clone();
614    if spans.is_empty() {
615        return out;
616    }
617    for line in &mut out.lines {
618        let mut new_runs: Vec<crate::layout::line::PositionedRun> =
619            Vec::with_capacity(line.runs.len());
620        for run in line.runs.drain(..) {
621            recolor_run_into(run, spans, &mut new_runs);
622        }
623        line.runs = new_runs;
624    }
625    out
626}
627
628/// Split `run` at paint-span boundaries and push the recolored segment(s) onto
629/// `out`. Image / glyph-less runs are passed through unchanged (paint overlays
630/// never recolor images).
631fn recolor_run_into(
632    run: crate::layout::line::PositionedRun,
633    spans: &[PaintSpan],
634    out: &mut Vec<crate::layout::line::PositionedRun>,
635) {
636    if run.shaped_run.glyphs.is_empty() || run.shaped_run.image_name.is_some() {
637        out.push(run);
638        return;
639    }
640
641    // Per-glyph effective override, in glyph (visual) order. Works for LTR and
642    // RTL alike: we group by adjacency in the glyph array, not by char order.
643    let overrides: Vec<PaintOverride> = run
644        .shaped_run
645        .glyphs
646        .iter()
647        .map(|g| PaintOverride::for_char(g.cluster as usize, spans))
648        .collect();
649
650    // Fast path: the whole run shares one override (the common case, and the
651    // only case when `spans` doesn't touch this run — then it's a no-op). Keep
652    // the base `advance_width` exactly so a cleared/uncovered run is identical.
653    if overrides.iter().all(|o| *o == overrides[0]) {
654        let mut seg = run;
655        overrides[0].apply(&mut seg);
656        out.push(seg);
657        return;
658    }
659
660    // Split into maximal runs of equal override.
661    let glyphs = run.shaped_run.glyphs.clone();
662    let mut seg_x = run.x;
663    let mut start = 0usize;
664    while start < glyphs.len() {
665        let ov = &overrides[start];
666        let mut end = start + 1;
667        while end < glyphs.len() && overrides[end] == *ov {
668            end += 1;
669        }
670        let seg_glyphs: Vec<crate::shaping::run::ShapedGlyph> = glyphs[start..end].to_vec();
671        let seg_advance: f32 = seg_glyphs.iter().map(|g| g.x_advance).sum();
672        let mut shaped = run.shaped_run.clone();
673        shaped.glyphs = seg_glyphs;
674        shaped.advance_width = seg_advance;
675        let mut seg = crate::layout::line::PositionedRun {
676            shaped_run: shaped,
677            x: seg_x,
678            decorations: run.decorations.clone(),
679        };
680        if !ov.is_noop() {
681            ov.apply(&mut seg);
682        }
683        out.push(seg);
684        seg_x += seg_advance;
685        start = end;
686    }
687}
688
689/// Add letter_spacing (to all glyphs) and word_spacing (to space glyphs).
690fn apply_spacing(run: &mut ShapedRun, text: &str, letter_spacing: f32, word_spacing: f32) {
691    let mut extra_advance = 0.0f32;
692    for glyph in &mut run.glyphs {
693        glyph.x_advance += letter_spacing;
694        extra_advance += letter_spacing;
695
696        // Add word_spacing to space characters.
697        // Detect spaces by mapping cluster back to the text.
698        if word_spacing != 0.0 {
699            let byte_offset = glyph.cluster as usize;
700            if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
701                && ch == ' '
702            {
703                glyph.x_advance += word_spacing;
704                extra_advance += word_spacing;
705            }
706        }
707    }
708    run.advance_width += extra_advance;
709}
710
711/// Shape a hyphen glyph (`-`) in the default font, for appending at
712/// hyphenated line breaks. Returns `None` if no default font resolves.
713pub(crate) fn shape_hyphen(
714    registry: &FontRegistry,
715    scale_factor: f32,
716    font_scale: f32,
717) -> Option<ShapedGlyph> {
718    let resolved = resolve_font(
719        registry,
720        None,
721        None,
722        None,
723        None,
724        None,
725        scale_factor,
726        font_scale,
727    )?;
728    let run = shape_text(registry, &resolved, "-", 0)?;
729    run.glyphs.into_iter().next()
730}
731
732/// Shape the list marker text and position it in the indent area.
733fn shape_list_marker(
734    registry: &FontRegistry,
735    _metrics: &FontMetricsPx,
736    params: &BlockLayoutParams,
737    scale_factor: f32,
738    font_scale: f32,
739) -> Option<ShapedListMarker> {
740    // Use the default font for the marker
741    let resolved = resolve_font(
742        registry,
743        None,
744        None,
745        None,
746        None,
747        None,
748        scale_factor,
749        font_scale,
750    )?;
751    let run = shape_text(registry, &resolved, &params.list_marker, 0)?;
752
753    // Position the marker: right-aligned within the indent area, with a small gap
754    let gap = 4.0; // pixels between marker and content
755    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
756    let marker_x = marker_x.max(params.left_margin);
757
758    Some(ShapedListMarker { run, x: marker_x })
759}
760
761/// Expand tab character advances to reach the next tab stop position.
762fn apply_tab_stops(run: &mut ShapedRun, text: &str, tab_positions: &[f32]) {
763    let default_tab = 48.0; // default tab width if no stops defined
764    let mut pen_x = 0.0f32;
765
766    for glyph in &mut run.glyphs {
767        let byte_offset = glyph.cluster as usize;
768        if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
769            && ch == '\t'
770        {
771            // Find the next tab stop after the current pen position
772            let next_stop = tab_positions
773                .iter()
774                .find(|&&stop| stop > pen_x + 1.0)
775                .copied()
776                .unwrap_or_else(|| {
777                    // Past all defined stops: use default tab increments
778                    let last = tab_positions.last().copied().unwrap_or(0.0);
779                    let increment = if tab_positions.len() >= 2 {
780                        tab_positions[1] - tab_positions[0]
781                    } else {
782                        default_tab
783                    };
784                    let mut stop = last + increment;
785                    while stop <= pen_x + 1.0 {
786                        stop += increment;
787                    }
788                    stop
789                });
790
791            let tab_advance = next_stop - pen_x;
792            let delta = tab_advance - glyph.x_advance;
793            glyph.x_advance = tab_advance;
794            run.advance_width += delta;
795        }
796        pen_x += glyph.x_advance;
797    }
798}
799
800/// Shape a checkbox marker (unchecked or checked) for rendering in the margin.
801fn shape_checkbox_marker(
802    registry: &FontRegistry,
803    _metrics: &FontMetricsPx,
804    params: &BlockLayoutParams,
805    scale_factor: f32,
806    font_scale: f32,
807) -> Option<ShapedListMarker> {
808    let checked = params.checkbox?;
809    let marker_text = if checked { "\u{2611}" } else { "\u{2610}" }; // ballot box with/without check
810
811    let resolved = resolve_font(
812        registry,
813        None,
814        None,
815        None,
816        None,
817        None,
818        scale_factor,
819        font_scale,
820    )?;
821    let run = shape_text(registry, &resolved, marker_text, 0)?;
822
823    // If the font doesn't have the ballot box characters, use ASCII fallback
824    let run = if run.glyphs.iter().any(|g| g.glyph_id == 0) {
825        let fallback_text = if checked { "[x]" } else { "[ ]" };
826        shape_text(registry, &resolved, fallback_text, 0)?
827    } else {
828        run
829    };
830
831    let gap = 4.0;
832    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
833    let marker_x = marker_x.max(params.left_margin);
834
835    Some(ShapedListMarker { run, x: marker_x })
836}
837
838fn get_default_metrics(
839    registry: &FontRegistry,
840    scale_factor: f32,
841    font_scale: f32,
842) -> FontMetricsPx {
843    if let Some(default_id) = registry.default_font() {
844        let resolved = ResolvedFont {
845            font_face_id: default_id,
846            size_px: registry.default_size_px() * font_scale,
847            face_index: registry.get(default_id).map(|e| e.face_index).unwrap_or(0),
848            swash_cache_key: registry
849                .get(default_id)
850                .map(|e| e.swash_cache_key)
851                .unwrap_or_default(),
852            scale_factor,
853            weight: 400,
854        };
855        if let Some(m) = font_metrics_px(registry, &resolved) {
856            return m;
857        }
858    }
859    // Absolute fallback: synthetic metrics for 16px
860    FontMetricsPx {
861        ascent: 14.0,
862        descent: 4.0,
863        leading: 0.0,
864        underline_offset: -2.0,
865        strikeout_offset: 5.0,
866        stroke_size: 1.0,
867    }
868}