use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Document {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "documentId"
)]
pub document_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "revisionId"
)]
pub revision_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<Body>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tabs: Vec<Tab>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
rename = "namedRanges"
)]
pub named_ranges: HashMap<String, NamedRanges>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, Header>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub footers: HashMap<String, Footer>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub footnotes: HashMap<String, Footnote>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedSegment<'a> {
pub segment_id: &'a str,
pub content: &'a [StructuralElement],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedTab<'a> {
pub tab_id: Option<&'a str>,
pub title: Option<&'a str>,
pub nesting_level: i64,
pub body: Option<&'a Body>,
headers_map: Option<&'a HashMap<String, Header>>,
footers_map: Option<&'a HashMap<String, Footer>>,
footnotes_map: Option<&'a HashMap<String, Footnote>>,
pub named_ranges: &'a HashMap<String, NamedRanges>,
}
impl<'a> ResolvedTab<'a> {
#[must_use]
pub fn headers(&self) -> Vec<ResolvedSegment<'a>> {
self.headers_map
.map_or_else(Vec::new, |m| resolve_segments(m, |h| h.content.as_slice()))
}
#[must_use]
pub fn footers(&self) -> Vec<ResolvedSegment<'a>> {
self.footers_map
.map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
}
#[must_use]
pub fn footnotes(&self) -> Vec<ResolvedSegment<'a>> {
self.footnotes_map
.map_or_else(Vec::new, |m| resolve_segments(m, |f| f.content.as_slice()))
}
}
fn resolve_segments<'a, T>(
map: &'a HashMap<String, T>,
content: impl Fn(&'a T) -> &'a [StructuralElement],
) -> Vec<ResolvedSegment<'a>> {
let mut out: Vec<ResolvedSegment<'a>> = map
.iter()
.map(|(id, seg)| ResolvedSegment {
segment_id: id.as_str(),
content: content(seg),
})
.collect();
out.sort_by_key(|s| s.segment_id);
out
}
impl Document {
#[must_use]
pub fn title(&self) -> &str {
self.title.as_deref().unwrap_or("")
}
#[must_use]
pub fn resolved_tabs(&self) -> Vec<ResolvedTab<'_>> {
if self.tabs.is_empty() {
if self.body.is_none()
&& self.headers.is_empty()
&& self.footers.is_empty()
&& self.footnotes.is_empty()
{
return Vec::new();
}
return vec![ResolvedTab {
tab_id: None,
title: None,
nesting_level: 0,
body: self.body.as_ref(),
headers_map: Some(&self.headers),
footers_map: Some(&self.footers),
footnotes_map: Some(&self.footnotes),
named_ranges: &self.named_ranges,
}];
}
let mut out = Vec::new();
for tab in &self.tabs {
push_tab(tab, 0, &mut out);
}
out
}
}
fn push_tab<'a>(tab: &'a Tab, depth: i64, out: &mut Vec<ResolvedTab<'a>>) {
let props = tab.tab_properties.as_ref();
let doc_tab = tab.document_tab.as_ref();
out.push(ResolvedTab {
tab_id: props.and_then(|p| p.tab_id.as_deref()),
title: props.and_then(|p| p.title.as_deref()),
nesting_level: props.and_then(|p| p.nesting_level).unwrap_or(depth),
body: doc_tab.and_then(|dt| dt.body.as_ref()),
headers_map: doc_tab.map(|dt| &dt.headers),
footers_map: doc_tab.map(|dt| &dt.footers),
footnotes_map: doc_tab.map(|dt| &dt.footnotes),
named_ranges: match doc_tab {
Some(dt) => &dt.named_ranges,
None => empty_named_ranges(),
},
});
for child in &tab.child_tabs {
push_tab(child, depth + 1, out);
}
}
fn empty_named_ranges() -> &'static HashMap<String, NamedRanges> {
static EMPTY: std::sync::LazyLock<HashMap<String, NamedRanges>> =
std::sync::LazyLock::new(HashMap::new);
&EMPTY
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tab {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "tabProperties"
)]
pub tab_properties: Option<TabProperties>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "documentTab"
)]
pub document_tab: Option<DocumentTab>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "childTabs")]
pub child_tabs: Vec<Self>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TabProperties {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
pub tab_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "nestingLevel"
)]
pub nesting_level: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "parentTabId"
)]
pub parent_tab_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DocumentTab {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<Body>,
#[serde(
default,
skip_serializing_if = "HashMap::is_empty",
rename = "namedRanges"
)]
pub named_ranges: HashMap<String, NamedRanges>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, Header>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub footers: HashMap<String, Footer>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub footnotes: HashMap<String, Footnote>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Body {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Header {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Footer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Footnote {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct StructuralElement {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "startIndex"
)]
pub start_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
pub end_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paragraph: Option<Paragraph>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<Table>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "tableOfContents"
)]
pub table_of_contents: Option<TableOfContents>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "sectionBreak"
)]
pub section_break: Option<serde_json::Value>,
}
impl StructuralElement {
#[must_use]
pub fn start_index(&self) -> i64 {
self.start_index.unwrap_or(0)
}
#[must_use]
pub fn end_index(&self) -> i64 {
self.end_index.unwrap_or(0)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Paragraph {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub elements: Vec<ParagraphElement>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "paragraphStyle"
)]
pub paragraph_style: Option<ParagraphStyle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bullet: Option<Bullet>,
}
impl Paragraph {
#[must_use]
pub fn text(&self) -> String {
self.elements
.iter()
.filter_map(|el| el.text_run.as_ref())
.map(|run| run.content.as_str())
.collect()
}
#[must_use]
pub fn named_style_type(&self) -> Option<&str> {
self.paragraph_style
.as_ref()
.and_then(|style| style.named_style_type.as_deref())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ParagraphStyle {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "namedStyleType"
)]
pub named_style_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "headingId")]
pub heading_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bullet {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "listId")]
pub list_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "nestingLevel"
)]
pub nesting_level: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ParagraphElement {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "startIndex"
)]
pub start_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
pub end_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "textRun")]
pub text_run: Option<TextRun>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "inlineObjectElement"
)]
pub inline_object_element: Option<InlineObjectElement>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "pageBreak")]
pub page_break: Option<serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "columnBreak"
)]
pub column_break: Option<serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "horizontalRule"
)]
pub horizontal_rule: Option<serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "footnoteReference"
)]
pub footnote_reference: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "autoText")]
pub auto_text: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub equation: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub person: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "richLink")]
pub rich_link: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TextRun {
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "suggestedInsertionIds"
)]
pub suggested_insertion_ids: Vec<String>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
rename = "suggestedDeletionIds"
)]
pub suggested_deletion_ids: Vec<String>,
#[serde(default)]
pub content: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct InlineObjectElement {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "inlineObjectId"
)]
pub inline_object_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct Table {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rows: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableRows")]
pub table_rows: Vec<TableRow>,
#[serde(skip)]
pub has_pending_suggestions: bool,
}
pub(super) fn has_suggestions(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Object(fields) => fields.iter().any(|(key, value)| {
(key.starts_with("suggested")
&& match value {
serde_json::Value::Array(v) => !v.is_empty(),
serde_json::Value::Object(v) => !v.is_empty(),
serde_json::Value::Null => false,
_ => true,
})
|| has_suggestions(value)
}),
serde_json::Value::Array(values) => values.iter().any(has_suggestions),
_ => false,
}
}
impl<'de> Deserialize<'de> for Table {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct WireTable {
#[serde(default)]
rows: Option<i64>,
#[serde(default)]
columns: Option<i64>,
#[serde(default, rename = "tableRows")]
table_rows: Vec<TableRow>,
}
let raw = serde_json::Value::deserialize(deserializer)?;
let has_pending_suggestions = has_suggestions(&raw);
let wire: WireTable = serde_json::from_value(raw).map_err(serde::de::Error::custom)?;
Ok(Self {
rows: wire.rows,
columns: wire.columns,
table_rows: wire.table_rows,
has_pending_suggestions,
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TableRow {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "startIndex"
)]
pub start_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
pub end_index: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "tableCells")]
pub table_cells: Vec<TableCell>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TableCell {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "startIndex"
)]
pub start_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
pub end_index: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "tableCellStyle"
)]
pub table_cell_style: Option<TableCellStyle>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TableCellStyle {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "rowSpan")]
pub row_span: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "columnSpan"
)]
pub column_span: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TableOfContents {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuralElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct NamedRanges {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "namedRanges")]
pub named_ranges: Vec<NamedRange>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct NamedRange {
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "namedRangeId"
)]
pub named_range_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ranges: Vec<Range>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Range {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "segmentId")]
pub segment_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "tabId")]
pub tab_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "startIndex"
)]
pub start_index: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "endIndex")]
pub end_index: Option<i64>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn parse(json: serde_json::Value) -> Document {
serde_json::from_value(json).unwrap()
}
#[test]
fn a_missing_start_index_means_zero() {
let doc = parse(serde_json::json!({
"body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
}));
let el = &doc.body.as_ref().unwrap().content[0];
assert_eq!(el.start_index, None, "the key really is absent");
assert_eq!(el.start_index(), 0, "and the accessor reads it as zero");
assert_eq!(el.end_index(), 1);
}
#[test]
fn a_present_but_empty_section_break_is_still_recognised() {
let doc = parse(serde_json::json!({
"body": {"content": [{"sectionBreak": {}}]},
}));
let el = &doc.body.as_ref().unwrap().content[0];
assert!(el.section_break.is_some(), "present-and-empty is Some");
assert!(el.paragraph.is_none());
}
#[test]
fn an_absent_section_break_is_none() {
let doc = parse(serde_json::json!({
"body": {"content": [{"paragraph": {"elements": []}}]},
}));
assert!(doc.body.as_ref().unwrap().content[0]
.section_break
.is_none());
}
#[test]
fn text_run_content_defaults_to_empty() {
let run: TextRun = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(run.content, "");
}
#[test]
fn paragraph_text_joins_every_text_run_in_order() {
let para: Paragraph = serde_json::from_value(serde_json::json!({
"elements": [
{"textRun": {"content": "Hello "}},
{"textRun": {"content": "bold"}},
{"textRun": {"content": " world\n"}},
],
}))
.unwrap();
assert_eq!(para.text(), "Hello bold world\n");
}
#[test]
fn paragraph_text_skips_a_non_text_element() {
let para: Paragraph = serde_json::from_value(serde_json::json!({
"elements": [
{"textRun": {"content": "a"}},
{"inlineObjectElement": {"inlineObjectId": "kix.1"}},
{"textRun": {"content": "b\n"}},
],
}))
.unwrap();
assert_eq!(para.text(), "ab\n");
assert!(para.elements[1].inline_object_element.is_some());
}
#[test]
fn resolved_tabs_synthesises_one_anonymous_tab_from_a_legacy_body() {
let doc = parse(serde_json::json!({
"title": "Legacy",
"body": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
}));
let tabs = doc.resolved_tabs();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].tab_id, None);
assert_eq!(tabs[0].title, None);
assert_eq!(tabs[0].nesting_level, 0);
assert!(tabs[0].body.is_some());
}
#[test]
fn resolved_tabs_of_a_document_with_neither_body_nor_tabs_is_empty() {
assert!(parse(serde_json::json!({"title": "Empty"}))
.resolved_tabs()
.is_empty());
}
#[test]
fn resolved_tabs_resolves_top_level_segments_for_a_legacy_body() {
let doc = parse(serde_json::json!({
"body": {"content": []},
"headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
"footers": {"f1": {"content": []}},
"footnotes": {"fn1": {"content": []}},
}));
let tabs = doc.resolved_tabs();
assert_eq!(tabs[0].headers().len(), 1);
assert_eq!(tabs[0].headers()[0].segment_id, "h1");
assert_eq!(tabs[0].footers().len(), 1);
assert_eq!(tabs[0].footnotes().len(), 1);
}
#[test]
fn resolved_tabs_has_empty_segments_when_the_document_has_none() {
let doc = parse(serde_json::json!({"body": {"content": []}}));
let tabs = doc.resolved_tabs();
assert!(tabs[0].headers().is_empty());
assert!(tabs[0].footers().is_empty());
assert!(tabs[0].footnotes().is_empty());
}
#[test]
fn resolved_tabs_synthesises_a_tab_for_segments_with_no_body() {
let doc = parse(serde_json::json!({
"headers": {"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]}},
}));
let tabs = doc.resolved_tabs();
assert_eq!(tabs.len(), 1);
assert!(tabs[0].body.is_none());
assert_eq!(tabs[0].headers().len(), 1);
}
#[test]
fn resolved_tabs_resolves_tab_scoped_segments_sorted_by_segment_id() {
let doc = parse(serde_json::json!({
"tabs": [{
"tabProperties": {"tabId": "t.0"},
"documentTab": {
"body": {"content": []},
"headers": {
"h2": {"content": []},
"h1": {"content": [{"endIndex": 1, "sectionBreak": {}}]},
},
},
}],
}));
let tabs = doc.resolved_tabs();
let headers = tabs[0].headers();
let ids: Vec<_> = headers.iter().map(|s| s.segment_id).collect();
assert_eq!(ids, vec!["h1", "h2"]);
assert_eq!(headers[0].content.len(), 1);
}
#[test]
fn resolved_tabs_walks_child_tabs_depth_first_parent_before_children() {
let doc = parse(serde_json::json!({
"tabs": [
{
"tabProperties": {"tabId": "t.0", "title": "One", "nestingLevel": 0},
"documentTab": {"body": {"content": []}},
"childTabs": [{
"tabProperties": {"tabId": "t.0.a", "title": "One A", "nestingLevel": 1},
"documentTab": {"body": {"content": []}},
}],
},
{
"tabProperties": {"tabId": "t.1", "title": "Two", "nestingLevel": 0},
"documentTab": {"body": {"content": []}},
},
],
}));
let ids: Vec<_> = doc.resolved_tabs().iter().map(|t| t.tab_id).collect();
assert_eq!(ids, vec![Some("t.0"), Some("t.0.a"), Some("t.1")]);
assert_eq!(doc.resolved_tabs()[1].nesting_level, 1);
}
#[test]
fn resolved_tabs_falls_back_to_walk_depth_when_nesting_level_is_absent() {
let doc = parse(serde_json::json!({
"tabs": [{
"tabProperties": {"tabId": "t.0"},
"childTabs": [{"tabProperties": {"tabId": "t.0.a"}}],
}],
}));
let tabs = doc.resolved_tabs();
assert_eq!(tabs[0].nesting_level, 0);
assert_eq!(tabs[1].nesting_level, 1);
}
#[test]
fn tabs_take_precedence_over_a_legacy_body() {
let doc = parse(serde_json::json!({
"body": {"content": [{"paragraph": {"elements": [{"textRun": {"content": "legacy\n"}}]}}]},
"tabs": [{
"tabProperties": {"tabId": "t.0"},
"documentTab": {"body": {"content": []}},
}],
}));
let tabs = doc.resolved_tabs();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].tab_id, Some("t.0"));
}
#[test]
fn nested_tables_parse_recursively() {
let doc = parse(serde_json::json!({
"body": {"content": [{
"startIndex": 1, "endIndex": 40,
"table": {
"rows": 1, "columns": 1,
"tableRows": [{"tableCells": [{"content": [{
"table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
"content": [{"paragraph": {"elements": [
{"textRun": {"content": "deep\n"}}]}}],
}]}]},
}]}]}],
},
}]},
}));
let outer = doc.body.as_ref().unwrap().content[0]
.table
.as_ref()
.unwrap();
let inner = outer.table_rows[0].table_cells[0].content[0]
.table
.as_ref()
.unwrap();
let para = inner.table_rows[0].table_cells[0].content[0]
.paragraph
.as_ref()
.unwrap();
assert_eq!(para.text(), "deep\n");
}
#[test]
fn named_ranges_parse_as_a_map_keyed_by_name() {
let doc = parse(serde_json::json!({
"namedRanges": {
"intro": {
"name": "intro",
"namedRanges": [{
"namedRangeId": "nr.1", "name": "intro",
"ranges": [{"startIndex": 1, "endIndex": 20}],
}],
},
},
}));
let group = doc.named_ranges.get("intro").unwrap();
assert_eq!(group.name, "intro");
assert_eq!(group.named_ranges[0].ranges[0].end_index, Some(20));
}
#[test]
fn a_document_without_edit_access_has_no_revision_id() {
assert_eq!(parse(serde_json::json!({"title": "T"})).revision_id, None);
let with = parse(serde_json::json!({"revisionId": "rev-1"}));
assert_eq!(with.revision_id.as_deref(), Some("rev-1"));
}
#[test]
fn document_tolerates_unmodelled_fields() {
let doc = parse(serde_json::json!({
"documentId": "d1",
"title": "T",
"documentStyle": {"marginTop": {"magnitude": 72.0}},
"lists": {"kix.l1": {"listProperties": {}}},
"inlineObjects": {"kix.i1": {}},
"suggestionsViewMode": "PREVIEW_SUGGESTIONS_ACCEPTED",
"body": {"content": [{
"endIndex": 5,
"paragraph": {
"elements": [{"textRun": {
"content": "hi\n",
"textStyle": {"bold": true},
"suggestedInsertionIds": ["s1"],
}}],
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT", "direction": "LEFT_TO_RIGHT"},
},
}]},
}));
assert_eq!(doc.document_id.as_deref(), Some("d1"));
let para = doc.body.as_ref().unwrap().content[0]
.paragraph
.as_ref()
.unwrap();
assert_eq!(para.text(), "hi\n");
assert_eq!(para.named_style_type(), Some("NORMAL_TEXT"));
}
#[test]
fn has_suggestions_counts_only_non_empty_suggested_fields_at_any_depth() {
for (value, expected) in [
(serde_json::json!({"suggestedInsertionIds": ["s"]}), true),
(
serde_json::json!({"suggestedTextStyleChanges": {"s": {}}}),
true,
),
(serde_json::json!({"suggestedBold": true}), true),
(serde_json::json!({"suggestedNote": "text"}), true),
(serde_json::json!({"suggestedCount": 0}), true),
(serde_json::json!({"suggestedInsertionIds": []}), false),
(serde_json::json!({"suggestedTextStyleChanges": {}}), false),
(serde_json::json!({"suggestedNote": null}), false),
(serde_json::json!({"insertionIds": ["s"]}), false),
(serde_json::json!({"notSuggested": true}), false),
(
serde_json::json!({"a": [{"b": {"suggestedDeletionIds": ["s"]}}]}),
true,
),
(
serde_json::json!({"a": [{"b": {"suggestedNote": null}}]}),
false,
),
(serde_json::json!("suggestedInsertionIds"), false),
(serde_json::json!(null), false),
] {
assert_eq!(has_suggestions(&value), expected, "{value}");
}
}
#[test]
fn a_table_remembers_unmodelled_suggestions_without_serialising_them() {
let clean: Table = serde_json::from_value(serde_json::json!({
"rows": 1,
"columns": 1,
"tableRows": [{"suggestedInsertionIds": []}],
}))
.unwrap();
assert!(!clean.has_pending_suggestions);
let suggested: Table = serde_json::from_value(serde_json::json!({
"rows": 1,
"columns": 1,
"tableRows": [{"tableCells": [{"tableCellStyle": {"suggestedTableCellStyleChanges": {"s": {}}}}]}],
}))
.unwrap();
assert!(suggested.has_pending_suggestions);
let wire = serde_json::to_value(&suggested).unwrap();
assert!(!wire.to_string().contains("suggested"), "{wire}");
assert!(wire.get("has_pending_suggestions").is_none());
}
#[test]
fn title_defaults_to_empty() {
assert_eq!(parse(serde_json::json!({})).title(), "");
assert_eq!(parse(serde_json::json!({"title": "T"})).title(), "T");
}
}