1use serde::Serialize;
31
32use crate::drive::docs::types::{Body, ResolvedTab, StructuralElement};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum ElementKind {
38 Paragraph,
40 Table,
42 SectionBreak,
44 TableOfContents,
46}
47
48impl ElementKind {
49 #[must_use]
51 pub const fn as_str(self) -> &'static str {
52 match self {
53 Self::Paragraph => "paragraph",
54 Self::Table => "table",
55 Self::SectionBreak => "section-break",
56 Self::TableOfContents => "table-of-contents",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct DocElement {
64 pub start_index: i64,
66 pub end_index: i64,
68 pub kind: ElementKind,
70 pub path: Vec<usize>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub style: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub list_id: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub rows: Option<i64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub columns: Option<i64>,
86 pub text: String,
89}
90
91impl DocElement {
92 #[must_use]
94 pub fn depth(&self) -> usize {
95 self.path.len().saturating_sub(1)
96 }
97}
98
99#[must_use]
102pub fn flatten(body: &Body) -> Vec<DocElement> {
103 flatten_content(&body.content)
104}
105
106#[must_use]
112pub fn flatten_content(content: &[StructuralElement]) -> Vec<DocElement> {
113 let mut out = Vec::new();
114 walk(content, &mut Vec::new(), &mut out);
115 out
116}
117
118fn walk(content: &[StructuralElement], path: &mut Vec<usize>, out: &mut Vec<DocElement>) {
121 for (index, element) in content.iter().enumerate() {
122 path.push(index);
123 emit(element, path, out);
124 path.pop();
125 }
126}
127
128fn emit(element: &StructuralElement, path: &mut Vec<usize>, out: &mut Vec<DocElement>) {
130 let base = |kind: ElementKind| DocElement {
131 start_index: element.start_index(),
132 end_index: element.end_index(),
133 kind,
134 path: path.clone(),
135 style: None,
136 list_id: None,
137 rows: None,
138 columns: None,
139 text: String::new(),
140 };
141
142 if let Some(paragraph) = &element.paragraph {
143 out.push(DocElement {
144 style: paragraph.named_style_type().map(ToString::to_string),
145 list_id: paragraph.bullet.as_ref().and_then(|b| b.list_id.clone()),
146 text: strip_paragraph_terminator(¶graph.text()),
147 ..base(ElementKind::Paragraph)
148 });
149 return;
150 }
151
152 if let Some(table) = &element.table {
153 out.push(DocElement {
154 rows: table.rows,
155 columns: table.columns,
156 ..base(ElementKind::Table)
157 });
158 for (row_index, row) in table.table_rows.iter().enumerate() {
159 path.push(row_index);
160 for (cell_index, cell) in row.table_cells.iter().enumerate() {
161 path.push(cell_index);
162 walk(&cell.content, path, out);
163 path.pop();
164 }
165 path.pop();
166 }
167 return;
168 }
169
170 if let Some(toc) = &element.table_of_contents {
171 out.push(base(ElementKind::TableOfContents));
172 walk(&toc.content, path, out);
173 return;
174 }
175
176 if element.section_break.is_some() {
177 out.push(base(ElementKind::SectionBreak));
178 }
179 }
183
184fn strip_paragraph_terminator(text: &str) -> String {
190 text.strip_suffix('\n').unwrap_or(text).to_string()
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct Heading {
196 pub style: String,
198 pub start_index: i64,
200 pub end_index: i64,
202 pub text: String,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
208pub struct TabOutline {
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub tab_id: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub title: Option<String>,
215 pub nesting_level: i64,
217 pub characters: i64,
220 pub paragraphs: usize,
222 pub tables: usize,
224 pub section_breaks: usize,
226 pub tables_of_contents: usize,
228 pub headings: Vec<Heading>,
230}
231
232fn is_heading_style(style: &str) -> bool {
238 style.starts_with("HEADING_") || style == "TITLE" || style == "SUBTITLE"
239}
240
241#[must_use]
251pub fn outline(tab: &ResolvedTab<'_>) -> TabOutline {
252 let mut out = TabOutline {
253 tab_id: tab.tab_id.map(ToString::to_string),
254 title: tab.title.map(ToString::to_string),
255 nesting_level: tab.nesting_level,
256 ..TabOutline::default()
257 };
258 let Some(body) = tab.body else {
259 return out;
260 };
261 for element in flatten(body) {
262 out.characters = out.characters.max(element.end_index);
263 match element.kind {
264 ElementKind::Paragraph => {
265 out.paragraphs += 1;
266 if let Some(style) = element.style.as_deref().filter(|s| is_heading_style(s)) {
267 out.headings.push(Heading {
268 style: style.to_string(),
269 start_index: element.start_index,
270 end_index: element.end_index,
271 text: element.text.clone(),
272 });
273 }
274 }
275 ElementKind::Table => out.tables += 1,
276 ElementKind::SectionBreak => out.section_breaks += 1,
277 ElementKind::TableOfContents => out.tables_of_contents += 1,
278 }
279 }
280 out
281}
282
283#[cfg(test)]
284#[allow(clippy::unwrap_used, clippy::expect_used)]
285mod tests {
286 use super::*;
287 use crate::drive::docs::types::Document;
288
289 fn body(json: serde_json::Value) -> Body {
290 serde_json::from_value(json).unwrap()
291 }
292
293 fn paragraph(start: i64, end: i64, text: &str, style: &str) -> serde_json::Value {
294 serde_json::json!({
295 "startIndex": start, "endIndex": end,
296 "paragraph": {
297 "elements": [{"textRun": {"content": text}}],
298 "paragraphStyle": {"namedStyleType": style},
299 },
300 })
301 }
302
303 #[test]
304 fn flatten_of_an_empty_body_is_empty() {
305 assert!(flatten(&body(serde_json::json!({"content": []}))).is_empty());
306 }
307
308 #[test]
309 fn flatten_emits_elements_in_index_order() {
310 let elements = flatten(&body(serde_json::json!({"content": [
311 {"endIndex": 1, "sectionBreak": {}},
312 paragraph(1, 10, "Overview\n", "HEADING_1"),
313 paragraph(10, 20, "Body text\n", "NORMAL_TEXT"),
314 ]})));
315 assert_eq!(
316 elements.iter().map(|e| e.start_index).collect::<Vec<_>>(),
317 vec![0, 1, 10]
318 );
319 assert_eq!(elements[0].kind, ElementKind::SectionBreak);
320 assert_eq!(elements[1].kind, ElementKind::Paragraph);
321 }
322
323 #[test]
327 fn flatten_content_agrees_with_flatten_on_the_same_content() {
328 let via_body = flatten(&body(serde_json::json!({
329 "content": [paragraph(0, 9, "Header\n", "NORMAL_TEXT")],
330 })));
331 let structural: Vec<StructuralElement> = serde_json::from_value(serde_json::Value::Array(
332 vec![paragraph(0, 9, "Header\n", "NORMAL_TEXT")],
333 ))
334 .unwrap();
335 let via_content = flatten_content(&structural);
336 assert_eq!(via_body, via_content);
337 assert_eq!(via_content[0].text, "Header");
338 }
339
340 #[test]
343 fn flatten_strips_the_terminator_from_text_but_not_from_end_index() {
344 let elements = flatten(&body(serde_json::json!({
345 "content": [paragraph(1, 10, "Overview\n", "HEADING_1")],
346 })));
347 assert_eq!(elements[0].text, "Overview");
348 assert_eq!(elements[0].end_index, 10);
349 }
350
351 #[test]
354 fn flatten_strips_exactly_one_newline_not_a_trim_end() {
355 let elements = flatten(&body(serde_json::json!({
356 "content": [paragraph(1, 10, "text \u{b}\n", "NORMAL_TEXT")],
357 })));
358 assert_eq!(elements[0].text, "text \u{b}");
359 }
360
361 #[test]
362 fn flatten_joins_multiple_text_runs_in_one_paragraph() {
363 let elements = flatten(&body(serde_json::json!({"content": [{
364 "startIndex": 1, "endIndex": 20,
365 "paragraph": {"elements": [
366 {"textRun": {"content": "Hello "}},
367 {"textRun": {"content": "bold"}},
368 {"textRun": {"content": " world\n"}},
369 ]},
370 }]})));
371 assert_eq!(elements[0].text, "Hello bold world");
372 }
373
374 #[test]
377 fn flatten_counts_an_inline_object_as_index_space_with_no_text() {
378 let elements = flatten(&body(serde_json::json!({"content": [{
379 "startIndex": 1, "endIndex": 5,
380 "paragraph": {"elements": [
381 {"textRun": {"content": "a"}},
382 {"inlineObjectElement": {"inlineObjectId": "kix.1"}},
383 {"textRun": {"content": "b\n"}},
384 ]},
385 }]})));
386 assert_eq!(elements[0].text, "ab");
387 assert_eq!(elements[0].end_index - elements[0].start_index, 4);
388 }
389
390 #[test]
393 fn flatten_emits_the_table_container_before_its_cell_paragraphs() {
394 let elements = flatten(&body(serde_json::json!({"content": [{
395 "startIndex": 1, "endIndex": 40,
396 "table": {
397 "rows": 1, "columns": 2,
398 "tableRows": [{"tableCells": [
399 {"content": [paragraph(3, 10, "Name\n", "NORMAL_TEXT")]},
400 {"content": [paragraph(10, 20, "Value\n", "NORMAL_TEXT")]},
401 ]}],
402 },
403 }]})));
404 assert_eq!(elements.len(), 3);
405 assert_eq!(elements[0].kind, ElementKind::Table);
406 assert_eq!(elements[0].rows, Some(1));
407 assert_eq!(elements[0].columns, Some(2));
408 assert_eq!(elements[1].text, "Name");
409 assert_eq!(elements[2].text, "Value");
410 }
411
412 #[test]
413 fn flatten_records_a_path_for_a_cell_paragraph() {
414 let elements = flatten(&body(serde_json::json!({"content": [{
415 "startIndex": 1, "endIndex": 40,
416 "table": {"rows": 1, "columns": 2, "tableRows": [{"tableCells": [
417 {"content": [paragraph(3, 10, "a\n", "NORMAL_TEXT")]},
418 {"content": [paragraph(10, 20, "b\n", "NORMAL_TEXT")]},
419 ]}]},
420 }]})));
421 assert_eq!(elements[0].path, vec![0]);
422 assert_eq!(elements[0].depth(), 0);
423 assert_eq!(elements[2].path, vec![0, 0, 1, 0]);
425 assert_eq!(elements[2].depth(), 3);
426 }
427
428 #[test]
429 fn flatten_recurses_into_a_nested_table() {
430 let elements = flatten(&body(serde_json::json!({"content": [{
431 "startIndex": 1, "endIndex": 60,
432 "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{"content": [{
433 "startIndex": 3, "endIndex": 50,
434 "table": {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [{
435 "content": [paragraph(5, 12, "deep\n", "NORMAL_TEXT")],
436 }]}]},
437 }]}]}]},
438 }]})));
439 let kinds: Vec<_> = elements.iter().map(|e| e.kind).collect();
440 assert_eq!(
441 kinds,
442 vec![
443 ElementKind::Table,
444 ElementKind::Table,
445 ElementKind::Paragraph
446 ]
447 );
448 assert_eq!(elements[2].text, "deep");
449 assert_eq!(elements[2].path, vec![0, 0, 0, 0, 0, 0, 0]);
452 assert_eq!(elements[2].depth(), 6);
453 }
454
455 #[test]
456 fn flatten_records_a_list_id_for_a_bullet() {
457 let elements = flatten(&body(serde_json::json!({"content": [{
458 "startIndex": 1, "endIndex": 8,
459 "paragraph": {
460 "elements": [{"textRun": {"content": "item\n"}}],
461 "bullet": {"listId": "kix.l1", "nestingLevel": 0},
462 },
463 }]})));
464 assert_eq!(elements[0].list_id.as_deref(), Some("kix.l1"));
465 }
466
467 #[test]
468 fn flatten_walks_a_table_of_contents() {
469 let elements = flatten(&body(serde_json::json!({"content": [{
470 "startIndex": 1, "endIndex": 30,
471 "tableOfContents": {"content": [paragraph(2, 12, "Overview\n", "NORMAL_TEXT")]},
472 }]})));
473 assert_eq!(elements[0].kind, ElementKind::TableOfContents);
474 assert_eq!(elements[1].text, "Overview");
475 }
476
477 #[test]
481 fn indices_are_utf16_code_units_not_chars() {
482 let elements = flatten(&body(serde_json::json!({
483 "content": [paragraph(1, 4, "\u{1F600}\n", "NORMAL_TEXT")],
486 })));
487 assert_eq!(elements[0].end_index - elements[0].start_index, 3);
488 assert_eq!(elements[0].text.chars().count(), 1);
489 assert_eq!(elements[0].text.encode_utf16().count(), 2);
490 assert_eq!(elements[0].text.len(), 4);
491 }
492
493 fn outline_of(json: serde_json::Value) -> TabOutline {
494 let doc: Document = serde_json::from_value(json).unwrap();
495 let tabs = doc.resolved_tabs();
496 outline(&tabs[0])
497 }
498
499 #[test]
500 fn outline_counts_each_element_kind() {
501 let counts = outline_of(serde_json::json!({"body": {"content": [
502 {"endIndex": 1, "sectionBreak": {}},
503 paragraph(1, 10, "A\n", "HEADING_1"),
504 paragraph(10, 20, "b\n", "NORMAL_TEXT"),
505 {"startIndex": 20, "endIndex": 40, "table": {
506 "rows": 1, "columns": 1,
507 "tableRows": [{"tableCells": [{"content": [
508 paragraph(22, 30, "c\n", "NORMAL_TEXT")]}]}],
509 }},
510 ]}}));
511 assert_eq!(counts.section_breaks, 1);
512 assert_eq!(counts.tables, 1);
513 assert_eq!(counts.paragraphs, 3);
515 }
516
517 #[test]
518 fn outline_lists_headings_in_document_order_with_their_index_ranges() {
519 let counts = outline_of(serde_json::json!({"body": {"content": [
520 paragraph(1, 10, "Overview\n", "HEADING_1"),
521 paragraph(10, 20, "prose\n", "NORMAL_TEXT"),
522 paragraph(20, 30, "Goals\n", "HEADING_2"),
523 ]}}));
524 assert_eq!(counts.headings.len(), 2);
525 assert_eq!(counts.headings[0].text, "Overview");
526 assert_eq!(counts.headings[0].style, "HEADING_1");
527 assert_eq!(counts.headings[0].start_index, 1);
528 assert_eq!(counts.headings[1].text, "Goals");
529 assert_eq!(counts.headings[1].end_index, 30);
530 }
531
532 #[test]
535 fn outline_treats_title_and_subtitle_as_headings() {
536 let counts = outline_of(serde_json::json!({"body": {"content": [
537 paragraph(1, 10, "The Title\n", "TITLE"),
538 paragraph(10, 20, "A subtitle\n", "SUBTITLE"),
539 paragraph(20, 30, "prose\n", "NORMAL_TEXT"),
540 ]}}));
541 let styles: Vec<_> = counts.headings.iter().map(|h| h.style.as_str()).collect();
542 assert_eq!(styles, vec!["TITLE", "SUBTITLE"]);
543 }
544
545 #[test]
546 fn outline_characters_is_the_highest_end_index() {
547 let counts = outline_of(serde_json::json!({"body": {"content": [
548 paragraph(1, 10, "a\n", "NORMAL_TEXT"),
549 paragraph(10, 137, "b\n", "NORMAL_TEXT"),
550 ]}}));
551 assert_eq!(counts.characters, 137);
552 }
553
554 #[test]
555 fn outline_of_an_empty_tab_is_all_zeroes() {
556 let counts = outline_of(serde_json::json!({"body": {"content": []}}));
557 assert_eq!(counts.characters, 0);
558 assert_eq!(counts.paragraphs, 0);
559 assert!(counts.headings.is_empty());
560 }
561
562 #[test]
563 fn outline_carries_the_tabs_identity() {
564 let doc: Document = serde_json::from_value(serde_json::json!({"tabs": [{
565 "tabProperties": {"tabId": "t.0", "title": "Overview", "nestingLevel": 0},
566 "documentTab": {"body": {"content": [paragraph(1, 10, "a\n", "NORMAL_TEXT")]}},
567 }]}))
568 .unwrap();
569 let counts = outline(&doc.resolved_tabs()[0]);
570 assert_eq!(counts.tab_id.as_deref(), Some("t.0"));
571 assert_eq!(counts.title.as_deref(), Some("Overview"));
572 assert_eq!(counts.paragraphs, 1);
573 }
574
575 #[test]
576 fn element_kind_labels_match_the_serde_wire_form() {
577 for kind in [
578 ElementKind::Paragraph,
579 ElementKind::Table,
580 ElementKind::SectionBreak,
581 ElementKind::TableOfContents,
582 ] {
583 let wire = serde_json::to_value(kind).unwrap();
584 assert_eq!(wire.as_str().unwrap(), kind.as_str());
585 }
586 }
587}