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