1use 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
19fn iso639_1(code: &str) -> Option<[u8; 2]> {
25 let b = code.trim().as_bytes();
26 if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1].is_ascii_alphabetic() {
27 Some([b[0].to_ascii_lowercase(), b[1].to_ascii_lowercase()])
28 } else {
29 None
30 }
31}
32
33#[derive(Clone, Copy)]
40pub struct BridgeOptions {
41 pub code_block_background: [f32; 4],
46 pub code_block_foreground: Option<[f32; 4]>,
52 pub echo_char: Option<char>,
62 pub hyphenate_justified: bool,
70}
71
72impl Default for BridgeOptions {
73 fn default() -> Self {
74 Self {
75 code_block_background: [0.95, 0.95, 0.95, 1.0],
76 code_block_foreground: None,
77 echo_char: None,
78 hyphenate_justified: false,
79 }
80 }
81}
82
83pub fn convert_flow(flow: &FlowSnapshot) -> FlowElements {
87 convert_flow_with(flow, &BridgeOptions::default())
88}
89
90pub fn convert_flow_with(flow: &FlowSnapshot, opts: &BridgeOptions) -> FlowElements {
93 let mut blocks = Vec::new();
94 let mut tables = Vec::new();
95 let mut frames = Vec::new();
96
97 for (i, element) in flow.elements.iter().enumerate() {
98 match element {
99 FlowElementSnapshot::Block(block) => {
100 blocks.push((i, convert_block_with(block, opts)));
101 }
102 FlowElementSnapshot::Table(table) => {
103 tables.push((i, convert_table_with(table, opts)));
104 }
105 FlowElementSnapshot::Frame(frame) => {
106 frames.push((i, convert_frame_with(frame, opts)));
107 }
108 }
109 }
110
111 FlowElements {
112 blocks,
113 tables,
114 frames,
115 }
116}
117
118pub struct FlowElements {
120 pub blocks: Vec<(usize, BlockLayoutParams)>,
122 pub tables: Vec<(usize, TableLayoutParams)>,
123 pub frames: Vec<(usize, FrameLayoutParams)>,
124}
125
126pub fn convert_block(block: &BlockSnapshot) -> BlockLayoutParams {
127 convert_block_with(block, &BridgeOptions::default())
128}
129
130pub fn convert_block_with(block: &BlockSnapshot, opts: &BridgeOptions) -> BlockLayoutParams {
133 let alignment = block
134 .block_format
135 .alignment
136 .as_ref()
137 .map(convert_alignment)
138 .unwrap_or_default();
139
140 let heading_scale = match block.block_format.heading_level {
141 Some(1) => 2.0,
142 Some(2) => 1.5,
143 Some(3) => 1.25,
144 Some(4) => 1.1,
145 _ => 1.0,
146 };
147
148 let char_to_byte: Vec<usize> = block
166 .text
167 .char_indices()
168 .map(|(b, _)| b)
169 .chain(std::iter::once(block.text.len()))
170 .collect();
171 let fragments: Vec<FragmentParams> = block
172 .fragments
173 .iter()
174 .map(|f| {
175 let char_offset = match f {
176 FragmentContent::Text { offset, .. } => *offset,
177 FragmentContent::Image { offset, .. } => *offset,
178 };
179 let byte_offset = char_to_byte
180 .get(char_offset)
181 .copied()
182 .unwrap_or(block.text.len());
183 convert_fragment(f, heading_scale, opts, byte_offset)
184 })
185 .collect();
186
187 let indent_level = block.block_format.indent.unwrap_or(0) as f32;
188
189 let (list_marker, list_indent) = if let Some(ref info) = block.list_info {
190 let list_indent_level = info.indent as f32;
191 (
192 info.marker.clone(),
193 DEFAULT_LIST_INDENT + list_indent_level * INDENT_PER_LEVEL,
194 )
195 } else {
196 (String::new(), indent_level * INDENT_PER_LEVEL)
197 };
198
199 let checkbox = match block.block_format.marker {
200 Some(text_document::MarkerType::Checked) => Some(true),
201 Some(text_document::MarkerType::Unchecked) => Some(false),
202 _ => None,
203 };
204
205 let mut params = BlockLayoutParams {
206 block_id: block.block_id,
207 position: block.position,
208 text: block.text.clone(),
209 fragments,
210 alignment,
211 top_margin: block.block_format.top_margin.unwrap_or(0) as f32,
212 bottom_margin: block.block_format.bottom_margin.unwrap_or(0) as f32,
213 left_margin: block.block_format.left_margin.unwrap_or(0) as f32,
214 right_margin: block.block_format.right_margin.unwrap_or(0) as f32,
215 text_indent: block.block_format.text_indent.unwrap_or(0) as f32,
216 list_marker,
217 list_indent,
218 tab_positions: block
219 .block_format
220 .tab_positions
221 .iter()
222 .map(|&t| t as f32)
223 .collect(),
224 line_height_multiplier: block.block_format.line_height,
225 non_breakable_lines: block.block_format.non_breakable_lines.unwrap_or(false)
226 || block.block_format.is_code_block == Some(true),
227 hyphenation: {
234 let enabled = match block.block_format.hyphenate {
235 Some(v) => v,
236 None => opts.hyphenate_justified && alignment == Alignment::Justify,
237 };
238 enabled.then(|| crate::types::Hyphenation {
239 language: block
240 .block_format
241 .language
242 .as_deref()
243 .and_then(iso639_1)
244 .unwrap_or(*b"en"),
245 })
246 },
247 checkbox,
248 background_color: block
249 .block_format
250 .background_color
251 .as_ref()
252 .and_then(|s| parse_css_color(s))
253 .or_else(|| {
254 if block.block_format.is_code_block == Some(true) {
255 Some(opts.code_block_background)
256 } else {
257 None
258 }
259 }),
260 };
261
262 if let Some(echo) = opts.echo_char {
263 mask_block_params(&mut params, echo);
264 }
265
266 params
267}
268
269fn mask_block_params(params: &mut BlockLayoutParams, echo: char) {
278 if params.fragments.is_empty() {
279 params.text = echo.to_string().repeat(params.text.chars().count());
280 return;
281 }
282 let mut masked_block = String::new();
283 let mut byte_cursor = 0usize;
284 for frag in params.fragments.iter_mut() {
285 frag.offset = byte_cursor;
286 if frag.image_name.is_some() {
287 masked_block.push_str(&frag.text);
290 byte_cursor += frag.text.len();
291 continue;
292 }
293 let masked = echo.to_string().repeat(frag.text.chars().count());
294 byte_cursor += masked.len();
295 masked_block.push_str(&masked);
296 frag.text = masked;
297 }
298 params.text = masked_block;
299}
300
301fn convert_fragment(
302 frag: &FragmentContent,
303 heading_scale: f32,
304 opts: &BridgeOptions,
305 byte_offset: usize,
306) -> FragmentParams {
307 match frag {
308 FragmentContent::Text {
309 text,
310 format,
311 length,
312 ..
313 } => {
314 let is_monospace = format
319 .font_family
320 .as_deref()
321 .map(|f| f.eq_ignore_ascii_case("monospace"))
322 .unwrap_or(false);
323 let foreground_color =
324 format
325 .foreground_color
326 .as_ref()
327 .map(convert_color)
328 .or(if is_monospace {
329 opts.code_block_foreground
330 } else {
331 None
332 });
333 FragmentParams {
334 text: text.clone(),
335 offset: byte_offset,
336 length: *length,
337 font_family: format.font_family.clone(),
338 font_weight: format.font_weight,
339 font_bold: format.font_bold,
340 font_italic: format.font_italic,
341 font_point_size: if heading_scale != 1.0 {
342 Some((format.font_point_size.unwrap_or(16) as f32 * heading_scale) as u32)
344 } else {
345 format.font_point_size
346 },
347 underline_style: convert_underline_style(format),
348 overline: format.font_overline.unwrap_or(false),
349 strikeout: format.font_strikeout.unwrap_or(false),
350 is_link: format.is_anchor.unwrap_or(false),
351 letter_spacing: format.letter_spacing.unwrap_or(0) as f32,
352 word_spacing: format.word_spacing.unwrap_or(0) as f32,
353 foreground_color,
354 underline_color: format.underline_color.as_ref().map(convert_color),
355 background_color: format.background_color.as_ref().map(convert_color),
356 anchor_href: format.anchor_href.clone(),
357 tooltip: format.tooltip.clone(),
358 vertical_alignment: convert_vertical_alignment(format),
359 image_name: None,
360 image_width: 0.0,
361 image_height: 0.0,
362 features: Vec::new(),
363 }
364 }
365 FragmentContent::Image {
366 name,
367 width,
368 height,
369 quality: _,
370 format,
371 ..
372 } => FragmentParams {
373 text: "\u{FFFC}".to_string(),
374 offset: byte_offset,
375 length: 1,
376 font_family: None,
377 font_weight: None,
378 font_bold: None,
379 font_italic: None,
380 font_point_size: None,
381 underline_style: crate::types::UnderlineStyle::None,
382 overline: false,
383 strikeout: false,
384 is_link: format.is_anchor.unwrap_or(false),
385 letter_spacing: 0.0,
386 word_spacing: 0.0,
387 foreground_color: None,
388 underline_color: None,
389 background_color: None,
390 anchor_href: format.anchor_href.clone(),
391 tooltip: format.tooltip.clone(),
392 vertical_alignment: crate::types::VerticalAlignment::Normal,
393 image_name: Some(name.clone()),
394 image_width: *width as f32,
395 image_height: *height as f32,
396 features: Vec::new(),
397 },
398 }
399}
400
401fn convert_vertical_alignment(
402 format: &text_document::TextFormat,
403) -> crate::types::VerticalAlignment {
404 use crate::types::VerticalAlignment;
405 match format.vertical_alignment {
406 Some(text_document::CharVerticalAlignment::SuperScript) => VerticalAlignment::SuperScript,
407 Some(text_document::CharVerticalAlignment::SubScript) => VerticalAlignment::SubScript,
408 _ => VerticalAlignment::Normal,
409 }
410}
411
412fn convert_underline_style(format: &text_document::TextFormat) -> crate::types::UnderlineStyle {
413 use crate::types::UnderlineStyle;
414 match &format.underline_style {
415 Some(s) => convert_underline_style_value(s),
416 None => {
417 if format.font_underline.unwrap_or(false) {
418 UnderlineStyle::Single
419 } else {
420 UnderlineStyle::None
421 }
422 }
423 }
424}
425
426fn convert_underline_style_value(
428 s: &text_document::UnderlineStyle,
429) -> crate::types::UnderlineStyle {
430 use crate::types::UnderlineStyle;
431 match s {
432 text_document::UnderlineStyle::SingleUnderline => UnderlineStyle::Single,
433 text_document::UnderlineStyle::DashUnderline => UnderlineStyle::Dash,
434 text_document::UnderlineStyle::DotLine => UnderlineStyle::Dot,
435 text_document::UnderlineStyle::DashDotLine => UnderlineStyle::DashDot,
436 text_document::UnderlineStyle::DashDotDotLine => UnderlineStyle::DashDotDot,
437 text_document::UnderlineStyle::WaveUnderline => UnderlineStyle::Wave,
438 text_document::UnderlineStyle::SpellCheckUnderline => UnderlineStyle::SpellCheck,
439 text_document::UnderlineStyle::NoUnderline => UnderlineStyle::None,
440 }
441}
442
443pub fn convert_paint_spans(block: &BlockSnapshot) -> Vec<PaintSpan> {
449 block
450 .paint_highlights
451 .iter()
452 .map(|h| {
453 let underline_style = match &h.underline_style {
454 Some(s) => Some(convert_underline_style_value(s)),
455 None => match h.font_underline {
456 Some(true) => Some(crate::types::UnderlineStyle::Single),
457 Some(false) => Some(crate::types::UnderlineStyle::None),
458 None => None,
459 },
460 };
461 PaintSpan {
462 char_start: h.start,
463 char_end: h.start + h.length,
464 foreground_color: h.foreground_color.as_ref().map(convert_color),
465 underline_color: h.underline_color.as_ref().map(convert_color),
466 background_color: h.background_color.as_ref().map(convert_color),
467 underline_style,
468 overline: h.font_overline,
469 strikeout: h.font_strikeout,
470 }
471 })
472 .collect()
473}
474
475pub fn collect_paint_spans(
480 flow: &FlowSnapshot,
481) -> std::collections::HashMap<usize, Vec<PaintSpan>> {
482 let mut out = std::collections::HashMap::new();
483 for el in &flow.elements {
484 collect_paint_spans_element(el, &mut out);
485 }
486 out
487}
488
489fn collect_paint_spans_element(
490 el: &FlowElementSnapshot,
491 out: &mut std::collections::HashMap<usize, Vec<PaintSpan>>,
492) {
493 match el {
494 FlowElementSnapshot::Block(b) => {
495 if !b.paint_highlights.is_empty() {
496 out.insert(b.block_id, convert_paint_spans(b));
497 }
498 }
499 FlowElementSnapshot::Table(t) => {
500 for c in &t.cells {
501 for b in &c.blocks {
502 if !b.paint_highlights.is_empty() {
503 out.insert(b.block_id, convert_paint_spans(b));
504 }
505 }
506 }
507 }
508 FlowElementSnapshot::Frame(f) => {
509 for e in &f.elements {
510 collect_paint_spans_element(e, out);
511 }
512 }
513 }
514}
515
516fn convert_color(c: &text_document::Color) -> [f32; 4] {
517 [
518 c.red as f32 / 255.0,
519 c.green as f32 / 255.0,
520 c.blue as f32 / 255.0,
521 c.alpha as f32 / 255.0,
522 ]
523}
524
525fn parse_css_color(s: &str) -> Option<[f32; 4]> {
530 let s = s.trim();
531
532 match s.to_ascii_lowercase().as_str() {
534 "transparent" => return Some([0.0, 0.0, 0.0, 0.0]),
535 "black" => return Some([0.0, 0.0, 0.0, 1.0]),
536 "white" => return Some([1.0, 1.0, 1.0, 1.0]),
537 "red" => return Some([1.0, 0.0, 0.0, 1.0]),
538 "green" => return Some([0.0, 128.0 / 255.0, 0.0, 1.0]),
539 "blue" => return Some([0.0, 0.0, 1.0, 1.0]),
540 "yellow" => return Some([1.0, 1.0, 0.0, 1.0]),
541 "cyan" | "aqua" => return Some([0.0, 1.0, 1.0, 1.0]),
542 "magenta" | "fuchsia" => return Some([1.0, 0.0, 1.0, 1.0]),
543 "gray" | "grey" => return Some([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
544 _ => {}
545 }
546
547 if let Some(hex) = s.strip_prefix('#') {
549 let hex = hex.trim();
550 return match hex.len() {
551 3 => {
552 let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
554 let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
555 let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
556 Some([
557 (r * 17) as f32 / 255.0,
558 (g * 17) as f32 / 255.0,
559 (b * 17) as f32 / 255.0,
560 1.0,
561 ])
562 }
563 4 => {
564 let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
566 let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
567 let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
568 let a = u8::from_str_radix(&hex[3..4], 16).ok()?;
569 Some([
570 (r * 17) as f32 / 255.0,
571 (g * 17) as f32 / 255.0,
572 (b * 17) as f32 / 255.0,
573 (a * 17) as f32 / 255.0,
574 ])
575 }
576 6 => {
577 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
579 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
580 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
581 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
582 }
583 8 => {
584 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
586 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
587 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
588 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
589 Some([
590 r as f32 / 255.0,
591 g as f32 / 255.0,
592 b as f32 / 255.0,
593 a as f32 / 255.0,
594 ])
595 }
596 _ => None,
597 };
598 }
599
600 let inner = s
602 .strip_prefix("rgba(")
603 .and_then(|s| s.strip_suffix(')'))
604 .or_else(|| s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')))?;
605
606 let parts: Vec<&str> = inner.split(',').collect();
607 match parts.len() {
608 3 => {
609 let r: u8 = parts[0].trim().parse().ok()?;
610 let g: u8 = parts[1].trim().parse().ok()?;
611 let b: u8 = parts[2].trim().parse().ok()?;
612 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
613 }
614 4 => {
615 let r: u8 = parts[0].trim().parse().ok()?;
616 let g: u8 = parts[1].trim().parse().ok()?;
617 let b: u8 = parts[2].trim().parse().ok()?;
618 let a: f32 = parts[3].trim().parse().ok()?;
619 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a])
620 }
621 _ => None,
622 }
623}
624
625fn convert_alignment(a: &text_document::Alignment) -> Alignment {
626 match a {
627 text_document::Alignment::Left => Alignment::Left,
628 text_document::Alignment::Right => Alignment::Right,
629 text_document::Alignment::Center => Alignment::Center,
630 text_document::Alignment::Justify => Alignment::Justify,
631 }
632}
633
634pub fn convert_table(table: &TableSnapshot) -> TableLayoutParams {
635 convert_table_with(table, &BridgeOptions::default())
636}
637
638pub fn convert_table_with(table: &TableSnapshot, opts: &BridgeOptions) -> TableLayoutParams {
639 let column_widths: Vec<f32> = table.column_widths.iter().map(|&w| w as f32).collect();
640
641 let cells: Vec<CellLayoutParams> = table.cells.iter().map(|c| convert_cell(c, opts)).collect();
642
643 TableLayoutParams {
644 table_id: table.table_id,
645 rows: table.rows,
646 columns: table.columns,
647 column_widths,
648 border_width: table.format.border.unwrap_or(1) as f32,
649 cell_spacing: table.format.cell_spacing.unwrap_or(0) as f32,
650 cell_padding: table.format.cell_padding.unwrap_or(4) as f32,
651 cells,
652 }
653}
654
655fn convert_cell(cell: &CellSnapshot, opts: &BridgeOptions) -> CellLayoutParams {
656 let blocks: Vec<BlockLayoutParams> = cell
657 .blocks
658 .iter()
659 .map(|b| convert_block_with(b, opts))
660 .collect();
661
662 let background_color = cell
663 .format
664 .background_color
665 .as_ref()
666 .and_then(|s| parse_css_color(s));
667
668 CellLayoutParams {
669 row: cell.row,
670 column: cell.column,
671 blocks,
672 background_color,
673 }
674}
675
676pub fn convert_frame(frame: &FrameSnapshot) -> FrameLayoutParams {
677 convert_frame_with(frame, &BridgeOptions::default())
678}
679
680pub fn convert_frame_with(frame: &FrameSnapshot, opts: &BridgeOptions) -> FrameLayoutParams {
681 let mut blocks = Vec::new();
682 let mut tables = Vec::new();
683 let mut frames = Vec::new();
684
685 for (i, element) in frame.elements.iter().enumerate() {
686 match element {
687 FlowElementSnapshot::Block(block) => {
688 blocks.push((i, convert_block_with(block, opts)));
695 }
696 FlowElementSnapshot::Table(table) => {
697 tables.push((i, convert_table_with(table, opts)));
698 }
699 FlowElementSnapshot::Frame(inner_frame) => {
700 frames.push((i, convert_frame_with(inner_frame, opts)));
701 }
702 }
703 }
704
705 let position = match &frame.format.position {
706 Some(text_document::FramePosition::InFlow) | None => FramePosition::Inline,
707 Some(text_document::FramePosition::FloatLeft) => FramePosition::FloatLeft,
708 Some(text_document::FramePosition::FloatRight) => FramePosition::FloatRight,
709 };
710
711 let is_blockquote = frame.format.is_blockquote == Some(true);
712
713 FrameLayoutParams {
714 frame_id: frame.frame_id,
715 position,
716 width: frame.format.width.map(|w| w as f32),
717 height: frame.format.height.map(|h| h as f32),
718 margin_top: frame
719 .format
720 .top_margin
721 .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
722 margin_bottom: frame
723 .format
724 .bottom_margin
725 .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
726 margin_left: frame
727 .format
728 .left_margin
729 .unwrap_or(if is_blockquote { 16 } else { 0 }) as f32,
730 margin_right: frame.format.right_margin.unwrap_or(0) as f32,
731 padding: frame
732 .format
733 .padding
734 .unwrap_or(if is_blockquote { 8 } else { 0 }) as f32,
735 border_width: frame
736 .format
737 .border
738 .unwrap_or(if is_blockquote { 3 } else { 0 }) as f32,
739 border_style: if is_blockquote {
740 crate::layout::frame::FrameBorderStyle::LeftOnly
741 } else {
742 crate::layout::frame::FrameBorderStyle::Full
743 },
744 blocks,
745 tables,
746 frames,
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::iso639_1;
753
754 #[test]
755 fn iso639_1_parses_two_letter_codes_case_insensitively() {
756 assert_eq!(iso639_1("en"), Some(*b"en"));
757 assert_eq!(iso639_1("FR"), Some(*b"fr"));
758 assert_eq!(iso639_1("De"), Some(*b"de"));
759 assert_eq!(iso639_1("en-US"), Some(*b"en"));
761 assert_eq!(iso639_1(" fr "), Some(*b"fr"));
762 }
763
764 #[test]
765 fn iso639_1_rejects_non_letter_codes() {
766 assert_eq!(iso639_1(""), None);
767 assert_eq!(iso639_1("x"), None);
768 assert_eq!(iso639_1("12"), None);
769 }
770}