Skip to main content

text_typeset/
bridge.rs

1//! Bridge between text-document snapshot types and text-typeset layout params.
2//!
3//! Converts `FlowSnapshot`, `BlockSnapshot`, `TextFormat`, etc. into
4//! `BlockLayoutParams`, `FragmentParams`, `TableLayoutParams`, etc.
5
6use text_document::{
7    BlockSnapshot, CellSnapshot, FlowElementSnapshot, FlowSnapshot, FragmentContent, FrameSnapshot,
8    TableSnapshot,
9};
10
11use crate::layout::block::{BlockLayoutParams, FragmentParams, PaintSpan};
12use crate::layout::frame::{FrameLayoutParams, FramePosition};
13use crate::layout::paragraph::Alignment;
14use crate::layout::table::{CellLayoutParams, TableLayoutParams};
15
16const DEFAULT_LIST_INDENT: f32 = 24.0;
17const INDENT_PER_LEVEL: f32 = 24.0;
18
19/// Parse a 2-letter ISO 639-1 code (e.g. "en", "fr") into a lowercased
20/// byte pair for `hypher::Lang::from_iso`. Returns `None` for anything
21/// that isn't two ASCII letters (incl. longer tags like "en-US" — only
22/// the primary subtag matters, so callers may pass that and we take the
23/// first two letters).
24fn iso639_1(code: &str) -> Option<[u8; 2]> {
25    let b = code.trim().as_bytes();
26    if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1].is_ascii_alphabetic() {
27        Some([b[0].to_ascii_lowercase(), b[1].to_ascii_lowercase()])
28    } else {
29        None
30    }
31}
32
33/// Per-call knobs threaded through the conversion functions so that a
34/// host widget can override defaults driven by its active theme.
35///
36/// Default values reproduce the historical pre-themed behaviour: a
37/// light-grey card behind fenced code blocks and no foreground
38/// override for monospaced runs.
39#[derive(Clone, Copy)]
40pub struct BridgeOptions {
41    /// Background painted behind blocks where `BlockFormat.is_code_block
42    /// == Some(true)` AND the block carries no explicit
43    /// `background_color`. Has no effect on prose blocks or blocks that
44    /// set their own background.
45    pub code_block_background: [f32; 4],
46    /// Foreground used for character runs whose font family resolves
47    /// to `monospace` (set by the markdown importer for inline `code`
48    /// spans and by `is_code_block` blocks). `None` keeps the
49    /// engine-level default text colour. Only applied when the run
50    /// carries no explicit `foreground_color`.
51    pub code_block_foreground: Option<[f32; 4]>,
52    /// When `Some(c)`, every character of every block laid out with
53    /// these options is replaced with `c` — one echo char per source
54    /// `char` — before shaping. This is the password / secure-field
55    /// masking path: the real text never reaches the shaper or the
56    /// glyph atlas, only the echo character does. Emitting one echo per
57    /// source `char` (not per grapheme) preserves char counts, so the
58    /// engine's char-indexed caret / selection / hit-test stay aligned
59    /// with the host document's positions. `None` (default) lays text
60    /// out verbatim.
61    pub echo_char: Option<char>,
62    /// When true, blocks that are **justified** and don't set the
63    /// `hyphenate` flag explicitly are hyphenated automatically (in the
64    /// block's `language`, defaulting to English). This pairs hyphenation
65    /// with justification — its primary use case — without requiring a
66    /// per-block flag. An explicit `BlockFormat.hyphenate` (true or false)
67    /// always wins. Hosts should enable this only for prose/rich-text
68    /// surfaces, not single-line/label widgets. `false` by default.
69    pub hyphenate_justified: bool,
70}
71
72impl Default for BridgeOptions {
73    fn default() -> Self {
74        Self {
75            code_block_background: [0.95, 0.95, 0.95, 1.0],
76            code_block_foreground: None,
77            echo_char: None,
78            hyphenate_justified: false,
79        }
80    }
81}
82
83/// Convert a FlowSnapshot into layout params that can be fed to a [`DocumentFlow`].
84///
85/// [`DocumentFlow`]: crate::DocumentFlow
86pub fn convert_flow(flow: &FlowSnapshot) -> FlowElements {
87    convert_flow_with(flow, &BridgeOptions::default())
88}
89
90/// Same as [`convert_flow`] but accepts host-supplied [`BridgeOptions`]
91/// for theme-driven colour overrides.
92pub fn convert_flow_with(flow: &FlowSnapshot, opts: &BridgeOptions) -> FlowElements {
93    let mut blocks = Vec::new();
94    let mut tables = Vec::new();
95    let mut frames = Vec::new();
96
97    for (i, element) in flow.elements.iter().enumerate() {
98        match element {
99            FlowElementSnapshot::Block(block) => {
100                blocks.push((i, convert_block_with(block, opts)));
101            }
102            FlowElementSnapshot::Table(table) => {
103                tables.push((i, convert_table_with(table, opts)));
104            }
105            FlowElementSnapshot::Frame(frame) => {
106                frames.push((i, convert_frame_with(frame, opts)));
107            }
108        }
109    }
110
111    FlowElements {
112        blocks,
113        tables,
114        frames,
115    }
116}
117
118/// Converted flow elements, ordered by their position in the flow.
119pub struct FlowElements {
120    /// (flow_index, params)
121    pub blocks: Vec<(usize, BlockLayoutParams)>,
122    pub tables: Vec<(usize, TableLayoutParams)>,
123    pub frames: Vec<(usize, FrameLayoutParams)>,
124}
125
126pub fn convert_block(block: &BlockSnapshot) -> BlockLayoutParams {
127    convert_block_with(block, &BridgeOptions::default())
128}
129
130/// Same as [`convert_block`] but with theme-driven [`BridgeOptions`]
131/// for code-block colour overrides.
132pub fn convert_block_with(block: &BlockSnapshot, opts: &BridgeOptions) -> BlockLayoutParams {
133    let alignment = block
134        .block_format
135        .alignment
136        .as_ref()
137        .map(convert_alignment)
138        .unwrap_or_default();
139
140    let heading_scale = match block.block_format.heading_level {
141        Some(1) => 2.0,
142        Some(2) => 1.5,
143        Some(3) => 1.25,
144        Some(4) => 1.1,
145        _ => 1.0,
146    };
147
148    // text-document's `FragmentContent::{Text, Image}.offset` is the
149    // **character** offset of the fragment within the block. text-typeset
150    // downstream (block.rs:143 / paragraph.rs:216) treats
151    // `FragmentParams.offset` as the fragment's **byte** start in
152    // `block.text`, then adds it to glyph clusters (also bytes) to
153    // lift them into block-text byte space. The two units must
154    // agree, or any block whose first fragment carries a multi-byte
155    // character causes every subsequent fragment's glyphs to land at
156    // the wrong byte position — observed as hit-tests + formatting
157    // landing a character or two past the user's selection around
158    // em-dashes, curly quotes, accented characters, emoji, etc.
159    //
160    // Build a single char → byte index once over `block.text` (O(N)),
161    // then look each fragment's char offset up in O(1) and pass the
162    // byte offset into `convert_fragment`. The fragment stream covers
163    // the whole block text in char order, so the lookup is in range
164    // for every fragment we see.
165    let char_to_byte: Vec<usize> = block
166        .text
167        .char_indices()
168        .map(|(b, _)| b)
169        .chain(std::iter::once(block.text.len()))
170        .collect();
171    let fragments: Vec<FragmentParams> = block
172        .fragments
173        .iter()
174        .map(|f| {
175            let char_offset = match f {
176                FragmentContent::Text { offset, .. } => *offset,
177                FragmentContent::Image { offset, .. } => *offset,
178            };
179            let byte_offset = char_to_byte
180                .get(char_offset)
181                .copied()
182                .unwrap_or(block.text.len());
183            convert_fragment(f, heading_scale, opts, byte_offset)
184        })
185        .collect();
186
187    let indent_level = block.block_format.indent.unwrap_or(0) as f32;
188
189    let (list_marker, list_indent) = if let Some(ref info) = block.list_info {
190        let list_indent_level = info.indent as f32;
191        (
192            info.marker.clone(),
193            DEFAULT_LIST_INDENT + list_indent_level * INDENT_PER_LEVEL,
194        )
195    } else {
196        (String::new(), indent_level * INDENT_PER_LEVEL)
197    };
198
199    let checkbox = match block.block_format.marker {
200        Some(text_document::MarkerType::Checked) => Some(true),
201        Some(text_document::MarkerType::Unchecked) => Some(false),
202        _ => None,
203    };
204
205    let mut params = BlockLayoutParams {
206        block_id: block.block_id,
207        position: block.position,
208        text: block.text.clone(),
209        fragments,
210        alignment,
211        top_margin: block.block_format.top_margin.unwrap_or(0) as f32,
212        bottom_margin: block.block_format.bottom_margin.unwrap_or(0) as f32,
213        left_margin: block.block_format.left_margin.unwrap_or(0) as f32,
214        right_margin: block.block_format.right_margin.unwrap_or(0) as f32,
215        text_indent: block.block_format.text_indent.unwrap_or(0) as f32,
216        list_marker,
217        list_indent,
218        tab_positions: block
219            .block_format
220            .tab_positions
221            .iter()
222            .map(|&t| t as f32)
223            .collect(),
224        line_height_multiplier: block.block_format.line_height,
225        non_breakable_lines: block.block_format.non_breakable_lines.unwrap_or(false)
226            || block.block_format.is_code_block == Some(true),
227        // Map the document's per-block hyphenation flag + language to the
228        // engine's Hyphenation config. An explicit `hyphenate` flag always
229        // wins; when it's unset, `hyphenate_justified` opts justified
230        // blocks in (hyphenation's main use case). Language defaults to
231        // English when unset/unparseable; unsupported languages degrade to
232        // soft-hyphen-only at wrap time.
233        hyphenation: {
234            let enabled = match block.block_format.hyphenate {
235                Some(v) => v,
236                None => opts.hyphenate_justified && alignment == Alignment::Justify,
237            };
238            enabled.then(|| crate::types::Hyphenation {
239                language: block
240                    .block_format
241                    .language
242                    .as_deref()
243                    .and_then(iso639_1)
244                    .unwrap_or(*b"en"),
245            })
246        },
247        checkbox,
248        background_color: block
249            .block_format
250            .background_color
251            .as_ref()
252            .and_then(|s| parse_css_color(s))
253            .or_else(|| {
254                if block.block_format.is_code_block == Some(true) {
255                    Some(opts.code_block_background)
256                } else {
257                    None
258                }
259            }),
260    };
261
262    if let Some(echo) = opts.echo_char {
263        mask_block_params(&mut params, echo);
264    }
265
266    params
267}
268
269/// Replace every text fragment's content with `echo` repeated once per
270/// source `char`, rewriting the block text and fragment byte offsets to
271/// match. Image-placeholder fragments pass through unchanged (only their
272/// byte offset shifts). Used for password / secure-field masking: the
273/// plaintext is substituted here, before shaping, so it never reaches
274/// the shaper or the glyph atlas. Char counts are preserved per
275/// fragment, keeping the engine's char-indexed caret / selection /
276/// hit-test aligned with the host's real document positions.
277fn mask_block_params(params: &mut BlockLayoutParams, echo: char) {
278    if params.fragments.is_empty() {
279        params.text = echo.to_string().repeat(params.text.chars().count());
280        return;
281    }
282    let mut masked_block = String::new();
283    let mut byte_cursor = 0usize;
284    for frag in params.fragments.iter_mut() {
285        frag.offset = byte_cursor;
286        if frag.image_name.is_some() {
287            // Inline image placeholder — keep the object-replacement
288            // character intact; only its byte offset shifts.
289            masked_block.push_str(&frag.text);
290            byte_cursor += frag.text.len();
291            continue;
292        }
293        let masked = echo.to_string().repeat(frag.text.chars().count());
294        byte_cursor += masked.len();
295        masked_block.push_str(&masked);
296        frag.text = masked;
297    }
298    params.text = masked_block;
299}
300
301fn convert_fragment(
302    frag: &FragmentContent,
303    heading_scale: f32,
304    opts: &BridgeOptions,
305    byte_offset: usize,
306) -> FragmentParams {
307    match frag {
308        FragmentContent::Text {
309            text,
310            format,
311            length,
312            ..
313        } => {
314            // Monospaced runs without an explicit foreground pick up the
315            // host theme's code_block_foreground so `inline code` and
316            // fenced code blocks read as their own register against
317            // prose. Authors that pinned a colour explicitly always win.
318            let is_monospace = format
319                .font_family
320                .as_deref()
321                .map(|f| f.eq_ignore_ascii_case("monospace"))
322                .unwrap_or(false);
323            let foreground_color =
324                format
325                    .foreground_color
326                    .as_ref()
327                    .map(convert_color)
328                    .or(if is_monospace {
329                        opts.code_block_foreground
330                    } else {
331                        None
332                    });
333            FragmentParams {
334                text: text.clone(),
335                offset: byte_offset,
336                length: *length,
337                font_family: format.font_family.clone(),
338                font_weight: format.font_weight,
339                font_bold: format.font_bold,
340                font_italic: format.font_italic,
341                font_point_size: if heading_scale != 1.0 {
342                    // Apply heading scale; use 16 as default if no explicit size
343                    Some((format.font_point_size.unwrap_or(16) as f32 * heading_scale) as u32)
344                } else {
345                    format.font_point_size
346                },
347                underline_style: convert_underline_style(format),
348                overline: format.font_overline.unwrap_or(false),
349                strikeout: format.font_strikeout.unwrap_or(false),
350                is_link: format.is_anchor.unwrap_or(false),
351                letter_spacing: format.letter_spacing.unwrap_or(0) as f32,
352                word_spacing: format.word_spacing.unwrap_or(0) as f32,
353                foreground_color,
354                underline_color: format.underline_color.as_ref().map(convert_color),
355                background_color: format.background_color.as_ref().map(convert_color),
356                anchor_href: format.anchor_href.clone(),
357                tooltip: format.tooltip.clone(),
358                vertical_alignment: convert_vertical_alignment(format),
359                image_name: None,
360                image_width: 0.0,
361                image_height: 0.0,
362                features: Vec::new(),
363            }
364        }
365        FragmentContent::Image {
366            name,
367            width,
368            height,
369            quality: _,
370            format,
371            ..
372        } => FragmentParams {
373            text: "\u{FFFC}".to_string(),
374            offset: byte_offset,
375            length: 1,
376            font_family: None,
377            font_weight: None,
378            font_bold: None,
379            font_italic: None,
380            font_point_size: None,
381            underline_style: crate::types::UnderlineStyle::None,
382            overline: false,
383            strikeout: false,
384            is_link: format.is_anchor.unwrap_or(false),
385            letter_spacing: 0.0,
386            word_spacing: 0.0,
387            foreground_color: None,
388            underline_color: None,
389            background_color: None,
390            anchor_href: format.anchor_href.clone(),
391            tooltip: format.tooltip.clone(),
392            vertical_alignment: crate::types::VerticalAlignment::Normal,
393            image_name: Some(name.clone()),
394            image_width: *width as f32,
395            image_height: *height as f32,
396            features: Vec::new(),
397        },
398    }
399}
400
401fn convert_vertical_alignment(
402    format: &text_document::TextFormat,
403) -> crate::types::VerticalAlignment {
404    use crate::types::VerticalAlignment;
405    match format.vertical_alignment {
406        Some(text_document::CharVerticalAlignment::SuperScript) => VerticalAlignment::SuperScript,
407        Some(text_document::CharVerticalAlignment::SubScript) => VerticalAlignment::SubScript,
408        _ => VerticalAlignment::Normal,
409    }
410}
411
412fn convert_underline_style(format: &text_document::TextFormat) -> crate::types::UnderlineStyle {
413    use crate::types::UnderlineStyle;
414    match &format.underline_style {
415        Some(s) => convert_underline_style_value(s),
416        None => {
417            if format.font_underline.unwrap_or(false) {
418                UnderlineStyle::Single
419            } else {
420                UnderlineStyle::None
421            }
422        }
423    }
424}
425
426/// Map a raw `text_document::UnderlineStyle` to the typesetter enum.
427fn convert_underline_style_value(
428    s: &text_document::UnderlineStyle,
429) -> crate::types::UnderlineStyle {
430    use crate::types::UnderlineStyle;
431    match s {
432        text_document::UnderlineStyle::SingleUnderline => UnderlineStyle::Single,
433        text_document::UnderlineStyle::DashUnderline => UnderlineStyle::Dash,
434        text_document::UnderlineStyle::DotLine => UnderlineStyle::Dot,
435        text_document::UnderlineStyle::DashDotLine => UnderlineStyle::DashDot,
436        text_document::UnderlineStyle::DashDotDotLine => UnderlineStyle::DashDotDot,
437        text_document::UnderlineStyle::WaveUnderline => UnderlineStyle::Wave,
438        text_document::UnderlineStyle::SpellCheckUnderline => UnderlineStyle::SpellCheck,
439        text_document::UnderlineStyle::NoUnderline => UnderlineStyle::None,
440    }
441}
442
443/// Convert a block snapshot's paint-only highlight overlay into the typesetter's
444/// [`PaintSpan`]s. Char offsets pass through unchanged (both sides are
445/// block-relative char offsets — the space post-layout glyph clusters live in).
446/// Underline is expressed through `underline_style`: an explicit
447/// `underline_style` wins, else `font_underline` maps to Single / None.
448pub fn convert_paint_spans(block: &BlockSnapshot) -> Vec<PaintSpan> {
449    block
450        .paint_highlights
451        .iter()
452        .map(|h| {
453            let underline_style = match &h.underline_style {
454                Some(s) => Some(convert_underline_style_value(s)),
455                None => match h.font_underline {
456                    Some(true) => Some(crate::types::UnderlineStyle::Single),
457                    Some(false) => Some(crate::types::UnderlineStyle::None),
458                    None => None,
459                },
460            };
461            PaintSpan {
462                char_start: h.start,
463                char_end: h.start + h.length,
464                foreground_color: h.foreground_color.as_ref().map(convert_color),
465                underline_color: h.underline_color.as_ref().map(convert_color),
466                background_color: h.background_color.as_ref().map(convert_color),
467                underline_style,
468                overline: h.font_overline,
469                strikeout: h.font_strikeout,
470            }
471        })
472        .collect()
473}
474
475/// Walk a whole [`FlowSnapshot`] (top-level blocks, table cells, and frames
476/// recursively) and collect the paint-only overlay for every block that has
477/// one, keyed by block_id. Blocks without paint highlights are omitted (the
478/// engine resets those to their base colors).
479pub fn collect_paint_spans(
480    flow: &FlowSnapshot,
481) -> std::collections::HashMap<usize, Vec<PaintSpan>> {
482    let mut out = std::collections::HashMap::new();
483    for el in &flow.elements {
484        collect_paint_spans_element(el, &mut out);
485    }
486    out
487}
488
489fn collect_paint_spans_element(
490    el: &FlowElementSnapshot,
491    out: &mut std::collections::HashMap<usize, Vec<PaintSpan>>,
492) {
493    match el {
494        FlowElementSnapshot::Block(b) => {
495            if !b.paint_highlights.is_empty() {
496                out.insert(b.block_id, convert_paint_spans(b));
497            }
498        }
499        FlowElementSnapshot::Table(t) => {
500            for c in &t.cells {
501                for b in &c.blocks {
502                    if !b.paint_highlights.is_empty() {
503                        out.insert(b.block_id, convert_paint_spans(b));
504                    }
505                }
506            }
507        }
508        FlowElementSnapshot::Frame(f) => {
509            for e in &f.elements {
510                collect_paint_spans_element(e, out);
511            }
512        }
513    }
514}
515
516fn convert_color(c: &text_document::Color) -> [f32; 4] {
517    [
518        c.red as f32 / 255.0,
519        c.green as f32 / 255.0,
520        c.blue as f32 / 255.0,
521        c.alpha as f32 / 255.0,
522    ]
523}
524
525/// Parse a CSS color string into RGBA floats (0.0-1.0).
526///
527/// Supports: `#RGB`, `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`, `rgba(r,g,b,a)`,
528/// and common named colors.
529fn parse_css_color(s: &str) -> Option<[f32; 4]> {
530    let s = s.trim();
531
532    // Named colors
533    match s.to_ascii_lowercase().as_str() {
534        "transparent" => return Some([0.0, 0.0, 0.0, 0.0]),
535        "black" => return Some([0.0, 0.0, 0.0, 1.0]),
536        "white" => return Some([1.0, 1.0, 1.0, 1.0]),
537        "red" => return Some([1.0, 0.0, 0.0, 1.0]),
538        "green" => return Some([0.0, 128.0 / 255.0, 0.0, 1.0]),
539        "blue" => return Some([0.0, 0.0, 1.0, 1.0]),
540        "yellow" => return Some([1.0, 1.0, 0.0, 1.0]),
541        "cyan" | "aqua" => return Some([0.0, 1.0, 1.0, 1.0]),
542        "magenta" | "fuchsia" => return Some([1.0, 0.0, 1.0, 1.0]),
543        "gray" | "grey" => return Some([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
544        _ => {}
545    }
546
547    // Hex formats
548    if let Some(hex) = s.strip_prefix('#') {
549        let hex = hex.trim();
550        return match hex.len() {
551            3 => {
552                // #RGB
553                let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
554                let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
555                let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
556                Some([
557                    (r * 17) as f32 / 255.0,
558                    (g * 17) as f32 / 255.0,
559                    (b * 17) as f32 / 255.0,
560                    1.0,
561                ])
562            }
563            4 => {
564                // #RGBA
565                let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
566                let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
567                let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
568                let a = u8::from_str_radix(&hex[3..4], 16).ok()?;
569                Some([
570                    (r * 17) as f32 / 255.0,
571                    (g * 17) as f32 / 255.0,
572                    (b * 17) as f32 / 255.0,
573                    (a * 17) as f32 / 255.0,
574                ])
575            }
576            6 => {
577                // #RRGGBB
578                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
579                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
580                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
581                Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
582            }
583            8 => {
584                // #RRGGBBAA
585                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
586                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
587                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
588                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
589                Some([
590                    r as f32 / 255.0,
591                    g as f32 / 255.0,
592                    b as f32 / 255.0,
593                    a as f32 / 255.0,
594                ])
595            }
596            _ => None,
597        };
598    }
599
600    // rgb(r, g, b) and rgba(r, g, b, a)
601    let inner = s
602        .strip_prefix("rgba(")
603        .and_then(|s| s.strip_suffix(')'))
604        .or_else(|| s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')))?;
605
606    let parts: Vec<&str> = inner.split(',').collect();
607    match parts.len() {
608        3 => {
609            let r: u8 = parts[0].trim().parse().ok()?;
610            let g: u8 = parts[1].trim().parse().ok()?;
611            let b: u8 = parts[2].trim().parse().ok()?;
612            Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
613        }
614        4 => {
615            let r: u8 = parts[0].trim().parse().ok()?;
616            let g: u8 = parts[1].trim().parse().ok()?;
617            let b: u8 = parts[2].trim().parse().ok()?;
618            let a: f32 = parts[3].trim().parse().ok()?;
619            Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a])
620        }
621        _ => None,
622    }
623}
624
625fn convert_alignment(a: &text_document::Alignment) -> Alignment {
626    match a {
627        text_document::Alignment::Left => Alignment::Left,
628        text_document::Alignment::Right => Alignment::Right,
629        text_document::Alignment::Center => Alignment::Center,
630        text_document::Alignment::Justify => Alignment::Justify,
631    }
632}
633
634pub fn convert_table(table: &TableSnapshot) -> TableLayoutParams {
635    convert_table_with(table, &BridgeOptions::default())
636}
637
638pub fn convert_table_with(table: &TableSnapshot, opts: &BridgeOptions) -> TableLayoutParams {
639    let column_widths: Vec<f32> = table.column_widths.iter().map(|&w| w as f32).collect();
640
641    let cells: Vec<CellLayoutParams> = table.cells.iter().map(|c| convert_cell(c, opts)).collect();
642
643    TableLayoutParams {
644        table_id: table.table_id,
645        rows: table.rows,
646        columns: table.columns,
647        column_widths,
648        border_width: table.format.border.unwrap_or(1) as f32,
649        cell_spacing: table.format.cell_spacing.unwrap_or(0) as f32,
650        cell_padding: table.format.cell_padding.unwrap_or(4) as f32,
651        cells,
652    }
653}
654
655fn convert_cell(cell: &CellSnapshot, opts: &BridgeOptions) -> CellLayoutParams {
656    let blocks: Vec<BlockLayoutParams> = cell
657        .blocks
658        .iter()
659        .map(|b| convert_block_with(b, opts))
660        .collect();
661
662    let background_color = cell
663        .format
664        .background_color
665        .as_ref()
666        .and_then(|s| parse_css_color(s));
667
668    CellLayoutParams {
669        row: cell.row,
670        column: cell.column,
671        blocks,
672        background_color,
673    }
674}
675
676pub fn convert_frame(frame: &FrameSnapshot) -> FrameLayoutParams {
677    convert_frame_with(frame, &BridgeOptions::default())
678}
679
680pub fn convert_frame_with(frame: &FrameSnapshot, opts: &BridgeOptions) -> FrameLayoutParams {
681    let mut blocks = Vec::new();
682    let mut tables = Vec::new();
683    let mut frames = Vec::new();
684
685    for (i, element) in frame.elements.iter().enumerate() {
686        match element {
687            FlowElementSnapshot::Block(block) => {
688                // Carry the flow index so `layout_frame` can interleave
689                // blocks with sibling tables/frames in document order.
690                // Dropping the index here is the bug that caused
691                // nested-frame content (e.g. a depth-3 blockquote
692                // sitting between two depth-2 blocks) to render in the
693                // wrong visual order.
694                blocks.push((i, convert_block_with(block, opts)));
695            }
696            FlowElementSnapshot::Table(table) => {
697                tables.push((i, convert_table_with(table, opts)));
698            }
699            FlowElementSnapshot::Frame(inner_frame) => {
700                frames.push((i, convert_frame_with(inner_frame, opts)));
701            }
702        }
703    }
704
705    let position = match &frame.format.position {
706        Some(text_document::FramePosition::InFlow) | None => FramePosition::Inline,
707        Some(text_document::FramePosition::FloatLeft) => FramePosition::FloatLeft,
708        Some(text_document::FramePosition::FloatRight) => FramePosition::FloatRight,
709    };
710
711    let is_blockquote = frame.format.is_blockquote == Some(true);
712
713    FrameLayoutParams {
714        frame_id: frame.frame_id,
715        position,
716        width: frame.format.width.map(|w| w as f32),
717        height: frame.format.height.map(|h| h as f32),
718        margin_top: frame
719            .format
720            .top_margin
721            .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
722        margin_bottom: frame
723            .format
724            .bottom_margin
725            .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
726        margin_left: frame
727            .format
728            .left_margin
729            .unwrap_or(if is_blockquote { 16 } else { 0 }) as f32,
730        margin_right: frame.format.right_margin.unwrap_or(0) as f32,
731        padding: frame
732            .format
733            .padding
734            .unwrap_or(if is_blockquote { 8 } else { 0 }) as f32,
735        border_width: frame
736            .format
737            .border
738            .unwrap_or(if is_blockquote { 3 } else { 0 }) as f32,
739        border_style: if is_blockquote {
740            crate::layout::frame::FrameBorderStyle::LeftOnly
741        } else {
742            crate::layout::frame::FrameBorderStyle::Full
743        },
744        blocks,
745        tables,
746        frames,
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::iso639_1;
753
754    #[test]
755    fn iso639_1_parses_two_letter_codes_case_insensitively() {
756        assert_eq!(iso639_1("en"), Some(*b"en"));
757        assert_eq!(iso639_1("FR"), Some(*b"fr"));
758        assert_eq!(iso639_1("De"), Some(*b"de"));
759        // Region subtags are ignored — only the primary subtag matters.
760        assert_eq!(iso639_1("en-US"), Some(*b"en"));
761        assert_eq!(iso639_1("  fr  "), Some(*b"fr"));
762    }
763
764    #[test]
765    fn iso639_1_rejects_non_letter_codes() {
766        assert_eq!(iso639_1(""), None);
767        assert_eq!(iso639_1("x"), None);
768        assert_eq!(iso639_1("12"), None);
769    }
770}