1use std::collections::BTreeMap;
37use std::fmt::Write as _;
38
39use google_docs1::api::{
40 CreateParagraphBulletsRequest, Dimension, InsertTableRequest, InsertTextRequest, Link,
41 Location, ParagraphStyle, Range, Request, TextStyle, UpdateParagraphStyleRequest,
42 UpdateTextStyleRequest, WeightedFontFamily,
43};
44use google_docs1::common::FieldMask;
45use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
46use sha2::{Digest, Sha256};
47
48use crate::custom_id::section_id;
49use crate::google::utf16::len_utf16;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ElementKind {
55 Heading,
57 Paragraph,
59 List,
61 Table,
63 SrcBlock,
65 ExampleBlock,
67 Quote,
69 HorizontalRule,
71}
72
73impl ElementKind {
74 #[must_use]
77 pub const fn slug(self) -> &'static str {
78 match self {
79 Self::Heading => "heading",
80 Self::Paragraph => "paragraph",
81 Self::List => "list",
82 Self::Table => "table",
83 Self::SrcBlock => "src",
84 Self::ExampleBlock => "example",
85 Self::Quote => "quote",
86 Self::HorizontalRule => "hr",
87 }
88 }
89
90 #[must_use]
92 pub fn from_slug(slug: &str) -> Option<Self> {
93 match slug {
94 "heading" => Some(Self::Heading),
95 "paragraph" => Some(Self::Paragraph),
96 "list" => Some(Self::List),
97 "table" => Some(Self::Table),
98 "src" => Some(Self::SrcBlock),
99 "example" => Some(Self::ExampleBlock),
100 "quote" => Some(Self::Quote),
101 "hr" => Some(Self::HorizontalRule),
102 _ => None,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Position {
110 pub index: u32,
112 pub kind: ElementKind,
114}
115
116pub type PositionMap = BTreeMap<String, Position>;
121
122#[derive(Debug, Clone)]
127pub struct Projection {
128 pub requests: Vec<Request>,
130 pub positions: PositionMap,
132}
133
134impl Projection {
135 #[must_use]
143 pub fn fingerprint(&self) -> String {
144 let serialized = serde_json::to_vec(&self.requests).unwrap_or_default();
148 let mut hasher = Sha256::new();
149 hasher.update(&serialized);
150 hasher
151 .finalize()
152 .iter()
153 .fold(String::new(), |mut acc, byte| {
154 let _ = write!(acc, "{byte:02x}");
156 acc
157 })
158 }
159}
160
161pub(crate) const DOCUMENT_PARENT: &str = "doc";
167
168const MONOSPACE_FONT: &str = "Courier New";
170
171const RULE_GLYPHS: &str = "────────────────────";
173
174const MARK_UNCHECKED: &str = "\u{2610} ";
176const MARK_CHECKED: &str = "\u{2611} ";
177
178#[must_use]
180pub fn project(document: &Document) -> Projection {
181 let mut builder = Builder::new();
182 builder.blocks(&document.blocks, DOCUMENT_PARENT);
183 Projection {
184 requests: builder.requests,
185 positions: builder.positions,
186 }
187}
188
189struct Builder {
192 requests: Vec<Request>,
193 positions: PositionMap,
194 cursor: u32,
195 counters: BTreeMap<String, u32>,
196}
197
198impl Builder {
199 fn new() -> Self {
200 Self {
201 requests: Vec::new(),
202 positions: PositionMap::new(),
203 cursor: 1,
204 counters: BTreeMap::new(),
205 }
206 }
207
208 fn blocks(&mut self, blocks: &[Block], parent: &str) {
210 for block in blocks {
211 self.block(block, parent);
212 }
213 }
214
215 fn block(&mut self, block: &Block, parent: &str) {
218 match block {
219 Block::Heading {
220 level,
221 title,
222 children,
223 ..
224 } => self.heading(*level, title, children),
225 Block::Paragraph { inlines } => self.paragraph(inlines, parent),
226 Block::SrcBlock { content, .. } => {
227 self.monospace_block(content, parent, ElementKind::SrcBlock);
228 }
229 Block::ExampleBlock { content } => {
230 self.monospace_block(content, parent, ElementKind::ExampleBlock);
231 }
232 Block::QuoteBlock { children } => self.quote(children, parent),
233 Block::List { list_type, items } => self.list(list_type, items, parent),
234 Block::Table { rows } => self.table(rows, parent),
235 Block::HorizontalRule => self.horizontal_rule(parent),
236 Block::PropertyDrawer { .. }
237 | Block::LogbookDrawer { .. }
238 | Block::Planning { .. }
239 | Block::Comment { .. }
240 | Block::Keyword { .. }
241 | Block::BlankLine => {}
242 }
243 }
244
245 fn heading(&mut self, level: u8, title: &Title, children: &[Block]) {
247 let id = heading_id(title, children);
248 let start = self.cursor;
249 self.record(&id, ElementKind::Heading, start);
250 let (range_start, range_end) = self.insert_line(title.as_str());
251 self.push(named_style_request(
252 range_start,
253 range_end,
254 &heading_style(level),
255 ));
256 self.blocks(children, &id);
257 }
258
259 fn paragraph(&mut self, inlines: &[Inline], parent: &str) {
261 let id = self.next_id(parent, ElementKind::Paragraph);
262 let start = self.cursor;
263 self.record(&id, ElementKind::Paragraph, start);
264 let (text, spans) = flatten(inlines);
265 let (range_start, range_end) = self.insert_line(&text);
266 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
267 self.styles(range_start, &spans);
268 }
269
270 fn monospace_block(&mut self, content: &str, parent: &str, kind: ElementKind) {
272 let id = self.next_id(parent, kind);
273 let start = self.cursor;
274 self.record(&id, kind, start);
275 let (range_start, range_end) = self.insert_line(content);
276 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
277 self.push(text_style_request(
278 range_start,
279 range_end,
280 &Style::Monospace,
281 ));
282 }
283
284 fn quote(&mut self, children: &[Block], parent: &str) {
286 let id = self.next_id(parent, ElementKind::Quote);
287 let start = self.cursor;
288 self.record(&id, ElementKind::Quote, start);
289 for child in children {
290 if let Block::Paragraph { inlines } = child {
291 let (text, spans) = flatten(inlines);
292 let (range_start, range_end) = self.insert_line(&text);
293 self.push(indented_style_request(range_start, range_end));
294 self.styles(range_start, &spans);
295 } else {
296 self.block(child, &id);
297 }
298 }
299 }
300
301 fn list(&mut self, list_type: &ListType, items: &[ListItem], parent: &str) {
304 let id = self.next_id(parent, ElementKind::List);
305 let start = self.cursor;
306 self.record(&id, ElementKind::List, start);
307
308 let has_checkbox = items
309 .iter()
310 .any(|item| !matches!(item.checkbox, Checkbox::NoCheckbox));
311
312 let mut joined = String::new();
313 let mut spans: Vec<Span> = Vec::new();
314 for item in items {
315 let base = len_u32(&joined);
316 let prefix = checkbox_marker(&item.checkbox);
317 joined.push_str(prefix);
318 let item_base = base + len_u32(prefix);
319 let (text, item_spans) = flatten_blocks(&item.content);
320 for span in item_spans {
321 spans.push(span.shifted(item_base));
322 }
323 joined.push_str(&text);
324 joined.push('\n');
325 }
326
327 let range_start = self.cursor;
328 let range_end = self.emit_text(joined);
329 if !has_checkbox {
330 self.push(bullets_request(range_start, range_end, list_type));
331 }
332 self.styles(range_start, &spans);
333 }
334
335 fn table(&mut self, rows: &[Vec<TableCell>], parent: &str) {
339 let id = self.next_id(parent, ElementKind::Table);
340 let start = self.cursor;
341 self.record(&id, ElementKind::Table, start);
342
343 let row_count = len_u32_of(rows.len());
344 let col_count = len_u32_of(rows.iter().map(Vec::len).max().unwrap_or(0));
345 if row_count == 0 || col_count == 0 {
346 return;
347 }
348
349 self.push(insert_table_request(start, row_count, col_count));
350
351 let mut cells: Vec<(u32, String)> = Vec::new();
352 let mut text_units = 0u32;
353 for (row_index, row) in rows.iter().enumerate() {
354 for (col_index, cell) in row.iter().enumerate() {
355 let text = flatten(&cell.inlines).0;
356 if text.is_empty() {
357 continue;
358 }
359 let index = cell_content_index(
360 start,
361 len_u32_of(row_index),
362 len_u32_of(col_index),
363 col_count,
364 );
365 text_units = text_units.saturating_add(len_u32(&text));
366 cells.push((index, text));
367 }
368 }
369 cells.sort_by_key(|cell| core::cmp::Reverse(cell.0));
370 for (index, text) in cells {
371 self.push(insert_text_request(index, text));
372 }
373
374 self.cursor =
375 index_after_empty_table(start, row_count, col_count).saturating_add(text_units);
376 }
377
378 fn horizontal_rule(&mut self, parent: &str) {
380 let id = self.next_id(parent, ElementKind::HorizontalRule);
381 let start = self.cursor;
382 self.record(&id, ElementKind::HorizontalRule, start);
383 let (range_start, range_end) = self.insert_line(RULE_GLYPHS);
384 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
385 }
386
387 fn insert_line(&mut self, text: &str) -> (u32, u32) {
390 let start = self.cursor;
391 let mut line = String::with_capacity(text.len() + 1);
392 line.push_str(text);
393 line.push('\n');
394 let end = self.emit_text(line);
395 (start, end)
396 }
397
398 fn emit_text(&mut self, text: String) -> u32 {
401 let start = self.cursor;
402 let end = start.saturating_add(len_u32(&text));
403 self.push(insert_text_request(start, text));
404 self.cursor = end;
405 end
406 }
407
408 fn styles(&mut self, base: u32, spans: &[Span]) {
411 for span in spans {
412 let start = base.saturating_add(span.start);
413 let end = base.saturating_add(span.end);
414 if end <= start {
415 continue;
416 }
417 self.push(text_style_request(start, end, &span.style));
418 }
419 }
420
421 fn record(&mut self, id: &str, kind: ElementKind, index: u32) {
422 self.positions
423 .insert(id.to_owned(), Position { index, kind });
424 }
425
426 fn next_id(&mut self, parent: &str, kind: ElementKind) -> String {
428 let key = format!("{parent}/{}", kind.slug());
429 let count = self.counters.entry(key).or_insert(0);
430 *count = count.saturating_add(1);
431 format!("{parent}/{}-{count}", kind.slug())
432 }
433
434 fn push(&mut self, request: Request) {
435 self.requests.push(request);
436 }
437}
438
439struct Span {
444 style: Style,
445 start: u32,
446 end: u32,
447}
448
449impl Span {
450 fn shifted(self, offset: u32) -> Self {
452 Self {
453 style: self.style,
454 start: self.start.saturating_add(offset),
455 end: self.end.saturating_add(offset),
456 }
457 }
458}
459
460enum Style {
462 Bold,
463 Italic,
464 Strikethrough,
465 Monospace,
466 Link(String),
467}
468
469pub(crate) fn inline_text(inlines: &[Inline]) -> String {
475 flatten(inlines).0
476}
477
478fn flatten(inlines: &[Inline]) -> (String, Vec<Span>) {
480 let mut text = String::new();
481 let mut spans = Vec::new();
482 flatten_into(inlines, &mut text, &mut spans);
483 (text, spans)
484}
485
486fn flatten_blocks(blocks: &[Block]) -> (String, Vec<Span>) {
490 let mut text = String::new();
491 let mut spans = Vec::new();
492 collect_block_inlines(blocks, &mut text, &mut spans);
493 (text, spans)
494}
495
496fn collect_block_inlines(blocks: &[Block], text: &mut String, spans: &mut Vec<Span>) {
497 for block in blocks {
498 match block {
499 Block::Paragraph { inlines } => {
500 if !text.is_empty() {
501 text.push(' ');
502 }
503 flatten_into(inlines, text, spans);
504 }
505 Block::List { items, .. } => {
506 for item in items {
507 collect_block_inlines(&item.content, text, spans);
508 }
509 }
510 _ => {}
511 }
512 }
513}
514
515fn flatten_into(inlines: &[Inline], text: &mut String, spans: &mut Vec<Span>) {
516 for inline in inlines {
517 match inline {
518 Inline::Plain(value) => text.push_str(value),
519 Inline::Bold(children) => wrap(children, Style::Bold, text, spans),
520 Inline::Italic(children) => wrap(children, Style::Italic, text, spans),
521 Inline::Strikethrough(children) => wrap(children, Style::Strikethrough, text, spans),
522 Inline::InlineCode(value) | Inline::Verbatim(value) => {
523 push_span(value, Style::Monospace, text, spans);
524 }
525 Inline::LineBreak => text.push('\n'),
526 Inline::Link {
527 target,
528 description,
529 } => {
530 let visible = description.as_deref().unwrap_or(target);
531 push_span(visible, Style::Link(target.clone()), text, spans);
532 }
533 }
534 }
535}
536
537fn wrap(children: &[Inline], style: Style, text: &mut String, spans: &mut Vec<Span>) {
539 let start = len_u32(text);
540 flatten_into(children, text, spans);
541 let end = len_u32(text);
542 spans.push(Span { style, start, end });
543}
544
545fn push_span(value: &str, style: Style, text: &mut String, spans: &mut Vec<Span>) {
547 let start = len_u32(text);
548 text.push_str(value);
549 let end = len_u32(text);
550 spans.push(Span { style, start, end });
551}
552
553pub(crate) fn heading_id(title: &Title, children: &[Block]) -> String {
561 children
562 .iter()
563 .find_map(|block| match block {
564 Block::PropertyDrawer { entries } => entries.iter().find_map(|(key, value)| {
565 key.eq_ignore_ascii_case("CUSTOM_ID")
566 .then(|| value.trim().to_owned())
567 }),
568 _ => None,
569 })
570 .filter(|id| !id.is_empty())
571 .unwrap_or_else(|| section_id(title.as_str()))
572}
573
574fn heading_style(level: u8) -> String {
576 format!("HEADING_{}", level.clamp(1, 6))
577}
578
579const fn checkbox_marker(checkbox: &Checkbox) -> &'static str {
581 match checkbox {
582 Checkbox::Unchecked => MARK_UNCHECKED,
583 Checkbox::Checked => MARK_CHECKED,
584 Checkbox::NoCheckbox => "",
585 }
586}
587
588const fn cell_content_index(table_loc: u32, row: u32, col: u32, cols: u32) -> u32 {
603 table_loc + 4 + row * (1 + 2 * cols) + col * 2
604}
605
606const fn index_after_empty_table(table_loc: u32, rows: u32, cols: u32) -> u32 {
615 table_loc + 3 + rows * (1 + 2 * cols)
616}
617
618fn location(index: u32) -> Location {
621 Location {
622 index: Some(to_i32(index)),
623 segment_id: None,
624 tab_id: None,
625 }
626}
627
628fn range(start: u32, end: u32) -> Range {
629 Range {
630 start_index: Some(to_i32(start)),
631 end_index: Some(to_i32(end)),
632 segment_id: None,
633 tab_id: None,
634 }
635}
636
637fn insert_text_request(index: u32, text: String) -> Request {
638 Request {
639 insert_text: Some(InsertTextRequest {
640 location: Some(location(index)),
641 text: Some(text),
642 end_of_segment_location: None,
643 }),
644 ..Request::default()
645 }
646}
647
648fn insert_table_request(location_index: u32, rows: u32, cols: u32) -> Request {
649 Request {
650 insert_table: Some(InsertTableRequest {
651 location: Some(location(location_index)),
652 rows: Some(to_i32(rows)),
653 columns: Some(to_i32(cols)),
654 end_of_segment_location: None,
655 }),
656 ..Request::default()
657 }
658}
659
660fn named_style_request(start: u32, end: u32, named_style: &str) -> Request {
661 Request {
662 update_paragraph_style: Some(UpdateParagraphStyleRequest {
663 range: Some(range(start, end)),
664 paragraph_style: Some(ParagraphStyle {
665 named_style_type: Some(named_style.to_owned()),
666 ..ParagraphStyle::default()
667 }),
668 fields: Some(FieldMask::new(&["namedStyleType"])),
669 }),
670 ..Request::default()
671 }
672}
673
674fn indented_style_request(start: u32, end: u32) -> Request {
675 Request {
676 update_paragraph_style: Some(UpdateParagraphStyleRequest {
677 range: Some(range(start, end)),
678 paragraph_style: Some(ParagraphStyle {
679 named_style_type: Some("NORMAL_TEXT".to_owned()),
680 indent_start: Some(Dimension {
681 magnitude: Some(36.0),
682 unit: Some("PT".to_owned()),
683 }),
684 ..ParagraphStyle::default()
685 }),
686 fields: Some(FieldMask::new(&["namedStyleType", "indentStart"])),
687 }),
688 ..Request::default()
689 }
690}
691
692fn bullets_request(start: u32, end: u32, list_type: &ListType) -> Request {
693 let preset = match list_type {
694 ListType::Ordered(_) => "NUMBERED_DECIMAL_ALPHA_ROMAN",
695 ListType::Unordered => "BULLET_DISC_CIRCLE_SQUARE",
696 };
697 Request {
698 create_paragraph_bullets: Some(CreateParagraphBulletsRequest {
699 range: Some(range(start, end)),
700 bullet_preset: Some(preset.to_owned()),
701 }),
702 ..Request::default()
703 }
704}
705
706fn text_style_request(start: u32, end: u32, style: &Style) -> Request {
707 let (text_style, fields) = match style {
708 Style::Bold => (
709 TextStyle {
710 bold: Some(true),
711 ..TextStyle::default()
712 },
713 "bold",
714 ),
715 Style::Italic => (
716 TextStyle {
717 italic: Some(true),
718 ..TextStyle::default()
719 },
720 "italic",
721 ),
722 Style::Strikethrough => (
723 TextStyle {
724 strikethrough: Some(true),
725 ..TextStyle::default()
726 },
727 "strikethrough",
728 ),
729 Style::Monospace => (
730 TextStyle {
731 weighted_font_family: Some(WeightedFontFamily {
732 font_family: Some(MONOSPACE_FONT.to_owned()),
733 weight: Some(400),
734 }),
735 ..TextStyle::default()
736 },
737 "weightedFontFamily",
738 ),
739 Style::Link(url) => (
740 TextStyle {
741 link: Some(Link {
742 url: Some(url.clone()),
743 ..Link::default()
744 }),
745 ..TextStyle::default()
746 },
747 "link",
748 ),
749 };
750 Request {
751 update_text_style: Some(UpdateTextStyleRequest {
752 range: Some(range(start, end)),
753 text_style: Some(text_style),
754 fields: Some(FieldMask::new(&[fields])),
755 }),
756 ..Request::default()
757 }
758}
759
760fn len_u32(text: &str) -> u32 {
765 u32::try_from(len_utf16(text)).unwrap_or(u32::MAX)
766}
767
768fn len_u32_of(value: usize) -> u32 {
770 u32::try_from(value).unwrap_or(u32::MAX)
771}
772
773fn to_i32(value: u32) -> i32 {
775 i32::try_from(value).unwrap_or(i32::MAX)
776}
777
778#[cfg(test)]
779mod tests {
780 use super::{ElementKind, Style, project, text_style_request};
781 use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
782
783 fn heading(level: u8, title: &str, children: Vec<Block>) -> Block {
784 Block::Heading {
785 level,
786 title: Title(title.to_owned()),
787 tags: vec![],
788 children,
789 }
790 }
791
792 fn custom_id_drawer(id: &str) -> Block {
793 Block::PropertyDrawer {
794 entries: vec![("CUSTOM_ID".to_owned(), format!(" {id}"))],
795 }
796 }
797
798 fn paragraph(inlines: Vec<Inline>) -> Block {
799 Block::Paragraph { inlines }
800 }
801
802 fn plain(text: &str) -> Inline {
803 Inline::Plain(text.to_owned())
804 }
805
806 fn doc(blocks: Vec<Block>) -> Document {
807 Document { blocks }
808 }
809
810 #[test]
811 fn heading_emits_insert_then_named_style() {
812 let projection = project(&doc(vec![heading(2, "Hi", vec![])]));
813 assert_eq!(projection.requests.len(), 2);
814
815 let insert = projection.requests[0].insert_text.as_ref().expect("insert");
816 assert_eq!(insert.text.as_deref(), Some("Hi\n"));
817 assert_eq!(insert.location.as_ref().and_then(|l| l.index), Some(1));
818
819 let style = projection.requests[1]
820 .update_paragraph_style
821 .as_ref()
822 .expect("style");
823 assert_eq!(
824 style
825 .paragraph_style
826 .as_ref()
827 .and_then(|p| p.named_style_type.as_deref()),
828 Some("HEADING_2")
829 );
830 let range = style.range.as_ref().expect("range");
831 assert_eq!((range.start_index, range.end_index), (Some(1), Some(4)));
832 }
833
834 #[test]
835 fn heading_level_clamped_to_six() {
836 let projection = project(&doc(vec![heading(9, "Deep", vec![])]));
837 let style = projection.requests[1]
838 .update_paragraph_style
839 .as_ref()
840 .unwrap();
841 assert_eq!(
842 style
843 .paragraph_style
844 .as_ref()
845 .and_then(|p| p.named_style_type.as_deref()),
846 Some("HEADING_6")
847 );
848 }
849
850 #[test]
851 fn paragraph_nested_bold_italic_and_link() {
852 let para = paragraph(vec![
854 plain("a "),
855 Inline::Bold(vec![Inline::Italic(vec![plain("bc")])]),
856 plain(" "),
857 Inline::Link {
858 target: "https://x".to_owned(),
859 description: Some("d".to_owned()),
860 },
861 ]);
862 let projection = project(&doc(vec![para]));
863
864 let insert = projection.requests[0].insert_text.as_ref().unwrap();
865 assert_eq!(insert.text.as_deref(), Some("a bc d\n"));
866 assert_eq!(
867 projection.requests[1]
868 .update_paragraph_style
869 .as_ref()
870 .and_then(|s| s.paragraph_style.as_ref())
871 .and_then(|p| p.named_style_type.as_deref()),
872 Some("NORMAL_TEXT")
873 );
874
875 let italic = projection.requests[2].update_text_style.as_ref().unwrap();
878 assert_eq!(
879 italic.text_style.as_ref().and_then(|t| t.italic),
880 Some(true)
881 );
882 let italic_range = italic.range.as_ref().unwrap();
883 assert_eq!(
884 (italic_range.start_index, italic_range.end_index),
885 (Some(3), Some(5))
886 );
887
888 let bold = projection.requests[3].update_text_style.as_ref().unwrap();
889 assert_eq!(bold.text_style.as_ref().and_then(|t| t.bold), Some(true));
890 let bold_range = bold.range.as_ref().unwrap();
891 assert_eq!(
892 (bold_range.start_index, bold_range.end_index),
893 (Some(3), Some(5))
894 );
895
896 let link = projection.requests[4].update_text_style.as_ref().unwrap();
897 assert_eq!(
898 link.text_style
899 .as_ref()
900 .and_then(|t| t.link.as_ref())
901 .and_then(|l| l.url.as_deref()),
902 Some("https://x")
903 );
904 let link_range = link.range.as_ref().unwrap();
905 assert_eq!(
906 (link_range.start_index, link_range.end_index),
907 (Some(6), Some(7))
908 );
909 }
910
911 #[test]
912 fn inline_code_is_monospace() {
913 let projection = project(&doc(vec![paragraph(vec![Inline::InlineCode(
914 "x".to_owned(),
915 )])]));
916 let style = projection.requests[2].update_text_style.as_ref().unwrap();
917 assert_eq!(
918 style
919 .text_style
920 .as_ref()
921 .and_then(|t| t.weighted_font_family.as_ref())
922 .and_then(|f| f.font_family.as_deref()),
923 Some("Courier New")
924 );
925 }
926
927 #[test]
928 fn unordered_list_inserts_joined_text_and_bullets() {
929 let list = Block::List {
930 list_type: ListType::Unordered,
931 items: vec![
932 ListItem {
933 content: vec![paragraph(vec![plain("A")])],
934 checkbox: Checkbox::NoCheckbox,
935 },
936 ListItem {
937 content: vec![paragraph(vec![plain("B")])],
938 checkbox: Checkbox::NoCheckbox,
939 },
940 ],
941 };
942 let projection = project(&doc(vec![list]));
943 assert_eq!(
944 projection.requests[0]
945 .insert_text
946 .as_ref()
947 .unwrap()
948 .text
949 .as_deref(),
950 Some("A\nB\n")
951 );
952 let bullets = projection.requests[1]
953 .create_paragraph_bullets
954 .as_ref()
955 .unwrap();
956 assert_eq!(
957 bullets.bullet_preset.as_deref(),
958 Some("BULLET_DISC_CIRCLE_SQUARE")
959 );
960 }
961
962 #[test]
963 fn ordered_list_uses_numbered_preset() {
964 let list = Block::List {
965 list_type: ListType::Ordered(1),
966 items: vec![ListItem {
967 content: vec![paragraph(vec![plain("only")])],
968 checkbox: Checkbox::NoCheckbox,
969 }],
970 };
971 let projection = project(&doc(vec![list]));
972 assert_eq!(
973 projection.requests[1]
974 .create_paragraph_bullets
975 .as_ref()
976 .unwrap()
977 .bullet_preset
978 .as_deref(),
979 Some("NUMBERED_DECIMAL_ALPHA_ROMAN")
980 );
981 }
982
983 #[test]
984 fn checkbox_list_marks_state_and_omits_bullets() {
985 let list = Block::List {
986 list_type: ListType::Unordered,
987 items: vec![
988 ListItem {
989 content: vec![paragraph(vec![plain("done")])],
990 checkbox: Checkbox::Checked,
991 },
992 ListItem {
993 content: vec![paragraph(vec![plain("todo")])],
994 checkbox: Checkbox::Unchecked,
995 },
996 ],
997 };
998 let projection = project(&doc(vec![list]));
999 assert_eq!(
1000 projection.requests[0]
1001 .insert_text
1002 .as_ref()
1003 .unwrap()
1004 .text
1005 .as_deref(),
1006 Some("\u{2611} done\n\u{2610} todo\n")
1007 );
1008 assert!(
1010 projection
1011 .requests
1012 .iter()
1013 .all(|r| r.create_paragraph_bullets.is_none())
1014 );
1015 }
1016
1017 #[test]
1018 fn table_inserts_skeleton_then_cells_descending() {
1019 let cell = |text: &str| TableCell {
1020 inlines: vec![plain(text)],
1021 };
1022 let table = Block::Table {
1023 rows: vec![vec![cell("A1"), cell("B1")], vec![cell("A2"), cell("B2")]],
1024 };
1025 let projection = project(&doc(vec![
1029 table,
1030 Block::Paragraph {
1031 inlines: vec![plain("after")],
1032 },
1033 ]));
1034
1035 let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
1036 assert_eq!(
1037 (insert_table.rows, insert_table.columns),
1038 (Some(2), Some(2))
1039 );
1040 assert_eq!(
1041 insert_table.location.as_ref().and_then(|l| l.index),
1042 Some(1)
1043 );
1044
1045 let cells = &projection.requests[1..5];
1047 let cell_indices: Vec<(Option<i32>, Option<&str>)> = cells
1048 .iter()
1049 .map(|r| {
1050 let insert = r.insert_text.as_ref().unwrap();
1051 (
1052 insert.location.as_ref().and_then(|l| l.index),
1053 insert.text.as_deref(),
1054 )
1055 })
1056 .collect();
1057 assert_eq!(
1058 cell_indices,
1059 vec![
1060 (Some(12), Some("B2")),
1061 (Some(10), Some("A2")),
1062 (Some(7), Some("B1")),
1063 (Some(5), Some("A1")),
1064 ]
1065 );
1066
1067 let after = projection.requests[5].insert_text.as_ref().unwrap();
1071 assert_eq!(after.text.as_deref(), Some("after\n"));
1072 assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
1073
1074 let position = projection.positions.get("doc/table-1").unwrap();
1076 assert_eq!((position.index, position.kind), (1, ElementKind::Table));
1077 }
1078
1079 #[test]
1080 fn src_block_is_monospace() {
1081 let projection = project(&doc(vec![Block::SrcBlock {
1082 language: "rust".to_owned(),
1083 content: "fn main() {}".to_owned(),
1084 }]));
1085 assert_eq!(
1086 projection.requests[0]
1087 .insert_text
1088 .as_ref()
1089 .unwrap()
1090 .text
1091 .as_deref(),
1092 Some("fn main() {}\n")
1093 );
1094 assert_eq!(
1095 projection.requests[2]
1096 .update_text_style
1097 .as_ref()
1098 .and_then(|s| s.text_style.as_ref())
1099 .and_then(|t| t.weighted_font_family.as_ref())
1100 .and_then(|f| f.font_family.as_deref()),
1101 Some("Courier New")
1102 );
1103 }
1104
1105 #[test]
1106 fn horizontal_rule_degrades_to_glyphs() {
1107 let projection = project(&doc(vec![Block::HorizontalRule]));
1108 let text = projection.requests[0]
1109 .insert_text
1110 .as_ref()
1111 .unwrap()
1112 .text
1113 .as_deref();
1114 assert!(text.is_some_and(|t| t.starts_with('\u{2500}')));
1115 assert!(projection.positions.contains_key("doc/hr-1"));
1116 }
1117
1118 #[test]
1119 fn quote_paragraphs_are_indented() {
1120 let quote = Block::QuoteBlock {
1121 children: vec![paragraph(vec![plain("quoted")])],
1122 };
1123 let projection = project(&doc(vec![quote]));
1124 let style = projection.requests[1]
1125 .update_paragraph_style
1126 .as_ref()
1127 .unwrap();
1128 assert!(
1129 style
1130 .paragraph_style
1131 .as_ref()
1132 .and_then(|p| p.indent_start.as_ref())
1133 .is_some()
1134 );
1135 assert!(projection.positions.contains_key("doc/quote-1"));
1136 }
1137
1138 #[test]
1139 fn position_map_uses_custom_id_and_hierarchical_ids() {
1140 let body = heading(
1141 1,
1142 "Intro",
1143 vec![
1144 custom_id_drawer("sec-intro"),
1145 paragraph(vec![plain("first")]),
1146 paragraph(vec![plain("second")]),
1147 ],
1148 );
1149 let projection = project(&doc(vec![body]));
1150
1151 let head = projection.positions.get("sec-intro").expect("heading id");
1152 assert_eq!((head.index, head.kind), (1, ElementKind::Heading));
1153
1154 assert!(projection.positions.contains_key("sec-intro/paragraph-1"));
1156 assert!(projection.positions.contains_key("sec-intro/paragraph-2"));
1157
1158 let p1 = projection.positions["sec-intro/paragraph-1"].index;
1160 let p2 = projection.positions["sec-intro/paragraph-2"].index;
1161 assert!(head.index < p1 && p1 < p2);
1162 }
1163
1164 #[test]
1165 fn metadata_blocks_produce_no_requests() {
1166 let blocks = vec![
1167 Block::BlankLine,
1168 Block::Comment {
1169 text: "hidden".to_owned(),
1170 },
1171 Block::Keyword {
1172 name: "TITLE".to_owned(),
1173 value: " Doc".to_owned(),
1174 },
1175 ];
1176 let projection = project(&doc(blocks));
1177 assert!(projection.requests.is_empty());
1178 assert!(projection.positions.is_empty());
1179 }
1180
1181 #[test]
1182 fn empty_table_emits_no_requests() {
1183 let projection = project(&doc(vec![Block::Table { rows: vec![] }]));
1184 assert!(projection.requests.is_empty());
1185 }
1186
1187 #[test]
1188 fn text_style_request_sets_strikethrough() {
1189 let request = text_style_request(1, 3, &Style::Strikethrough);
1190 assert_eq!(
1191 request
1192 .update_text_style
1193 .and_then(|s| s.text_style)
1194 .and_then(|t| t.strikethrough),
1195 Some(true)
1196 );
1197 }
1198}