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};
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/// Convert a FlowSnapshot into layout params that can be fed to a [`DocumentFlow`].
20///
21/// [`DocumentFlow`]: crate::DocumentFlow
22pub fn convert_flow(flow: &FlowSnapshot) -> FlowElements {
23    let mut blocks = Vec::new();
24    let mut tables = Vec::new();
25    let mut frames = Vec::new();
26
27    for (i, element) in flow.elements.iter().enumerate() {
28        match element {
29            FlowElementSnapshot::Block(block) => {
30                blocks.push((i, convert_block(block)));
31            }
32            FlowElementSnapshot::Table(table) => {
33                tables.push((i, convert_table(table)));
34            }
35            FlowElementSnapshot::Frame(frame) => {
36                frames.push((i, convert_frame(frame)));
37            }
38        }
39    }
40
41    FlowElements {
42        blocks,
43        tables,
44        frames,
45    }
46}
47
48/// Converted flow elements, ordered by their position in the flow.
49pub struct FlowElements {
50    /// (flow_index, params)
51    pub blocks: Vec<(usize, BlockLayoutParams)>,
52    pub tables: Vec<(usize, TableLayoutParams)>,
53    pub frames: Vec<(usize, FrameLayoutParams)>,
54}
55
56pub fn convert_block(block: &BlockSnapshot) -> BlockLayoutParams {
57    let alignment = block
58        .block_format
59        .alignment
60        .as_ref()
61        .map(convert_alignment)
62        .unwrap_or_default();
63
64    let heading_scale = match block.block_format.heading_level {
65        Some(1) => 2.0,
66        Some(2) => 1.5,
67        Some(3) => 1.25,
68        Some(4) => 1.1,
69        _ => 1.0,
70    };
71
72    let fragments: Vec<FragmentParams> = block
73        .fragments
74        .iter()
75        .map(|f| convert_fragment(f, heading_scale))
76        .collect();
77
78    let indent_level = block.block_format.indent.unwrap_or(0) as f32;
79
80    let (list_marker, list_indent) = if let Some(ref info) = block.list_info {
81        let list_indent_level = info.indent as f32;
82        (
83            info.marker.clone(),
84            DEFAULT_LIST_INDENT + list_indent_level * INDENT_PER_LEVEL,
85        )
86    } else {
87        (String::new(), indent_level * INDENT_PER_LEVEL)
88    };
89
90    let checkbox = match block.block_format.marker {
91        Some(text_document::MarkerType::Checked) => Some(true),
92        Some(text_document::MarkerType::Unchecked) => Some(false),
93        _ => None,
94    };
95
96    BlockLayoutParams {
97        block_id: block.block_id,
98        position: block.position,
99        text: block.text.clone(),
100        fragments,
101        alignment,
102        top_margin: block.block_format.top_margin.unwrap_or(0) as f32,
103        bottom_margin: block.block_format.bottom_margin.unwrap_or(0) as f32,
104        left_margin: block.block_format.left_margin.unwrap_or(0) as f32,
105        right_margin: block.block_format.right_margin.unwrap_or(0) as f32,
106        text_indent: block.block_format.text_indent.unwrap_or(0) as f32,
107        list_marker,
108        list_indent,
109        tab_positions: block
110            .block_format
111            .tab_positions
112            .iter()
113            .map(|&t| t as f32)
114            .collect(),
115        line_height_multiplier: block.block_format.line_height,
116        non_breakable_lines: block.block_format.non_breakable_lines.unwrap_or(false)
117            || block.block_format.is_code_block == Some(true),
118        checkbox,
119        background_color: block
120            .block_format
121            .background_color
122            .as_ref()
123            .and_then(|s| parse_css_color(s))
124            .or_else(|| {
125                if block.block_format.is_code_block == Some(true) {
126                    Some([0.95, 0.95, 0.95, 1.0])
127                } else {
128                    None
129                }
130            }),
131    }
132}
133
134fn convert_fragment(frag: &FragmentContent, heading_scale: f32) -> FragmentParams {
135    match frag {
136        FragmentContent::Text {
137            text,
138            format,
139            offset,
140            length,
141            ..
142        } => FragmentParams {
143            text: text.clone(),
144            offset: *offset,
145            length: *length,
146            font_family: format.font_family.clone(),
147            font_weight: format.font_weight,
148            font_bold: format.font_bold,
149            font_italic: format.font_italic,
150            font_point_size: if heading_scale != 1.0 {
151                // Apply heading scale; use 16 as default if no explicit size
152                Some((format.font_point_size.unwrap_or(16) as f32 * heading_scale) as u32)
153            } else {
154                format.font_point_size
155            },
156            underline_style: convert_underline_style(format),
157            overline: format.font_overline.unwrap_or(false),
158            strikeout: format.font_strikeout.unwrap_or(false),
159            is_link: format.is_anchor.unwrap_or(false),
160            letter_spacing: format.letter_spacing.unwrap_or(0) as f32,
161            word_spacing: format.word_spacing.unwrap_or(0) as f32,
162            foreground_color: format.foreground_color.as_ref().map(convert_color),
163            underline_color: format.underline_color.as_ref().map(convert_color),
164            background_color: format.background_color.as_ref().map(convert_color),
165            anchor_href: format.anchor_href.clone(),
166            tooltip: format.tooltip.clone(),
167            vertical_alignment: convert_vertical_alignment(format),
168            image_name: None,
169            image_width: 0.0,
170            image_height: 0.0,
171        },
172        FragmentContent::Image {
173            name,
174            width,
175            height,
176            quality: _,
177            format,
178            offset,
179            ..
180        } => FragmentParams {
181            text: "\u{FFFC}".to_string(),
182            offset: *offset,
183            length: 1,
184            font_family: None,
185            font_weight: None,
186            font_bold: None,
187            font_italic: None,
188            font_point_size: None,
189            underline_style: crate::types::UnderlineStyle::None,
190            overline: false,
191            strikeout: false,
192            is_link: format.is_anchor.unwrap_or(false),
193            letter_spacing: 0.0,
194            word_spacing: 0.0,
195            foreground_color: None,
196            underline_color: None,
197            background_color: None,
198            anchor_href: format.anchor_href.clone(),
199            tooltip: format.tooltip.clone(),
200            vertical_alignment: crate::types::VerticalAlignment::Normal,
201            image_name: Some(name.clone()),
202            image_width: *width as f32,
203            image_height: *height as f32,
204        },
205    }
206}
207
208fn convert_vertical_alignment(
209    format: &text_document::TextFormat,
210) -> crate::types::VerticalAlignment {
211    use crate::types::VerticalAlignment;
212    match format.vertical_alignment {
213        Some(text_document::CharVerticalAlignment::SuperScript) => VerticalAlignment::SuperScript,
214        Some(text_document::CharVerticalAlignment::SubScript) => VerticalAlignment::SubScript,
215        _ => VerticalAlignment::Normal,
216    }
217}
218
219fn convert_underline_style(format: &text_document::TextFormat) -> crate::types::UnderlineStyle {
220    use crate::types::UnderlineStyle;
221    match format.underline_style {
222        Some(text_document::UnderlineStyle::SingleUnderline) => UnderlineStyle::Single,
223        Some(text_document::UnderlineStyle::DashUnderline) => UnderlineStyle::Dash,
224        Some(text_document::UnderlineStyle::DotLine) => UnderlineStyle::Dot,
225        Some(text_document::UnderlineStyle::DashDotLine) => UnderlineStyle::DashDot,
226        Some(text_document::UnderlineStyle::DashDotDotLine) => UnderlineStyle::DashDotDot,
227        Some(text_document::UnderlineStyle::WaveUnderline) => UnderlineStyle::Wave,
228        Some(text_document::UnderlineStyle::SpellCheckUnderline) => UnderlineStyle::SpellCheck,
229        Some(text_document::UnderlineStyle::NoUnderline) => UnderlineStyle::None,
230        None => {
231            if format.font_underline.unwrap_or(false) {
232                UnderlineStyle::Single
233            } else {
234                UnderlineStyle::None
235            }
236        }
237    }
238}
239
240fn convert_color(c: &text_document::Color) -> [f32; 4] {
241    [
242        c.red as f32 / 255.0,
243        c.green as f32 / 255.0,
244        c.blue as f32 / 255.0,
245        c.alpha as f32 / 255.0,
246    ]
247}
248
249/// Parse a CSS color string into RGBA floats (0.0-1.0).
250///
251/// Supports: `#RGB`, `#RRGGBB`, `#RRGGBBAA`, `rgb(r,g,b)`, `rgba(r,g,b,a)`,
252/// and common named colors.
253fn parse_css_color(s: &str) -> Option<[f32; 4]> {
254    let s = s.trim();
255
256    // Named colors
257    match s.to_ascii_lowercase().as_str() {
258        "transparent" => return Some([0.0, 0.0, 0.0, 0.0]),
259        "black" => return Some([0.0, 0.0, 0.0, 1.0]),
260        "white" => return Some([1.0, 1.0, 1.0, 1.0]),
261        "red" => return Some([1.0, 0.0, 0.0, 1.0]),
262        "green" => return Some([0.0, 128.0 / 255.0, 0.0, 1.0]),
263        "blue" => return Some([0.0, 0.0, 1.0, 1.0]),
264        "yellow" => return Some([1.0, 1.0, 0.0, 1.0]),
265        "cyan" | "aqua" => return Some([0.0, 1.0, 1.0, 1.0]),
266        "magenta" | "fuchsia" => return Some([1.0, 0.0, 1.0, 1.0]),
267        "gray" | "grey" => return Some([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
268        _ => {}
269    }
270
271    // Hex formats
272    if let Some(hex) = s.strip_prefix('#') {
273        let hex = hex.trim();
274        return match hex.len() {
275            3 => {
276                // #RGB
277                let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
278                let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
279                let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
280                Some([
281                    (r * 17) as f32 / 255.0,
282                    (g * 17) as f32 / 255.0,
283                    (b * 17) as f32 / 255.0,
284                    1.0,
285                ])
286            }
287            4 => {
288                // #RGBA
289                let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
290                let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
291                let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
292                let a = u8::from_str_radix(&hex[3..4], 16).ok()?;
293                Some([
294                    (r * 17) as f32 / 255.0,
295                    (g * 17) as f32 / 255.0,
296                    (b * 17) as f32 / 255.0,
297                    (a * 17) as f32 / 255.0,
298                ])
299            }
300            6 => {
301                // #RRGGBB
302                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
303                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
304                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
305                Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
306            }
307            8 => {
308                // #RRGGBBAA
309                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
310                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
311                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
312                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
313                Some([
314                    r as f32 / 255.0,
315                    g as f32 / 255.0,
316                    b as f32 / 255.0,
317                    a as f32 / 255.0,
318                ])
319            }
320            _ => None,
321        };
322    }
323
324    // rgb(r, g, b) and rgba(r, g, b, a)
325    let inner = s
326        .strip_prefix("rgba(")
327        .and_then(|s| s.strip_suffix(')'))
328        .or_else(|| s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')))?;
329
330    let parts: Vec<&str> = inner.split(',').collect();
331    match parts.len() {
332        3 => {
333            let r: u8 = parts[0].trim().parse().ok()?;
334            let g: u8 = parts[1].trim().parse().ok()?;
335            let b: u8 = parts[2].trim().parse().ok()?;
336            Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
337        }
338        4 => {
339            let r: u8 = parts[0].trim().parse().ok()?;
340            let g: u8 = parts[1].trim().parse().ok()?;
341            let b: u8 = parts[2].trim().parse().ok()?;
342            let a: f32 = parts[3].trim().parse().ok()?;
343            Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a])
344        }
345        _ => None,
346    }
347}
348
349fn convert_alignment(a: &text_document::Alignment) -> Alignment {
350    match a {
351        text_document::Alignment::Left => Alignment::Left,
352        text_document::Alignment::Right => Alignment::Right,
353        text_document::Alignment::Center => Alignment::Center,
354        text_document::Alignment::Justify => Alignment::Justify,
355    }
356}
357
358pub fn convert_table(table: &TableSnapshot) -> TableLayoutParams {
359    let column_widths: Vec<f32> = table.column_widths.iter().map(|&w| w as f32).collect();
360
361    let cells: Vec<CellLayoutParams> = table.cells.iter().map(convert_cell).collect();
362
363    TableLayoutParams {
364        table_id: table.table_id,
365        rows: table.rows,
366        columns: table.columns,
367        column_widths,
368        border_width: table.format.border.unwrap_or(1) as f32,
369        cell_spacing: table.format.cell_spacing.unwrap_or(0) as f32,
370        cell_padding: table.format.cell_padding.unwrap_or(4) as f32,
371        cells,
372    }
373}
374
375fn convert_cell(cell: &CellSnapshot) -> CellLayoutParams {
376    let blocks: Vec<BlockLayoutParams> = cell.blocks.iter().map(convert_block).collect();
377
378    let background_color = cell
379        .format
380        .background_color
381        .as_ref()
382        .and_then(|s| parse_css_color(s));
383
384    CellLayoutParams {
385        row: cell.row,
386        column: cell.column,
387        blocks,
388        background_color,
389    }
390}
391
392pub fn convert_frame(frame: &FrameSnapshot) -> FrameLayoutParams {
393    let mut blocks = Vec::new();
394    let mut tables = Vec::new();
395    let mut frames = Vec::new();
396
397    for (i, element) in frame.elements.iter().enumerate() {
398        match element {
399            FlowElementSnapshot::Block(block) => {
400                blocks.push(convert_block(block));
401            }
402            FlowElementSnapshot::Table(table) => {
403                tables.push((i, convert_table(table)));
404            }
405            FlowElementSnapshot::Frame(inner_frame) => {
406                frames.push((i, convert_frame(inner_frame)));
407            }
408        }
409    }
410
411    let position = match &frame.format.position {
412        Some(text_document::FramePosition::InFlow) | None => FramePosition::Inline,
413        Some(text_document::FramePosition::FloatLeft) => FramePosition::FloatLeft,
414        Some(text_document::FramePosition::FloatRight) => FramePosition::FloatRight,
415    };
416
417    let is_blockquote = frame.format.is_blockquote == Some(true);
418
419    FrameLayoutParams {
420        frame_id: frame.frame_id,
421        position,
422        width: frame.format.width.map(|w| w as f32),
423        height: frame.format.height.map(|h| h as f32),
424        margin_top: frame
425            .format
426            .top_margin
427            .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
428        margin_bottom: frame
429            .format
430            .bottom_margin
431            .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
432        margin_left: frame
433            .format
434            .left_margin
435            .unwrap_or(if is_blockquote { 16 } else { 0 }) as f32,
436        margin_right: frame.format.right_margin.unwrap_or(0) as f32,
437        padding: frame
438            .format
439            .padding
440            .unwrap_or(if is_blockquote { 8 } else { 0 }) as f32,
441        border_width: frame
442            .format
443            .border
444            .unwrap_or(if is_blockquote { 3 } else { 0 }) as f32,
445        border_style: if is_blockquote {
446            crate::layout::frame::FrameBorderStyle::LeftOnly
447        } else {
448            crate::layout::frame::FrameBorderStyle::Full
449        },
450        blocks,
451        tables,
452        frames,
453    }
454}