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, break_into_lines};
5use crate::shaping::run::ShapedRun;
6use crate::shaping::shaper::{FontMetricsPx, font_metrics_px, shape_text};
7
8/// Computed layout for a single block (paragraph).
9#[derive(Clone)]
10pub struct BlockLayout {
11    pub block_id: usize,
12    /// Document character position of the block start.
13    pub position: usize,
14    /// Laid out lines within the block.
15    pub lines: Vec<LayoutLine>,
16    /// Top edge relative to document start (set by flow layout).
17    pub y: f32,
18    /// Total height: top_margin + sum(line heights) + bottom_margin.
19    pub height: f32,
20    pub top_margin: f32,
21    pub bottom_margin: f32,
22    pub left_margin: f32,
23    pub right_margin: f32,
24    /// Shaped list marker (positioned to the left of the content area).
25    /// None if the block is not a list item.
26    pub list_marker: Option<ShapedListMarker>,
27    /// Block background color (RGBA). None means transparent.
28    pub background_color: Option<[f32; 4]>,
29}
30
31/// A shaped list marker ready for rendering.
32#[derive(Clone)]
33pub struct ShapedListMarker {
34    pub run: ShapedRun,
35    /// X position of the marker (relative to block left edge, before content indent).
36    pub x: f32,
37}
38
39/// Parameters extracted from text-document's BlockFormat / TextFormat.
40/// This is a plain struct so block layout doesn't depend on text-document types.
41#[derive(Clone)]
42pub struct BlockLayoutParams {
43    pub block_id: usize,
44    pub position: usize,
45    pub text: String,
46    pub fragments: Vec<FragmentParams>,
47    pub alignment: Alignment,
48    pub top_margin: f32,
49    pub bottom_margin: f32,
50    pub left_margin: f32,
51    pub right_margin: f32,
52    pub text_indent: f32,
53    /// List marker text (e.g., "1.", "•", "a)"). Empty if not a list item.
54    pub list_marker: String,
55    /// Additional left indent for list items (in pixels).
56    pub list_indent: f32,
57    /// Tab stop positions in pixels from the left margin.
58    pub tab_positions: Vec<f32>,
59    /// Line height multiplier. 1.0 = normal (from font metrics), 1.5 = 150%, 2.0 = double.
60    /// None means use font metrics (ascent + descent + leading).
61    pub line_height_multiplier: Option<f32>,
62    /// If true, prevent line wrapping. The entire block is one long line.
63    pub non_breakable_lines: bool,
64    /// Checkbox marker: None = no checkbox, Some(false) = unchecked, Some(true) = checked.
65    pub checkbox: Option<bool>,
66    /// Block background color (RGBA). None means transparent.
67    pub background_color: Option<[f32; 4]>,
68}
69
70/// A text fragment with its formatting parameters.
71#[derive(Clone)]
72pub struct FragmentParams {
73    pub text: String,
74    /// **Byte** offset of this fragment's first character inside the
75    /// owning block's text. Lifted into glyph clusters by
76    /// `paragraph::flatten_runs` so glyph clusters
77    /// can be compared directly against `unicode-linebreak` break
78    /// positions (also bytes) and against the block-level text used
79    /// for `byte_offset_to_char_offset` conversion. Hosts threading
80    /// text-document `FragmentContent` through the bridge must
81    /// translate the char-based `FragmentContent::offset` into bytes
82    /// before assigning here.
83    pub offset: usize,
84    pub length: usize,
85    pub font_family: Option<String>,
86    pub font_weight: Option<u32>,
87    pub font_bold: Option<bool>,
88    pub font_italic: Option<bool>,
89    pub font_point_size: Option<u32>,
90    pub underline_style: crate::types::UnderlineStyle,
91    pub overline: bool,
92    pub strikeout: bool,
93    pub is_link: bool,
94    /// Extra space added after each glyph (in pixels). From TextFormat::letter_spacing.
95    pub letter_spacing: f32,
96    /// Extra space added after space glyphs (in pixels). From TextFormat::word_spacing.
97    pub word_spacing: f32,
98    /// Text foreground color (RGBA). None means default (black).
99    pub foreground_color: Option<[f32; 4]>,
100    /// Underline color (RGBA). None means use foreground_color.
101    pub underline_color: Option<[f32; 4]>,
102    /// Text-level background highlight color (RGBA). None means transparent.
103    pub background_color: Option<[f32; 4]>,
104    /// Hyperlink destination URL.
105    pub anchor_href: Option<String>,
106    /// Tooltip text.
107    pub tooltip: Option<String>,
108    /// Vertical alignment (normal, superscript, subscript).
109    pub vertical_alignment: crate::types::VerticalAlignment,
110    /// If Some, this fragment represents an inline image placeholder.
111    pub image_name: Option<String>,
112    /// Image width in pixels. Only meaningful when image_name is Some.
113    pub image_width: f32,
114    /// Image height in pixels. Only meaningful when image_name is Some.
115    pub image_height: f32,
116}
117
118/// Lay out a single block: resolve fonts, shape fragments, break into lines.
119///
120/// `scale_factor` is the device pixel ratio. Layout output is always in
121/// logical pixels; the scale factor affects shaping/rasterization precision.
122pub fn layout_block(
123    registry: &FontRegistry,
124    params: &BlockLayoutParams,
125    available_width: f32,
126    scale_factor: f32,
127) -> BlockLayout {
128    let effective_left_margin = params.left_margin + params.list_indent;
129    let content_width = (available_width - effective_left_margin - params.right_margin).max(0.0);
130
131    // Resolve fonts and shape each fragment
132    let mut shaped_runs = Vec::new();
133    let mut default_metrics: Option<FontMetricsPx> = None;
134
135    for frag in &params.fragments {
136        // Inline image: create a synthetic run with one placeholder glyph
137        if let Some(ref image_name) = frag.image_name {
138            use crate::shaping::run::{ShapedGlyph, ShapedRun};
139            let image_glyph = ShapedGlyph {
140                glyph_id: 0,
141                cluster: 0,
142                x_advance: frag.image_width,
143                y_advance: 0.0,
144                x_offset: 0.0,
145                y_offset: 0.0,
146                font_face_id: crate::types::FontFaceId(0),
147            };
148            let run = ShapedRun {
149                font_face_id: crate::types::FontFaceId(0),
150                size_px: 0.0,
151                weight: 400,
152                glyphs: vec![image_glyph],
153                advance_width: frag.image_width,
154                text_range: frag.offset..frag.offset + frag.text.len(),
155                underline_style: frag.underline_style,
156                overline: false,
157                strikeout: false,
158                is_link: frag.is_link,
159                foreground_color: None,
160                underline_color: None,
161                background_color: None,
162                anchor_href: frag.anchor_href.clone(),
163                tooltip: frag.tooltip.clone(),
164                vertical_alignment: crate::types::VerticalAlignment::Normal,
165                image_name: Some(image_name.clone()),
166                image_height: frag.image_height,
167            };
168            shaped_runs.push(run);
169            continue;
170        }
171
172        // Scale font size for superscript/subscript
173        let font_point_size = match frag.vertical_alignment {
174            crate::types::VerticalAlignment::SuperScript
175            | crate::types::VerticalAlignment::SubScript => frag
176                .font_point_size
177                .map(|s| ((s as f32 * 0.65) as u32).max(1)),
178            crate::types::VerticalAlignment::Normal => frag.font_point_size,
179        };
180
181        let resolved = resolve_font(
182            registry,
183            frag.font_family.as_deref(),
184            frag.font_weight,
185            frag.font_bold,
186            frag.font_italic,
187            font_point_size,
188            scale_factor,
189        );
190
191        if let Some(resolved) = resolved {
192            // Capture default metrics from the first resolved font
193            if default_metrics.is_none() {
194                default_metrics = font_metrics_px(registry, &resolved);
195            }
196
197            if let Some(mut run) = shape_text(registry, &resolved, &frag.text, frag.offset) {
198                run.underline_style = frag.underline_style;
199                run.overline = frag.overline;
200                run.strikeout = frag.strikeout;
201                run.is_link = frag.is_link;
202                run.foreground_color = frag.foreground_color;
203                run.underline_color = frag.underline_color;
204                run.background_color = frag.background_color;
205                run.anchor_href = frag.anchor_href.clone();
206                run.tooltip = frag.tooltip.clone();
207                run.vertical_alignment = frag.vertical_alignment;
208
209                // Apply letter_spacing and word_spacing post-shaping
210                if frag.letter_spacing != 0.0 || frag.word_spacing != 0.0 {
211                    apply_spacing(&mut run, &frag.text, frag.letter_spacing, frag.word_spacing);
212                }
213
214                // Apply tab stops
215                if !params.tab_positions.is_empty() {
216                    apply_tab_stops(&mut run, &frag.text, &params.tab_positions);
217                }
218
219                shaped_runs.push(run);
220            }
221        }
222    }
223
224    // Fallback metrics if no fragments resolved
225    let metrics = default_metrics.unwrap_or_else(|| get_default_metrics(registry, scale_factor));
226
227    // Non-breakable lines: use infinite width to prevent wrapping
228    let wrap_width = if params.non_breakable_lines {
229        f32::INFINITY
230    } else {
231        content_width
232    };
233
234    // Break shaped runs into lines
235    let mut lines = break_into_lines(
236        shaped_runs,
237        &params.text,
238        wrap_width,
239        params.alignment,
240        params.text_indent,
241        &metrics,
242    );
243
244    // Apply line height multiplier
245    let line_height_mul = params.line_height_multiplier.unwrap_or(1.0).max(0.1);
246
247    // Compute y positions for each line (relative to block content top)
248    let mut y = 0.0f32;
249    for line in &mut lines {
250        if line_height_mul != 1.0 {
251            line.line_height *= line_height_mul;
252        }
253        line.y = y + line.ascent; // y is the baseline position
254        y += line.line_height;
255    }
256
257    let content_height = y;
258    let total_height = params.top_margin + content_height + params.bottom_margin;
259
260    // Shape list marker or checkbox marker
261    let list_marker = if params.checkbox.is_some() {
262        shape_checkbox_marker(registry, &metrics, params, scale_factor)
263    } else if !params.list_marker.is_empty() {
264        shape_list_marker(registry, &metrics, params, scale_factor)
265    } else {
266        None
267    };
268
269    BlockLayout {
270        block_id: params.block_id,
271        position: params.position,
272        lines,
273        y: 0.0, // set by flow layout
274        height: total_height,
275        top_margin: params.top_margin,
276        bottom_margin: params.bottom_margin,
277        left_margin: effective_left_margin,
278        right_margin: params.right_margin,
279        list_marker,
280        background_color: params.background_color,
281    }
282}
283
284/// A resolved paint-only color overlay span for one character range of a block.
285///
286/// `char_start`/`char_end` are **block-relative character offsets** — the same
287/// space as the post-layout `ShapedGlyph::cluster` values (see
288/// `break_into_lines`, which converts clusters to char offsets). Each field is
289/// `None` when the overlay does not override it (the base run's value is kept).
290/// Applying paint spans never changes glyph geometry, advances, or line breaks
291/// — only color / decoration attributes — so the layout does not reflow.
292#[derive(Clone, Debug, Default, PartialEq)]
293pub struct PaintSpan {
294    pub char_start: usize,
295    pub char_end: usize,
296    pub foreground_color: Option<[f32; 4]>,
297    pub underline_color: Option<[f32; 4]>,
298    pub background_color: Option<[f32; 4]>,
299    pub underline_style: Option<crate::types::UnderlineStyle>,
300    pub overline: Option<bool>,
301    pub strikeout: Option<bool>,
302}
303
304/// The effective set of overrides for one glyph, used to group consecutive
305/// glyphs that share the same paint result into a single output run.
306#[derive(Clone, Default, PartialEq)]
307struct PaintOverride {
308    foreground_color: Option<[f32; 4]>,
309    underline_color: Option<[f32; 4]>,
310    background_color: Option<[f32; 4]>,
311    underline_style: Option<crate::types::UnderlineStyle>,
312    overline: Option<bool>,
313    strikeout: Option<bool>,
314}
315
316impl PaintOverride {
317    fn is_noop(&self) -> bool {
318        *self == PaintOverride::default()
319    }
320
321    /// Merge the overlapping spans covering `char_off` (last span wins per
322    /// field). Overlay spans from `extract_paint_spans` are already disjoint,
323    /// but last-wins keeps this correct for arbitrary inputs.
324    fn for_char(char_off: usize, spans: &[PaintSpan]) -> Self {
325        let mut o = PaintOverride::default();
326        for s in spans {
327            if s.char_start <= char_off && char_off < s.char_end {
328                if s.foreground_color.is_some() {
329                    o.foreground_color = s.foreground_color;
330                }
331                if s.underline_color.is_some() {
332                    o.underline_color = s.underline_color;
333                }
334                if s.background_color.is_some() {
335                    o.background_color = s.background_color;
336                }
337                if s.underline_style.is_some() {
338                    o.underline_style = s.underline_style;
339                }
340                if s.overline.is_some() {
341                    o.overline = s.overline;
342                }
343                if s.strikeout.is_some() {
344                    o.strikeout = s.strikeout;
345                }
346            }
347        }
348        o
349    }
350
351    /// Apply this override onto a positioned run segment, writing color /
352    /// decoration fields on BOTH the shaped run and its duplicated
353    /// `RunDecorations` (the renderer reads glyph color from the former and
354    /// decoration rects from the latter). `None` fields keep the base value.
355    fn apply(&self, run: &mut crate::layout::line::PositionedRun) {
356        if let Some(c) = self.foreground_color {
357            run.shaped_run.foreground_color = Some(c);
358            run.decorations.foreground_color = Some(c);
359        }
360        if let Some(c) = self.underline_color {
361            run.shaped_run.underline_color = Some(c);
362            run.decorations.underline_color = Some(c);
363        }
364        if let Some(c) = self.background_color {
365            run.shaped_run.background_color = Some(c);
366            run.decorations.background_color = Some(c);
367        }
368        if let Some(s) = self.underline_style {
369            run.shaped_run.underline_style = s;
370            run.decorations.underline_style = s;
371        }
372        if let Some(b) = self.overline {
373            run.shaped_run.overline = b;
374            run.decorations.overline = b;
375        }
376        if let Some(b) = self.strikeout {
377            run.shaped_run.strikeout = b;
378            run.decorations.strikeout = b;
379        }
380    }
381}
382
383/// Apply paint-only color spans to a base [`BlockLayout`], returning a recolored
384/// clone. The base is left untouched.
385///
386/// The result has byte-identical glyph positions, advances, line breaks, line
387/// widths, and block height to `base` — only color / decoration attributes
388/// differ. This is the "recolor without reshape/reflow" fast path: a run is
389/// split into segments at paint-span boundaries (snapped to glyph/cluster
390/// boundaries, never mid-cluster) and each segment's color fields are set.
391/// Splitting a run never alters any glyph advance, so line widths are preserved.
392///
393/// Empty `spans` returns an exact (color-preserving) clone of `base`.
394pub fn apply_paint_spans(base: &BlockLayout, spans: &[PaintSpan]) -> BlockLayout {
395    let mut out = base.clone();
396    if spans.is_empty() {
397        return out;
398    }
399    for line in &mut out.lines {
400        let mut new_runs: Vec<crate::layout::line::PositionedRun> =
401            Vec::with_capacity(line.runs.len());
402        for run in line.runs.drain(..) {
403            recolor_run_into(run, spans, &mut new_runs);
404        }
405        line.runs = new_runs;
406    }
407    out
408}
409
410/// Split `run` at paint-span boundaries and push the recolored segment(s) onto
411/// `out`. Image / glyph-less runs are passed through unchanged (paint overlays
412/// never recolor images).
413fn recolor_run_into(
414    run: crate::layout::line::PositionedRun,
415    spans: &[PaintSpan],
416    out: &mut Vec<crate::layout::line::PositionedRun>,
417) {
418    if run.shaped_run.glyphs.is_empty() || run.shaped_run.image_name.is_some() {
419        out.push(run);
420        return;
421    }
422
423    // Per-glyph effective override, in glyph (visual) order. Works for LTR and
424    // RTL alike: we group by adjacency in the glyph array, not by char order.
425    let overrides: Vec<PaintOverride> = run
426        .shaped_run
427        .glyphs
428        .iter()
429        .map(|g| PaintOverride::for_char(g.cluster as usize, spans))
430        .collect();
431
432    // Fast path: the whole run shares one override (the common case, and the
433    // only case when `spans` doesn't touch this run — then it's a no-op). Keep
434    // the base `advance_width` exactly so a cleared/uncovered run is identical.
435    if overrides.iter().all(|o| *o == overrides[0]) {
436        let mut seg = run;
437        overrides[0].apply(&mut seg);
438        out.push(seg);
439        return;
440    }
441
442    // Split into maximal runs of equal override.
443    let glyphs = run.shaped_run.glyphs.clone();
444    let mut seg_x = run.x;
445    let mut start = 0usize;
446    while start < glyphs.len() {
447        let ov = &overrides[start];
448        let mut end = start + 1;
449        while end < glyphs.len() && overrides[end] == *ov {
450            end += 1;
451        }
452        let seg_glyphs: Vec<crate::shaping::run::ShapedGlyph> = glyphs[start..end].to_vec();
453        let seg_advance: f32 = seg_glyphs.iter().map(|g| g.x_advance).sum();
454        let mut shaped = run.shaped_run.clone();
455        shaped.glyphs = seg_glyphs;
456        shaped.advance_width = seg_advance;
457        let mut seg = crate::layout::line::PositionedRun {
458            shaped_run: shaped,
459            x: seg_x,
460            decorations: run.decorations.clone(),
461        };
462        if !ov.is_noop() {
463            ov.apply(&mut seg);
464        }
465        out.push(seg);
466        seg_x += seg_advance;
467        start = end;
468    }
469}
470
471/// Add letter_spacing (to all glyphs) and word_spacing (to space glyphs).
472fn apply_spacing(run: &mut ShapedRun, text: &str, letter_spacing: f32, word_spacing: f32) {
473    let mut extra_advance = 0.0f32;
474    for glyph in &mut run.glyphs {
475        glyph.x_advance += letter_spacing;
476        extra_advance += letter_spacing;
477
478        // Add word_spacing to space characters.
479        // Detect spaces by mapping cluster back to the text.
480        if word_spacing != 0.0 {
481            let byte_offset = glyph.cluster as usize;
482            if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
483                && ch == ' '
484            {
485                glyph.x_advance += word_spacing;
486                extra_advance += word_spacing;
487            }
488        }
489    }
490    run.advance_width += extra_advance;
491}
492
493/// Shape the list marker text and position it in the indent area.
494fn shape_list_marker(
495    registry: &FontRegistry,
496    _metrics: &FontMetricsPx,
497    params: &BlockLayoutParams,
498    scale_factor: f32,
499) -> Option<ShapedListMarker> {
500    // Use the default font for the marker
501    let resolved = resolve_font(registry, None, None, None, None, None, scale_factor)?;
502    let run = shape_text(registry, &resolved, &params.list_marker, 0)?;
503
504    // Position the marker: right-aligned within the indent area, with a small gap
505    let gap = 4.0; // pixels between marker and content
506    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
507    let marker_x = marker_x.max(params.left_margin);
508
509    Some(ShapedListMarker { run, x: marker_x })
510}
511
512/// Expand tab character advances to reach the next tab stop position.
513fn apply_tab_stops(run: &mut ShapedRun, text: &str, tab_positions: &[f32]) {
514    let default_tab = 48.0; // default tab width if no stops defined
515    let mut pen_x = 0.0f32;
516
517    for glyph in &mut run.glyphs {
518        let byte_offset = glyph.cluster as usize;
519        if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
520            && ch == '\t'
521        {
522            // Find the next tab stop after the current pen position
523            let next_stop = tab_positions
524                .iter()
525                .find(|&&stop| stop > pen_x + 1.0)
526                .copied()
527                .unwrap_or_else(|| {
528                    // Past all defined stops: use default tab increments
529                    let last = tab_positions.last().copied().unwrap_or(0.0);
530                    let increment = if tab_positions.len() >= 2 {
531                        tab_positions[1] - tab_positions[0]
532                    } else {
533                        default_tab
534                    };
535                    let mut stop = last + increment;
536                    while stop <= pen_x + 1.0 {
537                        stop += increment;
538                    }
539                    stop
540                });
541
542            let tab_advance = next_stop - pen_x;
543            let delta = tab_advance - glyph.x_advance;
544            glyph.x_advance = tab_advance;
545            run.advance_width += delta;
546        }
547        pen_x += glyph.x_advance;
548    }
549}
550
551/// Shape a checkbox marker (unchecked or checked) for rendering in the margin.
552fn shape_checkbox_marker(
553    registry: &FontRegistry,
554    _metrics: &FontMetricsPx,
555    params: &BlockLayoutParams,
556    scale_factor: f32,
557) -> Option<ShapedListMarker> {
558    let checked = params.checkbox?;
559    let marker_text = if checked { "\u{2611}" } else { "\u{2610}" }; // ballot box with/without check
560
561    let resolved = resolve_font(registry, None, None, None, None, None, scale_factor)?;
562    let run = shape_text(registry, &resolved, marker_text, 0)?;
563
564    // If the font doesn't have the ballot box characters, use ASCII fallback
565    let run = if run.glyphs.iter().any(|g| g.glyph_id == 0) {
566        let fallback_text = if checked { "[x]" } else { "[ ]" };
567        shape_text(registry, &resolved, fallback_text, 0)?
568    } else {
569        run
570    };
571
572    let gap = 4.0;
573    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
574    let marker_x = marker_x.max(params.left_margin);
575
576    Some(ShapedListMarker { run, x: marker_x })
577}
578
579fn get_default_metrics(registry: &FontRegistry, scale_factor: f32) -> FontMetricsPx {
580    if let Some(default_id) = registry.default_font() {
581        let resolved = ResolvedFont {
582            font_face_id: default_id,
583            size_px: registry.default_size_px(),
584            face_index: registry.get(default_id).map(|e| e.face_index).unwrap_or(0),
585            swash_cache_key: registry
586                .get(default_id)
587                .map(|e| e.swash_cache_key)
588                .unwrap_or_default(),
589            scale_factor,
590            weight: 400,
591        };
592        if let Some(m) = font_metrics_px(registry, &resolved) {
593            return m;
594        }
595    }
596    // Absolute fallback: synthetic metrics for 16px
597    FontMetricsPx {
598        ascent: 14.0,
599        descent: 4.0,
600        leading: 0.0,
601        underline_offset: -2.0,
602        strikeout_offset: 5.0,
603        stroke_size: 1.0,
604    }
605}