1use std::collections::HashMap;
36
37use serde::{Deserialize, Serialize};
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
48pub struct Document {
49 #[serde(
51 default,
52 skip_serializing_if = "Option::is_none",
53 rename = "documentId"
54 )]
55 pub document_id: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub title: Option<String>,
59 #[serde(
66 default,
67 skip_serializing_if = "Option::is_none",
68 rename = "revisionId"
69 )]
70 pub revision_id: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub body: Option<Body>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub tabs: Vec<Tab>,
78 #[serde(
83 default,
84 skip_serializing_if = "HashMap::is_empty",
85 rename = "namedRanges"
86 )]
87 pub named_ranges: HashMap<String, NamedRanges>,
88 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91 pub headers: HashMap<String, Header>,
92 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94 pub footers: HashMap<String, Footer>,
95 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97 pub footnotes: HashMap<String, Footnote>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct ResolvedSegment<'a> {
107 pub segment_id: &'a str,
109 pub content: &'a [StructuralElement],
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct ResolvedTab<'a> {
124 pub tab_id: Option<&'a str>,
126 pub title: Option<&'a str>,
128 pub nesting_level: i64,
130 pub body: Option<&'a Body>,
132 headers_map: Option<&'a HashMap<String, Header>>,
133 footers_map: Option<&'a HashMap<String, Footer>>,
134 footnotes_map: Option<&'a HashMap<String, Footnote>>,
135 pub named_ranges: &'a HashMap<String, NamedRanges>,
137}
138
139impl<'a> ResolvedTab<'a> {
140 #[must_use]
143 pub fn headers(&self) -> Vec<ResolvedSegment<'a>> {
144 self.headers_map
145 .map_or_else(Vec::new, |m| resolve_segments(m, |h| h.content.as_slice()))
146 }
147 #[must_use]
149 pub fn footers(&self) -> Vec<ResolvedSegment<'a>> {
150 self.footers_map
151 .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
152 }
153 #[must_use]
155 pub fn footnotes(&self) -> Vec<ResolvedSegment<'a>> {
156 self.footnotes_map
157 .map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
158 }
159}
160
161fn resolve_segments<'a, T>(
163 map: &'a HashMap<String, T>,
164 content: impl Fn(&'a T) -> &'a [StructuralElement],
165) -> Vec<ResolvedSegment<'a>> {
166 let mut out: Vec<ResolvedSegment<'a>> = map
167 .iter()
168 .map(|(id, seg)| ResolvedSegment {
169 segment_id: id.as_str(),
170 content: content(seg),
171 })
172 .collect();
173 out.sort_by_key(|s| s.segment_id);
174 out
175}
176
177impl Document {
178 #[must_use]
180 pub fn title(&self) -> &str {
181 self.title.as_deref().unwrap_or("")
182 }
183
184 #[must_use]
194 pub fn resolved_tabs(&self) -> Vec<ResolvedTab<'_>> {
195 if self.tabs.is_empty() {
196 if self.body.is_none()
197 && self.headers.is_empty()
198 && self.footers.is_empty()
199 && self.footnotes.is_empty()
200 {
201 return Vec::new();
202 }
203 return vec![ResolvedTab {
204 tab_id: None,
205 title: None,
206 nesting_level: 0,
207 body: self.body.as_ref(),
208 headers_map: Some(&self.headers),
209 footers_map: Some(&self.footers),
210 footnotes_map: Some(&self.footnotes),
211 named_ranges: &self.named_ranges,
212 }];
213 }
214 let mut out = Vec::new();
215 for tab in &self.tabs {
216 push_tab(tab, 0, &mut out);
217 }
218 out
219 }
220}
221
222fn push_tab<'a>(tab: &'a Tab, depth: i64, out: &mut Vec<ResolvedTab<'a>>) {
224 let props = tab.tab_properties.as_ref();
225 let doc_tab = tab.document_tab.as_ref();
226 out.push(ResolvedTab {
227 tab_id: props.and_then(|p| p.tab_id.as_deref()),
228 title: props.and_then(|p| p.title.as_deref()),
229 nesting_level: props.and_then(|p| p.nesting_level).unwrap_or(depth),
233 body: doc_tab.and_then(|dt| dt.body.as_ref()),
234 headers_map: doc_tab.map(|dt| &dt.headers),
235 footers_map: doc_tab.map(|dt| &dt.footers),
236 footnotes_map: doc_tab.map(|dt| &dt.footnotes),
237 named_ranges: match doc_tab {
238 Some(dt) => &dt.named_ranges,
239 None => empty_named_ranges(),
240 },
241 });
242 for child in &tab.child_tabs {
243 push_tab(child, depth + 1, out);
244 }
245}
246
247fn empty_named_ranges() -> &'static HashMap<String, NamedRanges> {
248 static EMPTY: std::sync::LazyLock<HashMap<String, NamedRanges>> =
249 std::sync::LazyLock::new(HashMap::new);
250 &EMPTY
251}
252
253#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
255pub struct Tab {
256 #[serde(
258 default,
259 skip_serializing_if = "Option::is_none",
260 rename = "tabProperties"
261 )]
262 pub tab_properties: Option<TabProperties>,
263 #[serde(
265 default,
266 skip_serializing_if = "Option::is_none",
267 rename = "documentTab"
268 )]
269 pub document_tab: Option<DocumentTab>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "childTabs")]
272 pub child_tabs: Vec<Self>,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
277pub struct TabProperties {
278 #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
281 pub tab_id: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub title: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub index: Option<i64>,
288 #[serde(
290 default,
291 skip_serializing_if = "Option::is_none",
292 rename = "nestingLevel"
293 )]
294 pub nesting_level: Option<i64>,
295 #[serde(
297 default,
298 skip_serializing_if = "Option::is_none",
299 rename = "parentTabId"
300 )]
301 pub parent_tab_id: Option<String>,
302}
303
304#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
306pub struct DocumentTab {
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub body: Option<Body>,
310 #[serde(
312 default,
313 skip_serializing_if = "HashMap::is_empty",
314 rename = "namedRanges"
315 )]
316 pub named_ranges: HashMap<String, NamedRanges>,
317 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
319 pub headers: HashMap<String, Header>,
320 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
322 pub footers: HashMap<String, Footer>,
323 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
325 pub footnotes: HashMap<String, Footnote>,
326}
327
328#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
337pub struct Body {
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 pub content: Vec<StructuralElement>,
341}
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
345pub struct Header {
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub content: Vec<StructuralElement>,
349}
350
351#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
355pub struct Footer {
356 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub content: Vec<StructuralElement>,
359}
360
361#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
364pub struct Footnote {
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub content: Vec<StructuralElement>,
368}
369
370#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
373pub struct StructuralElement {
374 #[serde(
376 default,
377 skip_serializing_if = "Option::is_none",
378 rename = "startIndex"
379 )]
380 pub start_index: Option<i64>,
381 #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
383 pub end_index: Option<i64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub paragraph: Option<Paragraph>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub table: Option<Table>,
390 #[serde(
392 default,
393 skip_serializing_if = "Option::is_none",
394 rename = "tableOfContents"
395 )]
396 pub table_of_contents: Option<TableOfContents>,
397 #[serde(
400 default,
401 skip_serializing_if = "Option::is_none",
402 rename = "sectionBreak"
403 )]
404 pub section_break: Option<serde_json::Value>,
405}
406
407impl StructuralElement {
408 #[must_use]
410 pub fn start_index(&self) -> i64 {
411 self.start_index.unwrap_or(0)
412 }
413
414 #[must_use]
416 pub fn end_index(&self) -> i64 {
417 self.end_index.unwrap_or(0)
418 }
419}
420
421#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
423pub struct Paragraph {
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub elements: Vec<ParagraphElement>,
427 #[serde(
429 default,
430 skip_serializing_if = "Option::is_none",
431 rename = "paragraphStyle"
432 )]
433 pub paragraph_style: Option<ParagraphStyle>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub bullet: Option<Bullet>,
437}
438
439impl Paragraph {
440 #[must_use]
450 pub fn text(&self) -> String {
451 self.elements
452 .iter()
453 .filter_map(|el| el.text_run.as_ref())
454 .map(|run| run.content.as_str())
455 .collect()
456 }
457
458 #[must_use]
460 pub fn named_style_type(&self) -> Option<&str> {
461 self.paragraph_style
462 .as_ref()
463 .and_then(|style| style.named_style_type.as_deref())
464 }
465}
466
467#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
469pub struct ParagraphStyle {
470 #[serde(
472 default,
473 skip_serializing_if = "Option::is_none",
474 rename = "namedStyleType"
475 )]
476 pub named_style_type: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none", rename = "headingId")]
479 pub heading_id: Option<String>,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
484pub struct Bullet {
485 #[serde(default, skip_serializing_if = "Option::is_none", rename = "listId")]
487 pub list_id: Option<String>,
488 #[serde(
490 default,
491 skip_serializing_if = "Option::is_none",
492 rename = "nestingLevel"
493 )]
494 pub nesting_level: Option<i64>,
495}
496
497#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
504pub struct ParagraphElement {
505 #[serde(
507 default,
508 skip_serializing_if = "Option::is_none",
509 rename = "startIndex"
510 )]
511 pub start_index: Option<i64>,
512 #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
514 pub end_index: Option<i64>,
515 #[serde(default, skip_serializing_if = "Option::is_none", rename = "textRun")]
517 pub text_run: Option<TextRun>,
518 #[serde(
522 default,
523 skip_serializing_if = "Option::is_none",
524 rename = "inlineObjectElement"
525 )]
526 pub inline_object_element: Option<InlineObjectElement>,
527 #[serde(default, skip_serializing_if = "Option::is_none", rename = "pageBreak")]
529 pub page_break: Option<serde_json::Value>,
530 #[serde(
532 default,
533 skip_serializing_if = "Option::is_none",
534 rename = "columnBreak"
535 )]
536 pub column_break: Option<serde_json::Value>,
537 #[serde(
539 default,
540 skip_serializing_if = "Option::is_none",
541 rename = "horizontalRule"
542 )]
543 pub horizontal_rule: Option<serde_json::Value>,
544 #[serde(
546 default,
547 skip_serializing_if = "Option::is_none",
548 rename = "footnoteReference"
549 )]
550 pub footnote_reference: Option<serde_json::Value>,
551 #[serde(default, skip_serializing_if = "Option::is_none", rename = "autoText")]
553 pub auto_text: Option<serde_json::Value>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub equation: Option<serde_json::Value>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub person: Option<serde_json::Value>,
560 #[serde(default, skip_serializing_if = "Option::is_none", rename = "richLink")]
562 pub rich_link: Option<serde_json::Value>,
563}
564
565#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
567pub struct TextRun {
568 #[serde(
570 default,
571 skip_serializing_if = "Vec::is_empty",
572 rename = "suggestedInsertionIds"
573 )]
574 pub suggested_insertion_ids: Vec<String>,
575 #[serde(
577 default,
578 skip_serializing_if = "Vec::is_empty",
579 rename = "suggestedDeletionIds"
580 )]
581 pub suggested_deletion_ids: Vec<String>,
582 #[serde(default)]
584 pub content: String,
585}
586
587#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
589pub struct InlineObjectElement {
590 #[serde(
592 default,
593 skip_serializing_if = "Option::is_none",
594 rename = "inlineObjectId"
595 )]
596 pub inline_object_id: Option<String>,
597}
598
599#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
601pub struct Table {
602 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub rows: Option<i64>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub columns: Option<i64>,
608 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableRows")]
610 pub table_rows: Vec<TableRow>,
611 #[serde(skip)]
615 pub has_pending_suggestions: bool,
616}
617
618pub(super) fn has_suggestions(value: &serde_json::Value) -> bool {
619 match value {
620 serde_json::Value::Object(fields) => fields.iter().any(|(key, value)| {
621 (key.starts_with("suggested")
622 && match value {
623 serde_json::Value::Array(v) => !v.is_empty(),
624 serde_json::Value::Object(v) => !v.is_empty(),
625 serde_json::Value::Null => false,
626 _ => true,
627 })
628 || has_suggestions(value)
629 }),
630 serde_json::Value::Array(values) => values.iter().any(has_suggestions),
631 _ => false,
632 }
633}
634
635impl<'de> Deserialize<'de> for Table {
636 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
637 #[derive(Deserialize)]
638 struct WireTable {
639 #[serde(default)]
640 rows: Option<i64>,
641 #[serde(default)]
642 columns: Option<i64>,
643 #[serde(default, rename = "tableRows")]
644 table_rows: Vec<TableRow>,
645 }
646 let raw = serde_json::Value::deserialize(deserializer)?;
647 let has_pending_suggestions = has_suggestions(&raw);
648 let wire: WireTable = serde_json::from_value(raw).map_err(serde::de::Error::custom)?;
649 Ok(Self {
650 rows: wire.rows,
651 columns: wire.columns,
652 table_rows: wire.table_rows,
653 has_pending_suggestions,
654 })
655 }
656}
657
658#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
660pub struct TableRow {
661 #[serde(
663 default,
664 skip_serializing_if = "Option::is_none",
665 rename = "startIndex"
666 )]
667 pub start_index: Option<i64>,
668 #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
670 pub end_index: Option<i64>,
671 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableCells")]
673 pub table_cells: Vec<TableCell>,
674}
675
676#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
678pub struct TableCell {
679 #[serde(
681 default,
682 skip_serializing_if = "Option::is_none",
683 rename = "startIndex"
684 )]
685 pub start_index: Option<i64>,
686 #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
688 pub end_index: Option<i64>,
689 #[serde(default, skip_serializing_if = "Vec::is_empty")]
692 pub content: Vec<StructuralElement>,
693 #[serde(
695 default,
696 skip_serializing_if = "Option::is_none",
697 rename = "tableCellStyle"
698 )]
699 pub table_cell_style: Option<TableCellStyle>,
700}
701
702#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
704pub struct TableCellStyle {
705 #[serde(default, skip_serializing_if = "Option::is_none", rename = "rowSpan")]
707 pub row_span: Option<i64>,
708 #[serde(
710 default,
711 skip_serializing_if = "Option::is_none",
712 rename = "columnSpan"
713 )]
714 pub column_span: Option<i64>,
715}
716
717#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
719pub struct TableOfContents {
720 #[serde(default, skip_serializing_if = "Vec::is_empty")]
722 pub content: Vec<StructuralElement>,
723}
724
725#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
727pub struct NamedRanges {
728 #[serde(default)]
730 pub name: String,
731 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "namedRanges")]
733 pub named_ranges: Vec<NamedRange>,
734}
735
736#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
738pub struct NamedRange {
739 #[serde(
741 default,
742 skip_serializing_if = "Option::is_none",
743 rename = "namedRangeId"
744 )]
745 pub named_range_id: Option<String>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub name: Option<String>,
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
751 pub ranges: Vec<Range>,
752}
753
754#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
756pub struct Range {
757 #[serde(default, skip_serializing_if = "Option::is_none", rename = "segmentId")]
759 pub segment_id: Option<String>,
760 #[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
762 pub tab_id: Option<String>,
763 #[serde(
765 default,
766 skip_serializing_if = "Option::is_none",
767 rename = "startIndex"
768 )]
769 pub start_index: Option<i64>,
770 #[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
772 pub end_index: Option<i64>,
773}
774
775#[cfg(test)]
776#[allow(clippy::unwrap_used, clippy::expect_used)]
777mod tests {
778 use super::*;
779
780 fn parse(json: serde_json::Value) -> Document {
781 serde_json::from_value(json).unwrap()
782 }
783
784 #[test]
789 fn a_missing_start_index_means_zero() {
790 let doc = parse(serde_json::json!({
791 "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
792 }));
793 let el = &doc.body.as_ref().unwrap().content[0];
794 assert_eq!(el.start_index, None, "the key really is absent");
795 assert_eq!(el.start_index(), 0, "and the accessor reads it as zero");
796 assert_eq!(el.end_index(), 1);
797 }
798
799 #[test]
800 fn a_present_but_empty_section_break_is_still_recognised() {
801 let doc = parse(serde_json::json!({
802 "body": {"content": [{"sectionBreak": {}}]},
803 }));
804 let el = &doc.body.as_ref().unwrap().content[0];
805 assert!(el.section_break.is_some(), "present-and-empty is Some");
806 assert!(el.paragraph.is_none());
807 }
808
809 #[test]
810 fn an_absent_section_break_is_none() {
811 let doc = parse(serde_json::json!({
812 "body": {"content": [{"paragraph": {"elements": []}}]},
813 }));
814 assert!(doc.body.as_ref().unwrap().content[0]
815 .section_break
816 .is_none());
817 }
818
819 #[test]
820 fn text_run_content_defaults_to_empty() {
821 let run: TextRun = serde_json::from_value(serde_json::json!({})).unwrap();
822 assert_eq!(run.content, "");
823 }
824
825 #[test]
828 fn paragraph_text_joins_every_text_run_in_order() {
829 let para: Paragraph = serde_json::from_value(serde_json::json!({
830 "elements": [
831 {"textRun": {"content": "Hello "}},
832 {"textRun": {"content": "bold"}},
833 {"textRun": {"content": " world\n"}},
834 ],
835 }))
836 .unwrap();
837 assert_eq!(para.text(), "Hello bold world\n");
838 }
839
840 #[test]
843 fn paragraph_text_skips_a_non_text_element() {
844 let para: Paragraph = serde_json::from_value(serde_json::json!({
845 "elements": [
846 {"textRun": {"content": "a"}},
847 {"inlineObjectElement": {"inlineObjectId": "kix.1"}},
848 {"textRun": {"content": "b\n"}},
849 ],
850 }))
851 .unwrap();
852 assert_eq!(para.text(), "ab\n");
853 assert!(para.elements[1].inline_object_element.is_some());
854 }
855
856 #[test]
857 fn resolved_tabs_synthesises_one_anonymous_tab_from_a_legacy_body() {
858 let doc = parse(serde_json::json!({
859 "title": "Legacy",
860 "body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
861 }));
862 let tabs = doc.resolved_tabs();
863 assert_eq!(tabs.len(), 1);
864 assert_eq!(tabs[0].tab_id, None);
865 assert_eq!(tabs[0].title, None);
866 assert_eq!(tabs[0].nesting_level, 0);
867 assert!(tabs[0].body.is_some());
868 }
869
870 #[test]
871 fn resolved_tabs_of_a_document_with_neither_body_nor_tabs_is_empty() {
872 assert!(parse(serde_json::json!({"title": "Empty"}))
873 .resolved_tabs()
874 .is_empty());
875 }
876
877 #[test]
879 fn resolved_tabs_resolves_top_level_segments_for_a_legacy_body() {
880 let doc = parse(serde_json::json!({
881 "body": {"content": []},
882 "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
883 "footers": {"f1": {"content": []}},
884 "footnotes": {"fn1": {"content": []}},
885 }));
886 let tabs = doc.resolved_tabs();
887 assert_eq!(tabs[0].headers().len(), 1);
888 assert_eq!(tabs[0].headers()[0].segment_id, "h1");
889 assert_eq!(tabs[0].footers().len(), 1);
890 assert_eq!(tabs[0].footnotes().len(), 1);
891 }
892
893 #[test]
895 fn resolved_tabs_has_empty_segments_when_the_document_has_none() {
896 let doc = parse(serde_json::json!({"body": {"content": []}}));
897 let tabs = doc.resolved_tabs();
898 assert!(tabs[0].headers().is_empty());
899 assert!(tabs[0].footers().is_empty());
900 assert!(tabs[0].footnotes().is_empty());
901 }
902
903 #[test]
908 fn resolved_tabs_synthesises_a_tab_for_segments_with_no_body() {
909 let doc = parse(serde_json::json!({
910 "headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
911 }));
912 let tabs = doc.resolved_tabs();
913 assert_eq!(tabs.len(), 1);
914 assert!(tabs[0].body.is_none());
915 assert_eq!(tabs[0].headers().len(), 1);
916 }
917
918 #[test]
921 fn resolved_tabs_resolves_tab_scoped_segments_sorted_by_segment_id() {
922 let doc = parse(serde_json::json!({
923 "tabs": [{
924 "tabProperties": {"tabId": "t.0"},
925 "documentTab": {
926 "body": {"content": []},
927 "headers": {
928 "h2": {"content": []},
929 "h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
930 },
931 },
932 }],
933 }));
934 let tabs = doc.resolved_tabs();
935 let headers = tabs[0].headers();
936 let ids: Vec<_> = headers.iter().map(|s| s.segment_id).collect();
937 assert_eq!(ids, vec!["h1", "h2"]);
938 assert_eq!(headers[0].content.len(), 1);
939 }
940
941 #[test]
942 fn resolved_tabs_walks_child_tabs_depth_first_parent_before_children() {
943 let doc = parse(serde_json::json!({
944 "tabs": [
945 {
946 "tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
947 "documentTab": {"body": {"content": []}},
948 "childTabs": [{
949 "tabProperties": {"tabId": "t.0.a", "title": "One A", "nestingLevel": 1},
950 "documentTab": {"body": {"content": []}},
951 }],
952 },
953 {
954 "tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
955 "documentTab": {"body": {"content": []}},
956 },
957 ],
958 }));
959 let ids: Vec<_> = doc.resolved_tabs().iter().map(|t| t.tab_id).collect();
960 assert_eq!(ids, vec![Some("t.0"), Some("t.0.a"), Some("t.1")]);
961 assert_eq!(doc.resolved_tabs()[1].nesting_level, 1);
962 }
963
964 #[test]
967 fn resolved_tabs_falls_back_to_walk_depth_when_nesting_level_is_absent() {
968 let doc = parse(serde_json::json!({
969 "tabs": [{
970 "tabProperties": {"tabId": "t.0"},
971 "childTabs": [{"tabProperties": {"tabId": "t.0.a"}}],
972 }],
973 }));
974 let tabs = doc.resolved_tabs();
975 assert_eq!(tabs[0].nesting_level, 0);
976 assert_eq!(tabs[1].nesting_level, 1);
977 }
978
979 #[test]
981 fn tabs_take_precedence_over_a_legacy_body() {
982 let doc = parse(serde_json::json!({
983 "body": {"content": [{"paragraph": {"elements": [{"textRun": {"content": "legacy\n"}}]}}]},
984 "tabs": [{
985 "tabProperties": {"tabId": "t.0"},
986 "documentTab": {"body": {"content": []}},
987 }],
988 }));
989 let tabs = doc.resolved_tabs();
990 assert_eq!(tabs.len(), 1);
991 assert_eq!(tabs[0].tab_id, Some("t.0"));
992 }
993
994 #[test]
995 fn nested_tables_parse_recursively() {
996 let doc = parse(serde_json::json!({
997 "body": {"content": [{
998 "startIndex": 1, "endIndex": 40,
999 "table": {
1000 "rows": 1, "columns": 1,
1001 "tableRows": [{"tableCells": [{"content": [{
1002 "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
1003 "content": [{"paragraph": {"elements": [
1004 {"textRun": {"content": "deep\n"}}]}}],
1005 }]}]},
1006 }]}]}],
1007 },
1008 }]},
1009 }));
1010 let outer = doc.body.as_ref().unwrap().content[0]
1011 .table
1012 .as_ref()
1013 .unwrap();
1014 let inner = outer.table_rows[0].table_cells[0].content[0]
1015 .table
1016 .as_ref()
1017 .unwrap();
1018 let para = inner.table_rows[0].table_cells[0].content[0]
1019 .paragraph
1020 .as_ref()
1021 .unwrap();
1022 assert_eq!(para.text(), "deep\n");
1023 }
1024
1025 #[test]
1026 fn named_ranges_parse_as_a_map_keyed_by_name() {
1027 let doc = parse(serde_json::json!({
1028 "namedRanges": {
1029 "intro": {
1030 "name": "intro",
1031 "namedRanges": [{
1032 "namedRangeId": "nr.1", "name": "intro",
1033 "ranges": [{"startIndex": 1, "endIndex": 20}],
1034 }],
1035 },
1036 },
1037 }));
1038 let group = doc.named_ranges.get("intro").unwrap();
1039 assert_eq!(group.name, "intro");
1040 assert_eq!(group.named_ranges[0].ranges[0].end_index, Some(20));
1041 }
1042
1043 #[test]
1047 fn a_document_without_edit_access_has_no_revision_id() {
1048 assert_eq!(parse(serde_json::json!({"title": "T"})).revision_id, None);
1049 let with = parse(serde_json::json!({"revisionId": "rev-1"}));
1050 assert_eq!(with.revision_id.as_deref(), Some("rev-1"));
1051 }
1052
1053 #[test]
1054 fn document_tolerates_unmodelled_fields() {
1055 let doc = parse(serde_json::json!({
1056 "documentId": "d1",
1057 "title": "T",
1058 "documentStyle": {"marginTop": {"magnitude": 72.0}},
1059 "lists": {"kix.l1": {"listProperties": {}}},
1060 "inlineObjects": {"kix.i1": {}},
1061 "suggestionsViewMode": "PREVIEW_SUGGESTIONS_ACCEPTED",
1062 "body": {"content": [{
1063 "endIndex": 5,
1064 "paragraph": {
1065 "elements": [{"textRun": {
1066 "content": "hi\n",
1067 "textStyle": {"bold": true},
1068 "suggestedInsertionIds": ["s1"],
1069 }}],
1070 "paragraphStyle": {"namedStyleType": "NORMAL_TEXT", "direction": "LEFT_TO_RIGHT"},
1071 },
1072 }]},
1073 }));
1074 assert_eq!(doc.document_id.as_deref(), Some("d1"));
1075 let para = doc.body.as_ref().unwrap().content[0]
1076 .paragraph
1077 .as_ref()
1078 .unwrap();
1079 assert_eq!(para.text(), "hi\n");
1080 assert_eq!(para.named_style_type(), Some("NORMAL_TEXT"));
1081 }
1082
1083 #[test]
1088 fn has_suggestions_counts_only_non_empty_suggested_fields_at_any_depth() {
1089 for (value, expected) in [
1090 (serde_json::json!({"suggestedInsertionIds": ["s"]}), true),
1091 (
1092 serde_json::json!({"suggestedTextStyleChanges": {"s": {}}}),
1093 true,
1094 ),
1095 (serde_json::json!({"suggestedBold": true}), true),
1096 (serde_json::json!({"suggestedNote": "text"}), true),
1097 (serde_json::json!({"suggestedCount": 0}), true),
1098 (serde_json::json!({"suggestedInsertionIds": []}), false),
1099 (serde_json::json!({"suggestedTextStyleChanges": {}}), false),
1100 (serde_json::json!({"suggestedNote": null}), false),
1101 (serde_json::json!({"insertionIds": ["s"]}), false),
1102 (serde_json::json!({"notSuggested": true}), false),
1103 (
1104 serde_json::json!({"a": [{"b": {"suggestedDeletionIds": ["s"]}}]}),
1105 true,
1106 ),
1107 (
1108 serde_json::json!({"a": [{"b": {"suggestedNote": null}}]}),
1109 false,
1110 ),
1111 (serde_json::json!("suggestedInsertionIds"), false),
1112 (serde_json::json!(null), false),
1113 ] {
1114 assert_eq!(has_suggestions(&value), expected, "{value}");
1115 }
1116 }
1117
1118 #[test]
1121 fn a_table_remembers_unmodelled_suggestions_without_serialising_them() {
1122 let clean: Table = serde_json::from_value(serde_json::json!({
1123 "rows": 1,
1124 "columns": 1,
1125 "tableRows": [{"suggestedInsertionIds": []}],
1126 }))
1127 .unwrap();
1128 assert!(!clean.has_pending_suggestions);
1129 let suggested: Table = serde_json::from_value(serde_json::json!({
1130 "rows": 1,
1131 "columns": 1,
1132 "tableRows": [{"tableCells": [{"tableCellStyle": {"suggestedTableCellStyleChanges": {"s": {}}}}]}],
1133 }))
1134 .unwrap();
1135 assert!(suggested.has_pending_suggestions);
1136 let wire = serde_json::to_value(&suggested).unwrap();
1137 assert!(!wire.to_string().contains("suggested"), "{wire}");
1138 assert!(wire.get("has_pending_suggestions").is_none());
1139 }
1140
1141 #[test]
1142 fn title_defaults_to_empty() {
1143 assert_eq!(parse(serde_json::json!({})).title(), "");
1144 assert_eq!(parse(serde_json::json!({"title": "T"})).title(), "T");
1145 }
1146}