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