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).
9pub struct BlockLayout {
10    pub block_id: usize,
11    /// Document character position of the block start.
12    pub position: usize,
13    /// Laid out lines within the block.
14    pub lines: Vec<LayoutLine>,
15    /// Top edge relative to document start (set by flow layout).
16    pub y: f32,
17    /// Total height: top_margin + sum(line heights) + bottom_margin.
18    pub height: f32,
19    pub top_margin: f32,
20    pub bottom_margin: f32,
21    pub left_margin: f32,
22    pub right_margin: f32,
23    /// Shaped list marker (positioned to the left of the content area).
24    /// None if the block is not a list item.
25    pub list_marker: Option<ShapedListMarker>,
26    /// Block background color (RGBA). None means transparent.
27    pub background_color: Option<[f32; 4]>,
28}
29
30/// A shaped list marker ready for rendering.
31pub struct ShapedListMarker {
32    pub run: ShapedRun,
33    /// X position of the marker (relative to block left edge, before content indent).
34    pub x: f32,
35}
36
37/// Parameters extracted from text-document's BlockFormat / TextFormat.
38/// This is a plain struct so block layout doesn't depend on text-document types.
39pub struct BlockLayoutParams {
40    pub block_id: usize,
41    pub position: usize,
42    pub text: String,
43    pub fragments: Vec<FragmentParams>,
44    pub alignment: Alignment,
45    pub top_margin: f32,
46    pub bottom_margin: f32,
47    pub left_margin: f32,
48    pub right_margin: f32,
49    pub text_indent: f32,
50    /// List marker text (e.g., "1.", "•", "a)"). Empty if not a list item.
51    pub list_marker: String,
52    /// Additional left indent for list items (in pixels).
53    pub list_indent: f32,
54    /// Tab stop positions in pixels from the left margin.
55    pub tab_positions: Vec<f32>,
56    /// Line height multiplier. 1.0 = normal (from font metrics), 1.5 = 150%, 2.0 = double.
57    /// None means use font metrics (ascent + descent + leading).
58    pub line_height_multiplier: Option<f32>,
59    /// If true, prevent line wrapping. The entire block is one long line.
60    pub non_breakable_lines: bool,
61    /// Checkbox marker: None = no checkbox, Some(false) = unchecked, Some(true) = checked.
62    pub checkbox: Option<bool>,
63    /// Block background color (RGBA). None means transparent.
64    pub background_color: Option<[f32; 4]>,
65}
66
67/// A text fragment with its formatting parameters.
68pub struct FragmentParams {
69    pub text: String,
70    pub offset: usize,
71    pub length: usize,
72    pub font_family: Option<String>,
73    pub font_weight: Option<u32>,
74    pub font_bold: Option<bool>,
75    pub font_italic: Option<bool>,
76    pub font_point_size: Option<u32>,
77    pub underline: bool,
78    pub overline: bool,
79    pub strikeout: bool,
80    pub is_link: bool,
81    /// Extra space added after each glyph (in pixels). From TextFormat::letter_spacing.
82    pub letter_spacing: f32,
83    /// Extra space added after space glyphs (in pixels). From TextFormat::word_spacing.
84    pub word_spacing: f32,
85}
86
87/// Lay out a single block: resolve fonts, shape fragments, break into lines.
88pub fn layout_block(
89    registry: &FontRegistry,
90    params: &BlockLayoutParams,
91    available_width: f32,
92) -> BlockLayout {
93    let effective_left_margin = params.left_margin + params.list_indent;
94    let content_width = (available_width - effective_left_margin - params.right_margin).max(0.0);
95
96    // Resolve fonts and shape each fragment
97    let mut shaped_runs = Vec::new();
98    let mut default_metrics: Option<FontMetricsPx> = None;
99
100    for frag in &params.fragments {
101        let resolved = resolve_font(
102            registry,
103            frag.font_family.as_deref(),
104            frag.font_weight,
105            frag.font_bold,
106            frag.font_italic,
107            frag.font_point_size,
108        );
109
110        if let Some(resolved) = resolved {
111            // Capture default metrics from the first resolved font
112            if default_metrics.is_none() {
113                default_metrics = font_metrics_px(registry, &resolved);
114            }
115
116            if let Some(mut run) = shape_text(registry, &resolved, &frag.text, frag.offset) {
117                run.underline = frag.underline;
118                run.overline = frag.overline;
119                run.strikeout = frag.strikeout;
120                run.is_link = frag.is_link;
121
122                // Apply letter_spacing and word_spacing post-shaping
123                if frag.letter_spacing != 0.0 || frag.word_spacing != 0.0 {
124                    apply_spacing(&mut run, &frag.text, frag.letter_spacing, frag.word_spacing);
125                }
126
127                // Apply tab stops
128                if !params.tab_positions.is_empty() {
129                    apply_tab_stops(&mut run, &frag.text, &params.tab_positions);
130                }
131
132                shaped_runs.push(run);
133            }
134        }
135    }
136
137    // Fallback metrics if no fragments resolved
138    let metrics = default_metrics.unwrap_or_else(|| get_default_metrics(registry));
139
140    // Non-breakable lines: use infinite width to prevent wrapping
141    let wrap_width = if params.non_breakable_lines {
142        f32::INFINITY
143    } else {
144        content_width
145    };
146
147    // Break shaped runs into lines
148    let mut lines = break_into_lines(
149        shaped_runs,
150        &params.text,
151        wrap_width,
152        params.alignment,
153        params.text_indent,
154        &metrics,
155    );
156
157    // Apply line height multiplier
158    let line_height_mul = params.line_height_multiplier.unwrap_or(1.0).max(0.1);
159
160    // Compute y positions for each line (relative to block content top)
161    let mut y = 0.0f32;
162    for line in &mut lines {
163        if line_height_mul != 1.0 {
164            line.line_height *= line_height_mul;
165        }
166        line.y = y + line.ascent; // y is the baseline position
167        y += line.line_height;
168    }
169
170    let content_height = y;
171    let total_height = params.top_margin + content_height + params.bottom_margin;
172
173    // Shape list marker or checkbox marker
174    let list_marker = if params.checkbox.is_some() {
175        shape_checkbox_marker(registry, &metrics, params)
176    } else if !params.list_marker.is_empty() {
177        shape_list_marker(registry, &metrics, params)
178    } else {
179        None
180    };
181
182    BlockLayout {
183        block_id: params.block_id,
184        position: params.position,
185        lines,
186        y: 0.0, // set by flow layout
187        height: total_height,
188        top_margin: params.top_margin,
189        bottom_margin: params.bottom_margin,
190        left_margin: effective_left_margin,
191        right_margin: params.right_margin,
192        list_marker,
193        background_color: params.background_color,
194    }
195}
196
197/// Add letter_spacing (to all glyphs) and word_spacing (to space glyphs).
198fn apply_spacing(run: &mut ShapedRun, text: &str, letter_spacing: f32, word_spacing: f32) {
199    let mut extra_advance = 0.0f32;
200    for glyph in &mut run.glyphs {
201        glyph.x_advance += letter_spacing;
202        extra_advance += letter_spacing;
203
204        // Add word_spacing to space characters.
205        // Detect spaces by mapping cluster back to the text.
206        if word_spacing != 0.0 {
207            let byte_offset = glyph.cluster as usize;
208            if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
209                && ch == ' '
210            {
211                glyph.x_advance += word_spacing;
212                extra_advance += word_spacing;
213            }
214        }
215    }
216    run.advance_width += extra_advance;
217}
218
219/// Shape the list marker text and position it in the indent area.
220fn shape_list_marker(
221    registry: &FontRegistry,
222    _metrics: &FontMetricsPx,
223    params: &BlockLayoutParams,
224) -> Option<ShapedListMarker> {
225    // Use the default font for the marker
226    let resolved = resolve_font(registry, None, None, None, None, None)?;
227    let run = shape_text(registry, &resolved, &params.list_marker, 0)?;
228
229    // Position the marker: right-aligned within the indent area, with a small gap
230    let gap = 4.0; // pixels between marker and content
231    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
232    let marker_x = marker_x.max(params.left_margin);
233
234    Some(ShapedListMarker { run, x: marker_x })
235}
236
237/// Expand tab character advances to reach the next tab stop position.
238fn apply_tab_stops(run: &mut ShapedRun, text: &str, tab_positions: &[f32]) {
239    let default_tab = 48.0; // default tab width if no stops defined
240    let mut pen_x = 0.0f32;
241
242    for glyph in &mut run.glyphs {
243        let byte_offset = glyph.cluster as usize;
244        if let Some(ch) = text.get(byte_offset..).and_then(|s| s.chars().next())
245            && ch == '\t'
246        {
247            // Find the next tab stop after the current pen position
248            let next_stop = tab_positions
249                .iter()
250                .find(|&&stop| stop > pen_x + 1.0)
251                .copied()
252                .unwrap_or_else(|| {
253                    // Past all defined stops: use default tab increments
254                    let last = tab_positions.last().copied().unwrap_or(0.0);
255                    let increment = if tab_positions.len() >= 2 {
256                        tab_positions[1] - tab_positions[0]
257                    } else {
258                        default_tab
259                    };
260                    let mut stop = last + increment;
261                    while stop <= pen_x + 1.0 {
262                        stop += increment;
263                    }
264                    stop
265                });
266
267            let tab_advance = next_stop - pen_x;
268            let delta = tab_advance - glyph.x_advance;
269            glyph.x_advance = tab_advance;
270            run.advance_width += delta;
271        }
272        pen_x += glyph.x_advance;
273    }
274}
275
276/// Shape a checkbox marker (unchecked or checked) for rendering in the margin.
277fn shape_checkbox_marker(
278    registry: &FontRegistry,
279    _metrics: &FontMetricsPx,
280    params: &BlockLayoutParams,
281) -> Option<ShapedListMarker> {
282    let checked = params.checkbox?;
283    let marker_text = if checked { "\u{2611}" } else { "\u{2610}" }; // ballot box with/without check
284
285    let resolved = resolve_font(registry, None, None, None, None, None)?;
286    let run = shape_text(registry, &resolved, marker_text, 0)?;
287
288    // If the font doesn't have the ballot box characters, use ASCII fallback
289    let run = if run.glyphs.iter().any(|g| g.glyph_id == 0) {
290        let fallback_text = if checked { "[x]" } else { "[ ]" };
291        shape_text(registry, &resolved, fallback_text, 0)?
292    } else {
293        run
294    };
295
296    let gap = 4.0;
297    let marker_x = params.left_margin + params.list_indent - run.advance_width - gap;
298    let marker_x = marker_x.max(params.left_margin);
299
300    Some(ShapedListMarker { run, x: marker_x })
301}
302
303fn get_default_metrics(registry: &FontRegistry) -> FontMetricsPx {
304    if let Some(default_id) = registry.default_font() {
305        let resolved = ResolvedFont {
306            font_face_id: default_id,
307            size_px: registry.default_size_px(),
308            face_index: registry.get(default_id).map(|e| e.face_index).unwrap_or(0),
309            swash_cache_key: registry
310                .get(default_id)
311                .map(|e| e.swash_cache_key)
312                .unwrap_or_default(),
313        };
314        if let Some(m) = font_metrics_px(registry, &resolved) {
315            return m;
316        }
317    }
318    // Absolute fallback: synthetic metrics for 16px
319    FontMetricsPx {
320        ascent: 14.0,
321        descent: 4.0,
322        leading: 0.0,
323        underline_offset: -2.0,
324        strikeout_offset: 5.0,
325        stroke_size: 1.0,
326    }
327}