1use std::collections::BTreeMap;
43use std::fmt::Write as _;
44
45use google_docs1::api::{
46 CreateParagraphBulletsRequest, Dimension, InsertTableRequest, InsertTextRequest, Link,
47 Location, ParagraphStyle, Range, Request, TextStyle, UpdateParagraphStyleRequest,
48 UpdateTextStyleRequest, WeightedFontFamily,
49};
50use google_docs1::common::FieldMask;
51use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
52use sha2::{Digest, Sha256};
53
54use crate::custom_id::section_id;
55use crate::google::utf16::len_utf16;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ElementKind {
61 Heading,
63 Paragraph,
65 List,
67 Table,
69 SrcBlock,
71 ExampleBlock,
73 Quote,
75 HorizontalRule,
77}
78
79impl ElementKind {
80 #[must_use]
83 pub const fn slug(self) -> &'static str {
84 match self {
85 Self::Heading => "heading",
86 Self::Paragraph => "paragraph",
87 Self::List => "list",
88 Self::Table => "table",
89 Self::SrcBlock => "src",
90 Self::ExampleBlock => "example",
91 Self::Quote => "quote",
92 Self::HorizontalRule => "hr",
93 }
94 }
95
96 #[must_use]
98 pub fn from_slug(slug: &str) -> Option<Self> {
99 match slug {
100 "heading" => Some(Self::Heading),
101 "paragraph" => Some(Self::Paragraph),
102 "list" => Some(Self::List),
103 "table" => Some(Self::Table),
104 "src" => Some(Self::SrcBlock),
105 "example" => Some(Self::ExampleBlock),
106 "quote" => Some(Self::Quote),
107 "hr" => Some(Self::HorizontalRule),
108 _ => None,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Position {
116 pub index: u32,
118 pub kind: ElementKind,
120}
121
122pub type PositionMap = BTreeMap<String, Position>;
127
128#[derive(Debug, Clone)]
133pub struct Projection {
134 pub requests: Vec<Request>,
136 pub positions: PositionMap,
138}
139
140impl Projection {
141 #[must_use]
149 pub fn fingerprint(&self) -> String {
150 let serialized = serde_json::to_vec(&self.requests).unwrap_or_default();
154 let mut hasher = Sha256::new();
155 hasher.update(&serialized);
156 hasher
157 .finalize()
158 .iter()
159 .fold(String::new(), |mut acc, byte| {
160 let _ = write!(acc, "{byte:02x}");
162 acc
163 })
164 }
165}
166
167pub(crate) const DOCUMENT_PARENT: &str = "doc";
173
174const MONOSPACE_FONT: &str = "Courier New";
176
177const RULE_GLYPHS: &str = "────────────────────";
179
180const MARK_UNCHECKED: &str = "\u{2610} ";
182const MARK_CHECKED: &str = "\u{2611} ";
183
184#[must_use]
186pub fn project(document: &Document) -> Projection {
187 let mut builder = Builder::new();
188 builder.blocks(&document.blocks, DOCUMENT_PARENT);
189 Projection {
190 requests: builder.requests,
191 positions: builder.positions,
192 }
193}
194
195struct Builder {
198 requests: Vec<Request>,
199 positions: PositionMap,
200 cursor: u32,
201 counters: BTreeMap<String, u32>,
202}
203
204impl Builder {
205 fn new() -> Self {
206 Self {
207 requests: Vec::new(),
208 positions: PositionMap::new(),
209 cursor: 1,
210 counters: BTreeMap::new(),
211 }
212 }
213
214 fn blocks(&mut self, blocks: &[Block], parent: &str) {
216 for block in blocks {
217 self.block(block, parent);
218 }
219 }
220
221 fn block(&mut self, block: &Block, parent: &str) {
224 match block {
225 Block::Heading {
226 level,
227 title,
228 children,
229 ..
230 } => self.heading(*level, title, children),
231 Block::Paragraph { inlines } => self.paragraph(inlines, parent),
232 Block::SrcBlock { content, .. } => {
233 self.monospace_block(content, parent, ElementKind::SrcBlock);
234 }
235 Block::ExampleBlock { content } => {
236 self.monospace_block(content, parent, ElementKind::ExampleBlock);
237 }
238 Block::QuoteBlock { children } => self.quote(children, parent),
239 Block::List { list_type, items } => self.list(list_type, items, parent),
240 Block::Table { rows } => self.table(rows, parent),
241 Block::HorizontalRule => self.horizontal_rule(parent),
242 Block::PropertyDrawer { .. }
243 | Block::LogbookDrawer { .. }
244 | Block::Planning { .. }
245 | Block::Comment { .. }
246 | Block::Keyword { .. }
247 | Block::BlankLine => {}
248 }
249 }
250
251 fn heading(&mut self, level: u8, title: &Title, children: &[Block]) {
253 let id = heading_id(title, children);
254 let start = self.cursor;
255 self.record(&id, ElementKind::Heading, start);
256 let (range_start, range_end) = self.insert_line(title.as_str());
257 self.push(named_style_request(
258 range_start,
259 range_end,
260 &heading_style(level),
261 ));
262 self.blocks(children, &id);
263 }
264
265 fn paragraph(&mut self, inlines: &[Inline], parent: &str) {
267 let id = self.next_id(parent, ElementKind::Paragraph);
268 let start = self.cursor;
269 self.record(&id, ElementKind::Paragraph, start);
270 let (text, spans) = flatten(inlines);
271 let (range_start, range_end) = self.insert_line(&text);
272 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
273 self.styles(range_start, &spans);
274 }
275
276 fn monospace_block(&mut self, content: &str, parent: &str, kind: ElementKind) {
278 let id = self.next_id(parent, kind);
279 let start = self.cursor;
280 self.record(&id, kind, start);
281 let (range_start, range_end) = self.insert_line(content);
282 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
283 self.push(text_style_request(
284 range_start,
285 range_end,
286 &Style::Monospace,
287 ));
288 }
289
290 fn quote(&mut self, children: &[Block], parent: &str) {
292 let id = self.next_id(parent, ElementKind::Quote);
293 let start = self.cursor;
294 self.record(&id, ElementKind::Quote, start);
295 for child in children {
296 if let Block::Paragraph { inlines } = child {
297 let (text, spans) = flatten(inlines);
298 let (range_start, range_end) = self.insert_line(&text);
299 self.push(indented_style_request(range_start, range_end));
300 self.styles(range_start, &spans);
301 } else {
302 self.block(child, &id);
303 }
304 }
305 }
306
307 fn list(&mut self, list_type: &ListType, items: &[ListItem], parent: &str) {
310 let id = self.next_id(parent, ElementKind::List);
311 let start = self.cursor;
312 self.record(&id, ElementKind::List, start);
313
314 let has_checkbox = items
315 .iter()
316 .any(|item| !matches!(item.checkbox, Checkbox::NoCheckbox));
317
318 let mut joined = String::new();
319 let mut spans: Vec<Span> = Vec::new();
320 for item in items {
321 let base = len_u32(&joined);
322 let prefix = checkbox_marker(&item.checkbox);
323 joined.push_str(prefix);
324 let item_base = base + len_u32(prefix);
325 let (text, item_spans) = flatten_blocks(&item.content);
326 for span in item_spans {
327 spans.push(span.shifted(item_base));
328 }
329 joined.push_str(&text);
330 joined.push('\n');
331 }
332
333 let range_start = self.cursor;
334 let range_end = self.emit_text(joined);
335 if !has_checkbox {
336 self.push(bullets_request(range_start, range_end, list_type));
337 }
338 self.styles(range_start, &spans);
339 }
340
341 fn table(&mut self, rows: &[Vec<TableCell>], parent: &str) {
345 let id = self.next_id(parent, ElementKind::Table);
346 let start = self.cursor;
347 self.record(&id, ElementKind::Table, start);
348
349 let data_rows: Vec<&Vec<TableCell>> = rows.iter().filter(|row| !is_rule_row(row)).collect();
353
354 let row_count = len_u32_of(data_rows.len());
355 let col_count = len_u32_of(data_rows.iter().map(|row| row.len()).max().unwrap_or(0));
356 if row_count == 0 || col_count == 0 {
357 return;
358 }
359
360 self.push(insert_table_request(start, row_count, col_count));
361
362 let mut cells: Vec<(u32, String)> = Vec::new();
363 let mut text_units = 0u32;
364 for (row_index, row) in data_rows.iter().enumerate() {
365 for (col_index, cell) in row.iter().enumerate() {
366 let text = flatten(&cell.inlines).0;
367 if text.is_empty() {
368 continue;
369 }
370 let index = cell_content_index(
371 start,
372 len_u32_of(row_index),
373 len_u32_of(col_index),
374 col_count,
375 );
376 text_units = text_units.saturating_add(len_u32(&text));
377 cells.push((index, text));
378 }
379 }
380 cells.sort_by_key(|cell| core::cmp::Reverse(cell.0));
381 for (index, text) in cells {
382 self.push(insert_text_request(index, text));
383 }
384
385 self.cursor =
386 index_after_empty_table(start, row_count, col_count).saturating_add(text_units);
387 }
388
389 fn horizontal_rule(&mut self, parent: &str) {
391 let id = self.next_id(parent, ElementKind::HorizontalRule);
392 let start = self.cursor;
393 self.record(&id, ElementKind::HorizontalRule, start);
394 let (range_start, range_end) = self.insert_line(RULE_GLYPHS);
395 self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
396 }
397
398 fn insert_line(&mut self, text: &str) -> (u32, u32) {
401 let start = self.cursor;
402 let mut line = String::with_capacity(text.len() + 1);
403 line.push_str(text);
404 line.push('\n');
405 let end = self.emit_text(line);
406 (start, end)
407 }
408
409 fn emit_text(&mut self, text: String) -> u32 {
412 let start = self.cursor;
413 let end = start.saturating_add(len_u32(&text));
414 self.push(insert_text_request(start, text));
415 self.cursor = end;
416 end
417 }
418
419 fn styles(&mut self, base: u32, spans: &[Span]) {
422 for span in spans {
423 let start = base.saturating_add(span.start);
424 let end = base.saturating_add(span.end);
425 if end <= start {
426 continue;
427 }
428 self.push(text_style_request(start, end, &span.style));
429 }
430 }
431
432 fn record(&mut self, id: &str, kind: ElementKind, index: u32) {
433 self.positions
434 .insert(id.to_owned(), Position { index, kind });
435 }
436
437 fn next_id(&mut self, parent: &str, kind: ElementKind) -> String {
439 let key = format!("{parent}/{}", kind.slug());
440 let count = self.counters.entry(key).or_insert(0);
441 *count = count.saturating_add(1);
442 format!("{parent}/{}-{count}", kind.slug())
443 }
444
445 fn push(&mut self, request: Request) {
446 self.requests.push(request);
447 }
448}
449
450struct Span {
455 style: Style,
456 start: u32,
457 end: u32,
458}
459
460impl Span {
461 fn shifted(self, offset: u32) -> Self {
463 Self {
464 style: self.style,
465 start: self.start.saturating_add(offset),
466 end: self.end.saturating_add(offset),
467 }
468 }
469}
470
471enum Style {
473 Bold,
474 Italic,
475 Strikethrough,
476 Monospace,
477 Link(String),
478}
479
480pub(crate) fn inline_text(inlines: &[Inline]) -> String {
486 flatten(inlines).0
487}
488
489fn flatten(inlines: &[Inline]) -> (String, Vec<Span>) {
491 let mut text = String::new();
492 let mut spans = Vec::new();
493 flatten_into(inlines, &mut text, &mut spans);
494 (text, spans)
495}
496
497fn flatten_blocks(blocks: &[Block]) -> (String, Vec<Span>) {
501 let mut text = String::new();
502 let mut spans = Vec::new();
503 collect_block_inlines(blocks, &mut text, &mut spans);
504 (text, spans)
505}
506
507fn collect_block_inlines(blocks: &[Block], text: &mut String, spans: &mut Vec<Span>) {
508 for block in blocks {
509 match block {
510 Block::Paragraph { inlines } => {
511 if !text.is_empty() {
512 text.push(' ');
513 }
514 flatten_into(inlines, text, spans);
515 }
516 Block::List { items, .. } => {
517 for item in items {
518 collect_block_inlines(&item.content, text, spans);
519 }
520 }
521 _ => {}
522 }
523 }
524}
525
526fn flatten_into(inlines: &[Inline], text: &mut String, spans: &mut Vec<Span>) {
527 for inline in inlines {
528 match inline {
529 Inline::Plain(value) => text.push_str(value),
530 Inline::Bold(children) => wrap(children, Style::Bold, text, spans),
531 Inline::Italic(children) => wrap(children, Style::Italic, text, spans),
532 Inline::Strikethrough(children) => wrap(children, Style::Strikethrough, text, spans),
533 Inline::InlineCode(value) | Inline::Verbatim(value) => {
534 push_span(value, Style::Monospace, text, spans);
535 }
536 Inline::LineBreak => {
537 if !text.ends_with(char::is_whitespace) {
543 text.push(' ');
544 }
545 }
546 Inline::Link {
547 target,
548 description,
549 } => {
550 let visible = description.as_deref().unwrap_or(target);
551 push_span(visible, Style::Link(target.clone()), text, spans);
552 }
553 }
554 }
555}
556
557fn wrap(children: &[Inline], style: Style, text: &mut String, spans: &mut Vec<Span>) {
559 let start = len_u32(text);
560 flatten_into(children, text, spans);
561 let end = len_u32(text);
562 spans.push(Span { style, start, end });
563}
564
565fn push_span(value: &str, style: Style, text: &mut String, spans: &mut Vec<Span>) {
567 let start = len_u32(text);
568 text.push_str(value);
569 let end = len_u32(text);
570 spans.push(Span { style, start, end });
571}
572
573pub(crate) fn heading_id(title: &Title, children: &[Block]) -> String {
581 children
582 .iter()
583 .find_map(|block| match block {
584 Block::PropertyDrawer { entries } => entries.iter().find_map(|(key, value)| {
585 key.eq_ignore_ascii_case("CUSTOM_ID")
586 .then(|| value.trim().to_owned())
587 }),
588 _ => None,
589 })
590 .filter(|id| !id.is_empty())
591 .unwrap_or_else(|| section_id(title.as_str()))
592}
593
594fn heading_style(level: u8) -> String {
596 format!("HEADING_{}", level.clamp(1, 6))
597}
598
599const fn checkbox_marker(checkbox: &Checkbox) -> &'static str {
601 match checkbox {
602 Checkbox::Unchecked => MARK_UNCHECKED,
603 Checkbox::Checked => MARK_CHECKED,
604 Checkbox::NoCheckbox => "",
605 }
606}
607
608fn is_rule_row(row: &[TableCell]) -> bool {
615 !row.is_empty()
616 && row.iter().all(|cell| {
617 let text = inline_text(&cell.inlines);
618 let trimmed = text.trim();
619 !trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '-' | '+' | '|'))
620 })
621}
622
623const fn cell_content_index(table_loc: u32, row: u32, col: u32, cols: u32) -> u32 {
638 table_loc + 4 + row * (1 + 2 * cols) + col * 2
639}
640
641const fn index_after_empty_table(table_loc: u32, rows: u32, cols: u32) -> u32 {
650 table_loc + 3 + rows * (1 + 2 * cols)
651}
652
653fn location(index: u32) -> Location {
656 Location {
657 index: Some(to_i32(index)),
658 segment_id: None,
659 tab_id: None,
660 }
661}
662
663fn range(start: u32, end: u32) -> Range {
664 Range {
665 start_index: Some(to_i32(start)),
666 end_index: Some(to_i32(end)),
667 segment_id: None,
668 tab_id: None,
669 }
670}
671
672fn insert_text_request(index: u32, text: String) -> Request {
673 Request {
674 insert_text: Some(InsertTextRequest {
675 location: Some(location(index)),
676 text: Some(text),
677 end_of_segment_location: None,
678 }),
679 ..Request::default()
680 }
681}
682
683fn insert_table_request(location_index: u32, rows: u32, cols: u32) -> Request {
684 Request {
685 insert_table: Some(InsertTableRequest {
686 location: Some(location(location_index)),
687 rows: Some(to_i32(rows)),
688 columns: Some(to_i32(cols)),
689 end_of_segment_location: None,
690 }),
691 ..Request::default()
692 }
693}
694
695fn named_style_request(start: u32, end: u32, named_style: &str) -> Request {
696 Request {
697 update_paragraph_style: Some(UpdateParagraphStyleRequest {
698 range: Some(range(start, end)),
699 paragraph_style: Some(ParagraphStyle {
700 named_style_type: Some(named_style.to_owned()),
701 ..ParagraphStyle::default()
702 }),
703 fields: Some(FieldMask::new(&["namedStyleType"])),
704 }),
705 ..Request::default()
706 }
707}
708
709fn indented_style_request(start: u32, end: u32) -> Request {
710 Request {
711 update_paragraph_style: Some(UpdateParagraphStyleRequest {
712 range: Some(range(start, end)),
713 paragraph_style: Some(ParagraphStyle {
714 named_style_type: Some("NORMAL_TEXT".to_owned()),
715 indent_start: Some(Dimension {
716 magnitude: Some(36.0),
717 unit: Some("PT".to_owned()),
718 }),
719 ..ParagraphStyle::default()
720 }),
721 fields: Some(FieldMask::new(&["namedStyleType", "indentStart"])),
722 }),
723 ..Request::default()
724 }
725}
726
727fn bullets_request(start: u32, end: u32, list_type: &ListType) -> Request {
728 let preset = match list_type {
729 ListType::Ordered(_) => "NUMBERED_DECIMAL_ALPHA_ROMAN",
730 ListType::Unordered => "BULLET_DISC_CIRCLE_SQUARE",
731 };
732 Request {
733 create_paragraph_bullets: Some(CreateParagraphBulletsRequest {
734 range: Some(range(start, end)),
735 bullet_preset: Some(preset.to_owned()),
736 }),
737 ..Request::default()
738 }
739}
740
741fn text_style_request(start: u32, end: u32, style: &Style) -> Request {
742 let (text_style, fields) = match style {
743 Style::Bold => (
744 TextStyle {
745 bold: Some(true),
746 ..TextStyle::default()
747 },
748 "bold",
749 ),
750 Style::Italic => (
751 TextStyle {
752 italic: Some(true),
753 ..TextStyle::default()
754 },
755 "italic",
756 ),
757 Style::Strikethrough => (
758 TextStyle {
759 strikethrough: Some(true),
760 ..TextStyle::default()
761 },
762 "strikethrough",
763 ),
764 Style::Monospace => (
765 TextStyle {
766 weighted_font_family: Some(WeightedFontFamily {
767 font_family: Some(MONOSPACE_FONT.to_owned()),
768 weight: Some(400),
769 }),
770 ..TextStyle::default()
771 },
772 "weightedFontFamily",
773 ),
774 Style::Link(url) => (
775 TextStyle {
776 link: Some(Link {
777 url: Some(url.clone()),
778 ..Link::default()
779 }),
780 ..TextStyle::default()
781 },
782 "link",
783 ),
784 };
785 Request {
786 update_text_style: Some(UpdateTextStyleRequest {
787 range: Some(range(start, end)),
788 text_style: Some(text_style),
789 fields: Some(FieldMask::new(&[fields])),
790 }),
791 ..Request::default()
792 }
793}
794
795fn len_u32(text: &str) -> u32 {
800 u32::try_from(len_utf16(text)).unwrap_or(u32::MAX)
801}
802
803fn len_u32_of(value: usize) -> u32 {
805 u32::try_from(value).unwrap_or(u32::MAX)
806}
807
808fn to_i32(value: u32) -> i32 {
810 i32::try_from(value).unwrap_or(i32::MAX)
811}
812
813#[cfg(test)]
814mod tests {
815 use super::{ElementKind, Style, project, text_style_request};
816 use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
817
818 fn heading(level: u8, title: &str, children: Vec<Block>) -> Block {
819 Block::Heading {
820 level,
821 title: Title(title.to_owned()),
822 tags: vec![],
823 children,
824 }
825 }
826
827 fn custom_id_drawer(id: &str) -> Block {
828 Block::PropertyDrawer {
829 entries: vec![("CUSTOM_ID".to_owned(), format!(" {id}"))],
830 }
831 }
832
833 fn paragraph(inlines: Vec<Inline>) -> Block {
834 Block::Paragraph { inlines }
835 }
836
837 fn plain(text: &str) -> Inline {
838 Inline::Plain(text.to_owned())
839 }
840
841 fn doc(blocks: Vec<Block>) -> Document {
842 Document { blocks }
843 }
844
845 #[test]
846 fn heading_emits_insert_then_named_style() {
847 let projection = project(&doc(vec![heading(2, "Hi", vec![])]));
848 assert_eq!(projection.requests.len(), 2);
849
850 let insert = projection.requests[0].insert_text.as_ref().expect("insert");
851 assert_eq!(insert.text.as_deref(), Some("Hi\n"));
852 assert_eq!(insert.location.as_ref().and_then(|l| l.index), Some(1));
853
854 let style = projection.requests[1]
855 .update_paragraph_style
856 .as_ref()
857 .expect("style");
858 assert_eq!(
859 style
860 .paragraph_style
861 .as_ref()
862 .and_then(|p| p.named_style_type.as_deref()),
863 Some("HEADING_2")
864 );
865 let range = style.range.as_ref().expect("range");
866 assert_eq!((range.start_index, range.end_index), (Some(1), Some(4)));
867 }
868
869 #[test]
870 fn heading_level_clamped_to_six() {
871 let projection = project(&doc(vec![heading(9, "Deep", vec![])]));
872 let style = projection.requests[1]
873 .update_paragraph_style
874 .as_ref()
875 .unwrap();
876 assert_eq!(
877 style
878 .paragraph_style
879 .as_ref()
880 .and_then(|p| p.named_style_type.as_deref()),
881 Some("HEADING_6")
882 );
883 }
884
885 #[test]
886 fn paragraph_nested_bold_italic_and_link() {
887 let para = paragraph(vec![
889 plain("a "),
890 Inline::Bold(vec![Inline::Italic(vec![plain("bc")])]),
891 plain(" "),
892 Inline::Link {
893 target: "https://x".to_owned(),
894 description: Some("d".to_owned()),
895 },
896 ]);
897 let projection = project(&doc(vec![para]));
898
899 let insert = projection.requests[0].insert_text.as_ref().unwrap();
900 assert_eq!(insert.text.as_deref(), Some("a bc d\n"));
901 assert_eq!(
902 projection.requests[1]
903 .update_paragraph_style
904 .as_ref()
905 .and_then(|s| s.paragraph_style.as_ref())
906 .and_then(|p| p.named_style_type.as_deref()),
907 Some("NORMAL_TEXT")
908 );
909
910 let italic = projection.requests[2].update_text_style.as_ref().unwrap();
913 assert_eq!(
914 italic.text_style.as_ref().and_then(|t| t.italic),
915 Some(true)
916 );
917 let italic_range = italic.range.as_ref().unwrap();
918 assert_eq!(
919 (italic_range.start_index, italic_range.end_index),
920 (Some(3), Some(5))
921 );
922
923 let bold = projection.requests[3].update_text_style.as_ref().unwrap();
924 assert_eq!(bold.text_style.as_ref().and_then(|t| t.bold), Some(true));
925 let bold_range = bold.range.as_ref().unwrap();
926 assert_eq!(
927 (bold_range.start_index, bold_range.end_index),
928 (Some(3), Some(5))
929 );
930
931 let link = projection.requests[4].update_text_style.as_ref().unwrap();
932 assert_eq!(
933 link.text_style
934 .as_ref()
935 .and_then(|t| t.link.as_ref())
936 .and_then(|l| l.url.as_deref()),
937 Some("https://x")
938 );
939 let link_range = link.range.as_ref().unwrap();
940 assert_eq!(
941 (link_range.start_index, link_range.end_index),
942 (Some(6), Some(7))
943 );
944 }
945
946 #[test]
947 fn inline_code_is_monospace() {
948 let projection = project(&doc(vec![paragraph(vec![Inline::InlineCode(
949 "x".to_owned(),
950 )])]));
951 let style = projection.requests[2].update_text_style.as_ref().unwrap();
952 assert_eq!(
953 style
954 .text_style
955 .as_ref()
956 .and_then(|t| t.weighted_font_family.as_ref())
957 .and_then(|f| f.font_family.as_deref()),
958 Some("Courier New")
959 );
960 }
961
962 #[test]
963 fn unordered_list_inserts_joined_text_and_bullets() {
964 let list = Block::List {
965 list_type: ListType::Unordered,
966 items: vec![
967 ListItem {
968 content: vec![paragraph(vec![plain("A")])],
969 checkbox: Checkbox::NoCheckbox,
970 },
971 ListItem {
972 content: vec![paragraph(vec![plain("B")])],
973 checkbox: Checkbox::NoCheckbox,
974 },
975 ],
976 };
977 let projection = project(&doc(vec![list]));
978 assert_eq!(
979 projection.requests[0]
980 .insert_text
981 .as_ref()
982 .unwrap()
983 .text
984 .as_deref(),
985 Some("A\nB\n")
986 );
987 let bullets = projection.requests[1]
988 .create_paragraph_bullets
989 .as_ref()
990 .unwrap();
991 assert_eq!(
992 bullets.bullet_preset.as_deref(),
993 Some("BULLET_DISC_CIRCLE_SQUARE")
994 );
995 }
996
997 #[test]
998 fn ordered_list_uses_numbered_preset() {
999 let list = Block::List {
1000 list_type: ListType::Ordered(1),
1001 items: vec![ListItem {
1002 content: vec![paragraph(vec![plain("only")])],
1003 checkbox: Checkbox::NoCheckbox,
1004 }],
1005 };
1006 let projection = project(&doc(vec![list]));
1007 assert_eq!(
1008 projection.requests[1]
1009 .create_paragraph_bullets
1010 .as_ref()
1011 .unwrap()
1012 .bullet_preset
1013 .as_deref(),
1014 Some("NUMBERED_DECIMAL_ALPHA_ROMAN")
1015 );
1016 }
1017
1018 #[test]
1019 fn checkbox_list_marks_state_and_omits_bullets() {
1020 let list = Block::List {
1021 list_type: ListType::Unordered,
1022 items: vec![
1023 ListItem {
1024 content: vec![paragraph(vec![plain("done")])],
1025 checkbox: Checkbox::Checked,
1026 },
1027 ListItem {
1028 content: vec![paragraph(vec![plain("todo")])],
1029 checkbox: Checkbox::Unchecked,
1030 },
1031 ],
1032 };
1033 let projection = project(&doc(vec![list]));
1034 assert_eq!(
1035 projection.requests[0]
1036 .insert_text
1037 .as_ref()
1038 .unwrap()
1039 .text
1040 .as_deref(),
1041 Some("\u{2611} done\n\u{2610} todo\n")
1042 );
1043 assert!(
1045 projection
1046 .requests
1047 .iter()
1048 .all(|r| r.create_paragraph_bullets.is_none())
1049 );
1050 }
1051
1052 #[test]
1053 fn table_inserts_skeleton_then_cells_descending() {
1054 let cell = |text: &str| TableCell {
1055 inlines: vec![plain(text)],
1056 };
1057 let table = Block::Table {
1058 rows: vec![vec![cell("A1"), cell("B1")], vec![cell("A2"), cell("B2")]],
1059 };
1060 let projection = project(&doc(vec![
1064 table,
1065 Block::Paragraph {
1066 inlines: vec![plain("after")],
1067 },
1068 ]));
1069
1070 let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
1071 assert_eq!(
1072 (insert_table.rows, insert_table.columns),
1073 (Some(2), Some(2))
1074 );
1075 assert_eq!(
1076 insert_table.location.as_ref().and_then(|l| l.index),
1077 Some(1)
1078 );
1079
1080 let cells = &projection.requests[1..5];
1082 let cell_indices: Vec<(Option<i32>, Option<&str>)> = cells
1083 .iter()
1084 .map(|r| {
1085 let insert = r.insert_text.as_ref().unwrap();
1086 (
1087 insert.location.as_ref().and_then(|l| l.index),
1088 insert.text.as_deref(),
1089 )
1090 })
1091 .collect();
1092 assert_eq!(
1093 cell_indices,
1094 vec![
1095 (Some(12), Some("B2")),
1096 (Some(10), Some("A2")),
1097 (Some(7), Some("B1")),
1098 (Some(5), Some("A1")),
1099 ]
1100 );
1101
1102 let after = projection.requests[5].insert_text.as_ref().unwrap();
1106 assert_eq!(after.text.as_deref(), Some("after\n"));
1107 assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
1108
1109 let position = projection.positions.get("doc/table-1").unwrap();
1111 assert_eq!((position.index, position.kind), (1, ElementKind::Table));
1112 }
1113
1114 #[test]
1115 fn src_block_is_monospace() {
1116 let projection = project(&doc(vec![Block::SrcBlock {
1117 language: "rust".to_owned(),
1118 content: "fn main() {}".to_owned(),
1119 }]));
1120 assert_eq!(
1121 projection.requests[0]
1122 .insert_text
1123 .as_ref()
1124 .unwrap()
1125 .text
1126 .as_deref(),
1127 Some("fn main() {}\n")
1128 );
1129 assert_eq!(
1130 projection.requests[2]
1131 .update_text_style
1132 .as_ref()
1133 .and_then(|s| s.text_style.as_ref())
1134 .and_then(|t| t.weighted_font_family.as_ref())
1135 .and_then(|f| f.font_family.as_deref()),
1136 Some("Courier New")
1137 );
1138 }
1139
1140 #[test]
1141 fn horizontal_rule_degrades_to_glyphs() {
1142 let projection = project(&doc(vec![Block::HorizontalRule]));
1143 let text = projection.requests[0]
1144 .insert_text
1145 .as_ref()
1146 .unwrap()
1147 .text
1148 .as_deref();
1149 assert!(text.is_some_and(|t| t.starts_with('\u{2500}')));
1150 assert!(projection.positions.contains_key("doc/hr-1"));
1151 }
1152
1153 #[test]
1154 fn quote_paragraphs_are_indented() {
1155 let quote = Block::QuoteBlock {
1156 children: vec![paragraph(vec![plain("quoted")])],
1157 };
1158 let projection = project(&doc(vec![quote]));
1159 let style = projection.requests[1]
1160 .update_paragraph_style
1161 .as_ref()
1162 .unwrap();
1163 assert!(
1164 style
1165 .paragraph_style
1166 .as_ref()
1167 .and_then(|p| p.indent_start.as_ref())
1168 .is_some()
1169 );
1170 assert!(projection.positions.contains_key("doc/quote-1"));
1171 }
1172
1173 #[test]
1174 fn position_map_uses_custom_id_and_hierarchical_ids() {
1175 let body = heading(
1176 1,
1177 "Intro",
1178 vec![
1179 custom_id_drawer("sec-intro"),
1180 paragraph(vec![plain("first")]),
1181 paragraph(vec![plain("second")]),
1182 ],
1183 );
1184 let projection = project(&doc(vec![body]));
1185
1186 let head = projection.positions.get("sec-intro").expect("heading id");
1187 assert_eq!((head.index, head.kind), (1, ElementKind::Heading));
1188
1189 assert!(projection.positions.contains_key("sec-intro/paragraph-1"));
1191 assert!(projection.positions.contains_key("sec-intro/paragraph-2"));
1192
1193 let p1 = projection.positions["sec-intro/paragraph-1"].index;
1195 let p2 = projection.positions["sec-intro/paragraph-2"].index;
1196 assert!(head.index < p1 && p1 < p2);
1197 }
1198
1199 #[test]
1200 fn metadata_blocks_produce_no_requests() {
1201 let blocks = vec![
1202 Block::BlankLine,
1203 Block::Comment {
1204 text: "hidden".to_owned(),
1205 },
1206 Block::Keyword {
1207 name: "TITLE".to_owned(),
1208 value: " Doc".to_owned(),
1209 },
1210 ];
1211 let projection = project(&doc(blocks));
1212 assert!(projection.requests.is_empty());
1213 assert!(projection.positions.is_empty());
1214 }
1215
1216 #[test]
1217 fn empty_table_emits_no_requests() {
1218 let projection = project(&doc(vec![Block::Table { rows: vec![] }]));
1219 assert!(projection.requests.is_empty());
1220 }
1221
1222 #[test]
1223 fn paragraph_soft_line_breaks_become_spaces() {
1224 let para = paragraph(vec![
1225 plain("first line"),
1226 Inline::LineBreak,
1227 plain("second line"),
1228 ]);
1229 let projection = project(&doc(vec![para]));
1230 let insert = projection.requests[0].insert_text.as_ref().unwrap();
1231 assert_eq!(insert.text.as_deref(), Some("first line second line\n"));
1233 }
1234
1235 #[test]
1236 fn soft_break_after_trailing_space_does_not_double() {
1237 let para = paragraph(vec![plain("first "), Inline::LineBreak, plain("second")]);
1238 let projection = project(&doc(vec![para]));
1239 let insert = projection.requests[0].insert_text.as_ref().unwrap();
1240 assert_eq!(insert.text.as_deref(), Some("first second\n"));
1241 }
1242
1243 #[test]
1244 fn list_item_soft_line_break_stays_one_bullet() {
1245 let list = Block::List {
1246 list_type: ListType::Unordered,
1247 items: vec![ListItem {
1248 content: vec![paragraph(vec![
1249 plain("wrapped"),
1250 Inline::LineBreak,
1251 plain("item"),
1252 ])],
1253 checkbox: Checkbox::NoCheckbox,
1254 }],
1255 };
1256 let projection = project(&doc(vec![list]));
1257 let insert = projection.requests[0].insert_text.as_ref().unwrap();
1258 assert_eq!(insert.text.as_deref(), Some("wrapped item\n"));
1261 }
1262
1263 #[test]
1264 fn table_drops_rule_row() {
1265 let cell = |text: &str| TableCell {
1266 inlines: vec![plain(text)],
1267 };
1268 let table = Block::Table {
1269 rows: vec![
1270 vec![cell("A1"), cell("B1")],
1271 vec![cell("---+---")],
1273 vec![cell("A2"), cell("B2")],
1274 ],
1275 };
1276 let projection = project(&doc(vec![
1277 table,
1278 Block::Paragraph {
1279 inlines: vec![plain("after")],
1280 },
1281 ]));
1282
1283 let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
1285 assert_eq!(
1286 (insert_table.rows, insert_table.columns),
1287 (Some(2), Some(2))
1288 );
1289
1290 assert!(projection.requests.iter().all(|r| {
1292 r.insert_text
1293 .as_ref()
1294 .and_then(|i| i.text.as_deref())
1295 .is_none_or(|t| !t.contains('+'))
1296 }));
1297
1298 let after = projection
1302 .requests
1303 .iter()
1304 .filter_map(|r| r.insert_text.as_ref())
1305 .find(|i| i.text.as_deref() == Some("after\n"))
1306 .unwrap();
1307 assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));
1308 }
1309
1310 #[test]
1311 fn table_of_only_rule_row_emits_no_requests() {
1312 let table = Block::Table {
1313 rows: vec![vec![TableCell {
1314 inlines: vec![plain("---+---")],
1315 }]],
1316 };
1317 let projection = project(&doc(vec![table]));
1318 assert!(projection.requests.is_empty());
1319 }
1320
1321 #[test]
1322 fn text_style_request_sets_strikethrough() {
1323 let request = text_style_request(1, 3, &Style::Strikethrough);
1324 assert_eq!(
1325 request
1326 .update_text_style
1327 .and_then(|s| s.text_style)
1328 .and_then(|t| t.strikethrough),
1329 Some(true)
1330 );
1331 }
1332}