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