1mod list_labels;
2
3use std::collections::HashSet;
4
5use list_labels::{ListDefinitions, StoryLists};
6
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::model::*;
13use crate::package::OoxmlPackage;
14use crate::revisions::{ReviewedDocument, RevisionCollector};
15
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum BlockType {
19 Text,
20 Heading,
21 SectionHeading,
22 Figure,
23 Table,
24 TableHead,
25 TableCell,
26 KeyValue,
27 PageNumber,
28 Barcode,
29 Formula,
30 Header,
31 Footer,
32 Watermark,
33 Legend,
34}
35
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct SourceRef {
39 pub document_id: String,
40 pub node_id: String,
41 pub part_name: String,
42 pub story: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub start: Option<usize>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub end: Option<usize>,
47 pub offset_encoding: String,
48}
49
50#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct ExtractDocumentBlocksOptions {
53 #[serde(default)]
54 pub include_words: bool,
55}
56
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct DocumentWord {
60 pub object: String,
61 pub id: String,
62 pub block_id: String,
63 pub content: String,
64 pub source_refs: Vec<SourceRef>,
65}
66
67#[derive(Clone, Debug, PartialEq, Serialize)]
68#[serde(tag = "type", rename_all = "snake_case")]
69pub enum ParagraphInline {
70 Text {
71 start: usize,
72 end: usize,
73 text: String,
74 style: Option<SharedTextStyle>,
75 link: Option<String>,
76 #[serde(rename = "noteReference")]
77 note_reference: Option<NoteReference>,
78 },
79 FormField {
80 #[serde(rename = "blockId")]
81 block_id: String,
82 start: usize,
83 end: usize,
84 text: String,
85 },
86 Block {
87 #[serde(rename = "blockId")]
88 block_id: String,
89 offset: usize,
90 },
91}
92
93struct FormFieldSemanticDetails<'a>(&'a FormFieldRunNode);
94
95impl Serialize for FormFieldSemanticDetails<'_> {
96 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
97 where
98 S: serde::Serializer,
99 {
100 use serde::ser::SerializeMap;
101 let field = self.0;
102 let mut map = serializer.serialize_map(None)?;
103 map.serialize_entry("type", &field.r#type)?;
104 map.serialize_entry("fieldType", &field.field_type)?;
105 macro_rules! optional {
106 ($name:literal, $value:expr) => {
107 if let Some(value) = $value.as_ref() {
108 map.serialize_entry($name, value)?;
109 }
110 };
111 }
112 optional!("sourceKind", field.source_kind);
113 optional!("id", field.id);
114 optional!("tag", field.tag);
115 optional!("title", field.title);
116 optional!("placeholder", field.placeholder);
117 optional!("checked", field.checked);
118 optional!("value", field.value);
119 optional!("options", field.options);
120 optional!("widget", field.widget);
121 optional!("checkedSymbol", field.checked_symbol);
122 optional!("uncheckedSymbol", field.unchecked_symbol);
123 optional!("style", field.style);
124 optional!("link", field.link);
125 optional!("sourceXml", field.source_xml);
126 map.end()
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct ParagraphDetails {
133 pub r#type: String,
134 pub kind: String,
135 pub style: Option<SharedParagraphStyle>,
136 pub style_id: String,
137 pub heading_level: Option<HeadingLevel>,
138 pub numbering: Option<ParagraphNumbering>,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub list_label: Option<String>,
142 pub field_instructions: Vec<String>,
143 pub inlines: Vec<ParagraphInline>,
144}
145
146#[derive(Clone, Debug, PartialEq, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct TableCellDetails {
149 pub r#type: String,
150 pub row_index: usize,
151 pub column_index: i64,
152 pub cell_index: usize,
153 pub column_span: i64,
154 pub row_span: i64,
155 pub is_header: bool,
156 pub style: Option<TableCellStyle>,
157}
158
159#[derive(Clone, Debug, Serialize)]
161#[serde(untagged)]
162pub enum BlockDetails {
163 Paragraph(Box<ParagraphDetails>),
164 TableCell(Box<TableCellDetails>),
165 Raw(Value),
166}
167
168impl BlockDetails {
169 pub fn to_json_value(&self) -> Value {
170 match self {
171 Self::Paragraph(details) => serde_json::to_value(details)
172 .expect("paragraph details contain JSON-compatible values"),
173 Self::TableCell(details) => serde_json::to_value(details)
174 .expect("table cell details contain JSON-compatible values"),
175 Self::Raw(value) => value.clone(),
176 }
177 }
178}
179
180impl PartialEq for BlockDetails {
181 fn eq(&self, other: &Self) -> bool {
182 match (self, other) {
183 (Self::Raw(left), Self::Raw(right)) => left == right,
184 _ => self.to_json_value() == other.to_json_value(),
185 }
186 }
187}
188
189impl<'de> Deserialize<'de> for BlockDetails {
190 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
191 Value::deserialize(deserializer).map(Self::Raw)
192 }
193}
194
195impl From<Value> for BlockDetails {
196 fn from(value: Value) -> Self {
197 Self::Raw(value)
198 }
199}
200
201impl From<ParagraphDetails> for BlockDetails {
202 fn from(details: ParagraphDetails) -> Self {
203 Self::Paragraph(Box::new(details))
204 }
205}
206
207impl From<TableCellDetails> for BlockDetails {
208 fn from(details: TableCellDetails) -> Self {
209 Self::TableCell(Box::new(details))
210 }
211}
212
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct DocumentBlock {
216 pub object: String,
217 pub id: String,
218 pub r#type: BlockType,
219 pub content: String,
220 pub details: BlockDetails,
221 pub source_refs: Vec<SourceRef>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub parent_block_id: Option<String>,
224 pub children: Vec<DocumentBlock>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub words: Option<Vec<DocumentWord>>,
227}
228
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct StructuredDocument {
232 pub schema_version: u32,
233 pub parser_version: String,
234 pub document_id: String,
235 pub blocks: Vec<DocumentBlock>,
236 pub warnings: Vec<String>,
237 pub geometry_status: String,
238}
239
240pub fn package_identity(package: &OoxmlPackage) -> String {
241 let mut hash = Sha256::new();
242 let mut parts: Vec<_> = package.parts.iter().collect();
243 parts.sort_by_key(|(name, _)| *name);
244 for (name, part) in parts {
245 hash.update((name.len() as u64).to_le_bytes());
246 hash.update(name.as_bytes());
247 hash.update((part.content.len() as u64).to_le_bytes());
248 hash.update(part.content.as_bytes());
249 }
250 let mut assets: Vec<_> = package.binary_assets.iter().collect();
251 assets.sort_by_key(|(name, _)| *name);
252 for (name, bytes) in assets {
253 hash.update((name.len() as u64).to_le_bytes());
254 hash.update(name.as_bytes());
255 hash.update((bytes.len() as u64).to_le_bytes());
256 hash.update(bytes);
257 }
258 format!("{:x}", hash.finalize())
259}
260
261fn image_url(image: &ImageRunNode) -> Option<String> {
262 if let Some(src) = image.src.as_ref().filter(|src| !src.trim().is_empty()) {
263 return Some(src.clone());
264 }
265 let data = image.data.as_deref().filter(|data| !data.is_empty())?;
266 let content_type = image
267 .content_type
268 .as_deref()
269 .filter(|content_type| !content_type.trim().is_empty())
270 .unwrap_or("application/octet-stream");
271 let capacity = 13usize
272 .checked_add(content_type.len())?
273 .checked_add(base64::encoded_len(data.len(), true)?)?;
274 let mut url = String::with_capacity(capacity);
275 url.push_str("data:");
276 url.push_str(content_type);
277 url.push_str(";base64,");
278 crate::parse::append_base64(data, &mut url);
279 Some(url)
280}
281
282#[derive(Serialize)]
283#[serde(rename_all = "camelCase")]
284struct ImagePresentation {
285 left: f64,
286 top: f64,
287 right: f64,
288 bottom: f64,
289 aspect_ratio: f64,
290 opacity: f64,
291}
292
293fn presented_image_url(image: &ImageRunNode, source: &str) -> Option<(String, ImagePresentation)> {
294 let opacity = image
295 .css_opacity
296 .filter(|v| v.is_finite())
297 .unwrap_or(1.0)
298 .clamp(0.0, 1.0);
299 if image.crop.is_none() && opacity == 1.0 {
300 return None;
301 }
302 let mut width = image.width_px.filter(|v| v.is_finite() && *v > 0.0)?;
303 let mut height = image.height_px.filter(|v| v.is_finite() && *v > 0.0)?;
304 let mut image_width = width;
305 let mut image_height = height;
306 let mut x = 0.0;
307 let mut y = 0.0;
308 if let Some(crop) = &image.crop {
309 let edge = |v: Option<f64>| v.unwrap_or(0.0).clamp(0.0, 1.0);
310 let left = edge(crop.left_fraction);
311 let top = edge(crop.top_fraction);
312 let across = 1.0 - left - edge(crop.right_fraction);
313 let down = 1.0 - top - edge(crop.bottom_fraction);
314 if across > 0.0 && down > 0.0 && (across < 1.0 || down < 1.0) {
315 width = width.round().max(1.0);
317 height = height.round().max(1.0);
318 image_width = (width / across).round().max(1.0);
319 image_height = (height / down).round().max(1.0);
320 x = -(image_width * left).round();
321 y = -(image_height * top).round();
322 }
323 }
324 if (width == image_width && height == image_height && opacity == 1.0)
325 || !image_width.is_finite()
326 || !image_height.is_finite()
327 {
328 return None;
329 }
330 let (header, _) = source.split_once(',')?;
331 let mime = header.split(';').next()?.strip_prefix("data:image/")?;
332 if !matches!(mime, "png" | "jpeg" | "gif" | "webp" | "bmp" | "svg+xml") {
333 return None;
334 }
335 let source = crate::parse::escape_xml_text(source);
336 let presentation = ImagePresentation {
337 left: -x / image_width,
338 top: -y / image_height,
339 right: ((-x + width) / image_width).min(1.0),
340 bottom: ((-y + height) / image_height).min(1.0),
341 aspect_ratio: width / height,
342 opacity,
343 };
344 if !presentation.aspect_ratio.is_finite() {
345 return None;
346 }
347 let svg = crate::parse::svg_data_uri(&format!(
348 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{width}" height="{height}" viewBox="0 0 {width} {height}"><image x="{x}" y="{y}" width="{image_width}" height="{image_height}" preserveAspectRatio="none" opacity="{opacity}" xlink:href="{source}"/></svg>"#
349 ));
350 Some((svg, presentation))
351}
352
353fn source_part(model: &DocModel, kind: &str) -> String {
354 model
355 .metadata
356 .source_part_names
357 .as_ref()
358 .and_then(|names| names.get(kind))
359 .cloned()
360 .unwrap_or_else(|| format!("word/{kind}.xml"))
361}
362
363fn indexed_path<const N: usize>(prefix: &str, components: [(&str, usize); N]) -> String {
364 let mut buffers: [itoa::Buffer; N] = std::array::from_fn(|_| itoa::Buffer::new());
365 let mut numbers = [""; N];
366 let mut capacity = prefix.len();
367 for ((buffer, (separator, number)), rendered) in buffers.iter_mut().zip(components).zip(numbers.iter_mut()) {
368 *rendered = buffer.format(number);
369 capacity += separator.len() + rendered.len();
370 }
371 let mut result = String::with_capacity(capacity);
372 result.push_str(prefix);
373 for ((separator, _), number) in components.into_iter().zip(numbers) {
374 result.push_str(separator);
375 result.push_str(number);
376 }
377 result
378}
379
380pub fn assign_source_ids(model: &mut DocModel, document_id: &str) {
381 fn paragraph(node: &mut ParagraphNode, id: String) {
382 for (index, child) in node.children.iter_mut().enumerate() {
383 if let ParagraphChildNode::Image(image) = child {
384 let image_id = indexed_path(&id, [("/child/", index)]);
385 if let Some(content) = &mut image.content_nodes {
386 nodes(content, &format!("{image_id}/content"));
387 }
388 image.block_id = Some(image_id);
389 }
390 }
391 node.block_id = Some(id);
392 }
393 fn table(node: &mut TableNode, id: String) {
394 for (ri, row) in node.rows.iter_mut().enumerate() {
395 for (ci, cell) in row.cells.iter_mut().enumerate() {
396 for (ni, child) in cell.nodes.iter_mut().enumerate() {
397 let id = indexed_path(&id, [("/row/", ri), ("/cell/", ci), ("/", ni)]);
398 match child {
399 TableCellContentNode::Paragraph(p) => paragraph(p, id),
400 TableCellContentNode::Table(t) => table(t, id),
401 }
402 }
403 }
404 }
405 node.block_id = Some(id);
406 }
407 fn nodes(content: &mut [DocNode], root: &str) {
408 for (index, node) in content.iter_mut().enumerate() {
409 let id = indexed_path(root, [("/", index)]);
410 match node {
411 DocNode::Paragraph(p) => paragraph(p, id),
412 DocNode::Table(t) => table(t, id),
413 }
414 }
415 }
416 model.metadata.document_id = Some(document_id.to_string());
417 let root = format!("docx:{document_id}");
418 let main_part = source_part(model, "document");
419 let footnote_part = source_part(model, "footnotes");
420 let endnote_part = source_part(model, "endnotes");
421 let comment_part = source_part(model, "comments");
422 nodes(&mut model.nodes, &format!("{root}/{main_part}/body"));
423 for header in &mut model.metadata.header_sections {
424 nodes(&mut header.nodes, &format!("{root}/{}", header.part_name));
425 }
426 for footer in &mut model.metadata.footer_sections {
427 nodes(&mut footer.nodes, &format!("{root}/{}", footer.part_name));
428 }
429 for section in model.metadata.sections.iter_mut().flatten() {
430 for header in &mut section.header_sections {
431 nodes(&mut header.nodes, &format!("{root}/{}", header.part_name));
432 }
433 for footer in &mut section.footer_sections {
434 nodes(&mut footer.nodes, &format!("{root}/{}", footer.part_name));
435 }
436 }
437 for (kind, part, notes) in [
438 ("footnote", footnote_part, &mut model.metadata.footnotes),
439 ("endnote", endnote_part, &mut model.metadata.endnotes),
440 ] {
441 for note in notes.iter_mut().flatten() {
442 if let Some(content) = &mut note.nodes {
443 nodes(content, &format!("{root}/{part}/{kind}/{}", note.id));
444 }
445 }
446 }
447 for comment in model.metadata.comments.iter_mut().flatten() {
448 if let Some(content) = &mut comment.nodes {
449 nodes(
450 content,
451 &format!("{root}/{comment_part}/comment/{}", comment.id),
452 );
453 }
454 }
455}
456
457fn joined_block_content(blocks: &[DocumentBlock], separator: &str) -> String {
458 let capacity = blocks.iter().map(|block| block.content.len()).sum::<usize>()
459 + separator.len() * blocks.len().saturating_sub(1);
460 let mut content = String::with_capacity(capacity);
461 for (index, block) in blocks.iter().enumerate() {
462 if index > 0 {
463 content.push_str(separator);
464 }
465 content.push_str(&block.content);
466 }
467 content
468}
469
470fn paragraph_field_instructions(xml: &str) -> Vec<String> {
471 if crate::xml::find_ignore_ascii_case(xml, 0, "<w:instrText").is_none() {
472 return Vec::new();
473 }
474 crate::xml::extract_balanced_tag_ranges(xml, "w:instrText")
475 .into_iter()
476 .map(|range| {
477 xml[range.start..range.end]
478 .split_once('>')
479 .map(|(_, value)| {
480 crate::xml::decode_xml_entities(value.split("</").next().unwrap_or(""))
481 })
482 .unwrap_or_default()
483 })
484 .collect()
485}
486
487struct Extractor<'a> {
488 lists: ListDefinitions<'a>,
489 document_id: String,
490 part_name: String,
491 story: String,
492 include_words: bool,
493 review: Option<RevisionCollector>,
494}
495
496impl Extractor<'_> {
497 fn block(
498 &self,
499 id: String,
500 kind: BlockType,
501 content: String,
502 details: impl Into<BlockDetails>,
503 children: Vec<DocumentBlock>,
504 parent: Option<&str>,
505 ) -> DocumentBlock {
506 DocumentBlock {
507 object: "block".into(),
508 id: id.clone(),
509 r#type: kind,
510 content,
511 details: details.into(),
512 source_refs: vec![SourceRef {
513 document_id: self.document_id.clone(),
514 node_id: id,
515 part_name: self.part_name.clone(),
516 story: self.story.clone(),
517 start: None,
518 end: None,
519 offset_encoding: "utf-16".into(),
520 }],
521 parent_block_id: parent.map(str::to_string),
522 children,
523 words: None,
524 }
525 }
526
527 fn words(&self, block_id: &str, content: &str) -> Vec<DocumentWord> {
528 let mut offset = 0;
529 content
530 .split_word_bounds()
531 .filter_map(|word| {
532 let start = offset;
533 offset += word.encode_utf16().count();
534 if word.chars().all(char::is_whitespace) {
535 return None;
536 }
537 Some(DocumentWord {
538 object: "word".into(),
539 id: indexed_path(block_id, [("/word/", start), ("-", offset)]),
540 block_id: block_id.into(),
541 content: word.into(),
542 source_refs: vec![SourceRef {
543 document_id: self.document_id.clone(),
544 node_id: block_id.into(),
545 part_name: self.part_name.clone(),
546 story: self.story.clone(),
547 start: Some(start),
548 end: Some(offset),
549 offset_encoding: "utf-16".into(),
550 }],
551 })
552 })
553 .collect()
554 }
555
556 fn story_block(&mut self, nodes: &[DocNode], kind: BlockType) -> DocumentBlock {
557 let id = format!("docx:{}/{}/story", self.document_id, self.part_name);
558 let children = self.story_nodes(nodes, Some(&id));
559 let content = joined_block_content(&children, "\n");
560 let refs = children
561 .iter()
562 .flat_map(|b| b.source_refs.clone())
563 .collect();
564 let mut block = self.block(id, kind, content, json!({"kind":"story"}), children, None);
565 block.source_refs = refs;
566 block
567 }
568
569 fn story_nodes(&mut self, nodes: &[DocNode], parent: Option<&str>) -> Vec<DocumentBlock> {
570 let moves = self.review.as_mut().map(|r| std::mem::take(&mut r.moves));
571 let blocks = self.nodes(nodes, parent, &mut StoryLists::default());
572 if let (Some(review), Some(moves)) = (&mut self.review, moves) {
573 review.moves = moves;
574 }
575 blocks
576 }
577
578 fn nodes(
579 &mut self,
580 nodes: &[DocNode],
581 parent: Option<&str>,
582 lists: &mut StoryLists,
583 ) -> Vec<DocumentBlock> {
584 nodes
585 .iter()
586 .enumerate()
587 .map(|(index, node)| {
588 let fallback = indexed_path(parent.unwrap_or("document"), [("/", index)]);
589 match node {
590 DocNode::Paragraph(p) => self.paragraph(p, &fallback, parent, lists),
591 DocNode::Table(t) => self.table(t, &fallback, parent, lists),
592 }
593 })
594 .collect()
595 }
596
597 fn paragraph(
598 &mut self,
599 p: &ParagraphNode,
600 fallback: &str,
601 parent: Option<&str>,
602 lists: &mut StoryLists,
603 ) -> DocumentBlock {
604 let id = p.block_id.as_deref().unwrap_or(fallback);
605 let style = p.style.as_ref();
606 let style_id = style.and_then(|s| s.style_id.as_deref()).unwrap_or("");
607 let field_instructions = p
608 .source_xml
609 .as_deref()
610 .map(paragraph_field_instructions)
611 .unwrap_or_default();
612 let joined_instructions = field_instructions.join("").to_uppercase();
613 let instruction_kind = joined_instructions.split_whitespace().next().unwrap_or("");
614 let kind = if matches!(instruction_kind, "PAGE" | "NUMPAGES" | "SECTIONPAGES") {
615 BlockType::PageNumber
616 } else if matches!(instruction_kind, "DISPLAYBARCODE" | "MERGEBARCODE") {
617 BlockType::Barcode
618 } else if style.and_then(|s| s.heading_level).is_some() {
619 BlockType::Heading
620 } else if style_id.eq_ignore_ascii_case("title")
621 || style_id.eq_ignore_ascii_case("subtitle")
622 {
623 BlockType::SectionHeading
624 } else if style_id.eq_ignore_ascii_case("caption") {
625 BlockType::Legend
626 } else {
627 BlockType::Text
628 };
629 let mut content = String::new();
630 let mut inline = Vec::with_capacity(p.children.len());
631 let mut children = Vec::new();
632 let mut text_offset = 0;
633 for (index, child) in p.children.iter().enumerate() {
634 match child {
635 ParagraphChildNode::Text(run) => {
636 let start = text_offset;
637 text_offset += run.text.encode_utf16().count();
638 content.push_str(&run.text);
639 inline.push(ParagraphInline::Text {
640 start,
641 end: text_offset,
642 text: run.text.clone(),
643 style: run.style.clone(),
644 link: run.link.clone(),
645 note_reference: run.note_reference.clone(),
646 });
647 }
648 ParagraphChildNode::FormField(field) => {
649 let child_id = indexed_path(&id, [("/child/", index)]);
650 let value = field.value.clone().unwrap_or_else(|| {
651 field
652 .checked
653 .map(|checked| if checked { "☒" } else { "☐" }.to_string())
654 .unwrap_or_default()
655 });
656 let start = text_offset;
657 text_offset += value.encode_utf16().count();
658 content.push_str(&value);
659 inline.push(ParagraphInline::FormField {
660 block_id: child_id.clone(),
661 start,
662 end: text_offset,
663 text: value.clone(),
664 });
665 let details = if field.source_preview.is_some() {
666 json!({"type":"key_value_details", "kind":"form_field", "field":FormFieldSemanticDetails(field)})
667 } else {
668 json!({"type":"key_value_details", "kind":"form_field", "field":field})
669 };
670 children.push(self.block(
671 child_id,
672 BlockType::KeyValue,
673 value,
674 details,
675 Vec::new(),
676 Some(id),
677 ));
678 }
679 ParagraphChildNode::Image(image) => {
680 let child_id = indexed_path(&id, [("/child/", index)]);
681 let kind = match image.semantic_kind.as_deref() {
682 Some("formula") => BlockType::Formula,
683 Some("watermark") => BlockType::Watermark,
684 Some("barcode") => BlockType::Barcode,
685 _ => BlockType::Figure,
686 };
687 let image_id = image.block_id.as_deref().unwrap_or(&child_id);
688 inline.push(ParagraphInline::Block {
689 block_id: image_id.to_owned(),
690 offset: text_offset,
691 });
692 let nested = image
693 .content_nodes
694 .as_ref()
695 .map(|nodes| self.story_nodes(nodes, Some(image_id)))
696 .unwrap_or_default();
697 let text = if nested.is_empty() {
698 image
699 .text_box_text
700 .clone()
701 .or_else(|| image.alt.clone())
702 .unwrap_or_default()
703 } else {
704 nested
705 .iter()
706 .map(|b| b.content.as_str())
707 .collect::<Vec<_>>()
708 .join("\n")
709 };
710 let mut details = json!({"kind":image.semantic_kind.as_deref().unwrap_or(if image.synthetic_text_box == Some(true) {"text_box"} else {"image"}), "mathMl":image.math_ml, "asset":image.part_name, "alt":image.alt, "contentType":image.content_type, "width":image.width_px, "height":image.height_px, "floating":image.floating});
711 if let Some(part_name) = &image.chart_part_name {
712 details["chartPartName"] = json!(part_name);
713 }
714 if let Some(paint_bounds) = image.paint_bounds.as_ref() {
715 details
716 .as_object_mut()
717 .unwrap()
718 .insert("paintBounds".into(), json!(paint_bounds));
719 }
720 if let Some(url) = image_url(image) {
721 if let Some((rendered, presentation)) = presented_image_url(image, &url) {
722 details["sourceImageUrl"] = Value::String(url);
723 details["imageUrl"] = Value::String(rendered);
724 details["imagePresentation"] = json!(presentation);
725 details["contentType"] = json!("image/svg+xml");
726 } else {
727 details["imageUrl"] = Value::String(url);
728 }
729 }
730 children.push(self.block(
731 image_id.to_string(),
732 kind,
733 text,
734 details,
735 nested,
736 Some(id),
737 ));
738 }
739 }
740 }
741 let details = ParagraphDetails {
742 r#type: "text_details".into(),
743 kind: "paragraph".into(),
744 style: p.style.clone(),
745 style_id: style_id.into(),
746 heading_level: style.and_then(|s| s.heading_level),
747 numbering: style.and_then(|s| s.numbering.clone()),
748 list_label: lists.label(p, &self.lists),
749 field_instructions,
750 inlines: inline,
751 };
752 let mut block = self.block(id.to_string(), kind, content, details, children, parent);
753 if let (Some(review), Some(xml)) = (&mut self.review, &p.source_xml) {
754 review.paragraph(xml, &block);
755 }
756 if self.include_words {
757 block.words = Some(self.words(id, &block.content));
758 }
759 block
760 }
761
762 fn table(
763 &mut self,
764 table: &TableNode,
765 fallback: &str,
766 parent: Option<&str>,
767 lists: &mut StoryLists,
768 ) -> DocumentBlock {
769 let id = table.block_id.as_deref().unwrap_or(fallback);
770 if let (Some(review), Some(xml)) = (&mut self.review, &table.source_xml) {
771 let source = SourceRef {
772 document_id: self.document_id.clone(),
773 node_id: id.to_owned(),
774 part_name: self.part_name.clone(),
775 story: self.story.clone(),
776 start: None,
777 end: None,
778 offset_encoding: "utf-16".into(),
779 };
780 review.table(xml, &source, table);
781 }
782 let mut children = Vec::new();
783 let mut row_text = String::new();
784 let mut column_count = 0;
785 for (ri, row) in table.rows.iter().enumerate() {
786 let row_id = indexed_path(id, [("/row/", ri)]);
787 let is_header = row
788 .style
789 .as_ref()
790 .and_then(|s| s.is_header)
791 .unwrap_or(false);
792 let mut cells = Vec::with_capacity(row.cells.len());
793 let mut column_index = row.style.as_ref().and_then(|s| s.grid_before).unwrap_or(0);
794 for (ci, cell) in row.cells.iter().enumerate() {
795 let cell_id = indexed_path(&row_id, [("/cell/", ci)]);
796 lists.break_paragraph_flow();
797 let content = cell
798 .nodes
799 .iter()
800 .enumerate()
801 .map(|(ni, node)| match node {
802 TableCellContentNode::Paragraph(p) => {
803 self.paragraph(p, &indexed_path(&cell_id, [("/", ni)]), Some(&cell_id), lists)
804 }
805 TableCellContentNode::Table(t) => {
806 self.table(t, &indexed_path(&cell_id, [("/", ni)]), Some(&cell_id), lists)
807 }
808 })
809 .collect::<Vec<_>>();
810 let text = joined_block_content(&content, "\n");
811 cells.push(self.block(
812 cell_id,
813 BlockType::TableCell,
814 text,
815 TableCellDetails {
816 r#type: "table_cell_details".into(),
817 row_index: ri,
818 column_index,
819 cell_index: ci,
820 column_span: cell.style.as_ref().and_then(|s| s.grid_span).unwrap_or(1),
821 row_span: cell.style.as_ref().and_then(|s| s.row_span).unwrap_or(1),
822 is_header,
823 style: cell.style.clone(),
824 },
825 content,
826 Some(if is_header { &row_id } else { id }),
827 ));
828 column_index += cell
829 .style
830 .as_ref()
831 .and_then(|s| s.grid_span)
832 .unwrap_or(1)
833 .max(1);
834 }
835 column_count = column_count.max(column_index);
836 let text = joined_block_content(&cells, "\t");
837 if ri > 0 {
838 row_text.push('\n');
839 }
840 row_text.push_str(&text);
841 if is_header {
842 children.push(self.block(
843 row_id,
844 BlockType::TableHead,
845 text,
846 json!({"rowIndex":ri}),
847 cells,
848 Some(id),
849 ));
850 } else {
851 children.extend(cells);
852 }
853 }
854 lists.break_paragraph_flow();
855 self.block(
856 id.to_string(),
857 BlockType::Table,
858 row_text,
859 json!({"type":"table_details", "rowCount":table.rows.len(), "columnCount":column_count}),
860 children,
861 parent,
862 )
863 }
864}
865
866fn block_has_visible_text(block: &DocumentBlock) -> bool {
867 if matches!(&block.details, BlockDetails::Paragraph(details) if details.list_label.is_some()) {
868 return true;
869 }
870 block.content.chars().any(|c| {
871 !c.is_whitespace() && !matches!(c, '\u{200b}' | '\u{feff}' | '\u{00ad}')
872 })
873}
874
875fn without_empty_blocks(
876 mut blocks: Vec<DocumentBlock>,
877 parent: Option<&str>,
878 anchors: &HashSet<&str>,
879) -> Vec<DocumentBlock> {
880 let mut needs_flattening = false;
881 blocks.retain_mut(|block| {
882 block.children = without_empty_blocks(std::mem::take(&mut block.children), Some(&block.id), anchors);
883 if block.parent_block_id.as_deref() != parent {
884 block.parent_block_id = parent.map(str::to_string);
885 }
886 if anchors.contains(block.id.as_str()) || block_has_visible_text(block) || matches!(block.r#type,
887 BlockType::Figure | BlockType::Formula | BlockType::Barcode
888 | BlockType::Watermark | BlockType::KeyValue)
889 {
890 return true;
891 }
892 if block.children.is_empty() {
893 return false;
894 }
895 needs_flattening |= matches!(block.r#type, BlockType::Text);
896 true
897 });
898 if !needs_flattening {
899 if blocks.capacity() > blocks.len().saturating_mul(4).max(64) {
900 blocks.shrink_to_fit();
901 }
902 return blocks;
903 }
904 let mut retained = Vec::with_capacity(blocks.len());
905 for mut block in blocks {
906 if matches!(block.r#type, BlockType::Text) && !block_has_visible_text(&block) && !anchors.contains(block.id.as_str()) {
907 for child in &mut block.children {
908 if child.parent_block_id.as_deref() != parent {
909 child.parent_block_id = parent.map(str::to_string);
910 }
911 }
912 retained.extend(block.children);
913 } else {
914 retained.push(block);
915 }
916 }
917 retained
918}
919
920pub fn extract_document_blocks(model: &DocModel) -> StructuredDocument {
921 extract_document_blocks_with_options(model, ExtractDocumentBlocksOptions::default())
922}
923
924pub fn extract_document_blocks_with_options(
925 model: &DocModel,
926 options: ExtractDocumentBlocksOptions,
927) -> StructuredDocument {
928 extract(model, options, false).document
929}
930
931pub fn extract_document_blocks_with_revisions(
934 model: &DocModel,
935 options: ExtractDocumentBlocksOptions,
936) -> ReviewedDocument {
937 extract(model, options, true)
938}
939
940fn extract(
941 model: &DocModel,
942 options: ExtractDocumentBlocksOptions,
943 include_revisions: bool,
944) -> ReviewedDocument {
945 let document_id = model
946 .metadata
947 .document_id
948 .clone()
949 .unwrap_or_else(|| "unsaved".into());
950 let mut extractor = Extractor {
951 lists: ListDefinitions::new(model.metadata.numbering_definitions.as_ref()),
952 document_id: document_id.clone(),
953 part_name: source_part(model, "document"),
954 story: "body".into(),
955 include_words: options.include_words,
956 review: include_revisions.then(RevisionCollector::default),
957 };
958 let mut blocks = extractor.story_nodes(&model.nodes, None);
959 if let (Some(review), Some(section), Some(source)) = (
960 &mut extractor.review,
961 model.metadata.section_properties_xml.as_deref(),
962 blocks.last().and_then(|b| b.source_refs.first()),
963 ) {
964 let in_paragraph = matches!(model.nodes.last(), Some(DocNode::Paragraph(p))
966 if p.source_xml.as_deref().is_some_and(|xml| xml.contains(section)));
967 if !in_paragraph {
968 review.final_section(section, source);
969 }
970 }
971 let mut seen = HashSet::new();
972 let headers = model.metadata.header_sections.iter().chain(
973 model
974 .metadata
975 .sections
976 .iter()
977 .flatten()
978 .flat_map(|s| s.header_sections.iter()),
979 );
980 for header in headers {
981 if !seen.insert(header.part_name.clone()) {
982 continue;
983 }
984 extractor.part_name = header.part_name.clone();
985 extractor.story = "header".into();
986 blocks.push(extractor.story_block(&header.nodes, BlockType::Header));
987 }
988 let footers = model.metadata.footer_sections.iter().chain(
989 model
990 .metadata
991 .sections
992 .iter()
993 .flatten()
994 .flat_map(|s| s.footer_sections.iter()),
995 );
996 for footer in footers {
997 if !seen.insert(footer.part_name.clone()) {
998 continue;
999 }
1000 extractor.part_name = footer.part_name.clone();
1001 extractor.story = "footer".into();
1002 blocks.push(extractor.story_block(&footer.nodes, BlockType::Footer));
1003 }
1004 for (kind, notes) in [
1005 ("footnote", &model.metadata.footnotes),
1006 ("endnote", &model.metadata.endnotes),
1007 ] {
1008 extractor.part_name = source_part(model, &format!("{kind}s"));
1009 extractor.story = kind.into();
1010 for note in notes.iter().flatten() {
1011 if let Some(nodes) = ¬e.nodes {
1012 blocks.extend(extractor.story_nodes(nodes, None));
1013 }
1014 }
1015 }
1016 extractor.part_name = source_part(model, "comments");
1017 extractor.story = "comment".into();
1018 for comment in model.metadata.comments.iter().flatten() {
1019 if let Some(nodes) = &comment.nodes {
1020 blocks.extend(extractor.story_nodes(nodes, None));
1021 }
1022 }
1023 let revisions = extractor.review.map(|review| review.records).unwrap_or_default();
1024 let anchors = revisions
1025 .iter()
1026 .flat_map(|revision| revision.source_refs.iter().map(|source| source.node_id.as_str()))
1027 .collect();
1028 let blocks = without_empty_blocks(blocks, None, &anchors);
1029 let document = StructuredDocument {
1030 schema_version: 1,
1031 parser_version: env!("CARGO_PKG_VERSION").into(),
1032 document_id,
1033 blocks,
1034 warnings: model.metadata.warnings.clone(),
1035 geometry_status: "requires_layout".into(),
1036 };
1037 ReviewedDocument { document, revisions }
1038}
1039
1040#[cfg(test)]
1041mod image_presentation_tests {
1042 use super::*;
1043
1044 #[test]
1045 fn embedded_crops_snap_to_pixels_without_changing_the_source() {
1046 let mut image: ImageRunNode = serde_json::from_value(json!({
1047 "type":"image", "widthPx":431.7, "heightPx":264.25,
1048 "crop":{"leftFraction":0.17321,"topFraction":0.16321,
1049 "rightFraction":0.10987,"bottomFraction":0.05132},
1050 "cssOpacity":0.37654,
1051 "src":"data:image/png;base64,AQID"
1052 }))
1053 .unwrap();
1054 let raw = image_url(&image).unwrap();
1055 let (url, region) = presented_image_url(&image, &raw).unwrap();
1056 assert_eq!((region.left, region.top), (104.0 / 603.0, 55.0 / 336.0));
1057 let expected = crate::parse::svg_data_uri(
1058 r#"<image x="-104" y="-55" width="603" height="336" preserveAspectRatio="none" opacity="0.37654""#,
1059 );
1060 assert!(url.contains(expected.split_once(',').unwrap().1));
1061 assert_eq!(image_url(&image).unwrap(), raw);
1062 assert!(presented_image_url(&image, "https://invalid.test/image.png").is_none());
1063 assert!(presented_image_url(&image, "data:text/html,invalid").is_none());
1064
1065 image.css_opacity = None;
1066 image.crop.as_mut().unwrap().left_fraction = Some(1.0);
1067 assert!(presented_image_url(&image, &raw).is_none());
1068 image.crop = None;
1069 assert!(presented_image_url(&image, &raw).is_none());
1070 }
1071}
1072
1073#[cfg(test)]
1074mod typed_table_tests {
1075 #[test]
1076 fn table_details_round_trip_through_imported_json() {
1077 let mut package = crate::package::OoxmlPackage {
1078 parts: Default::default(),
1079 binary_assets: Default::default(),
1080 warnings: vec![],
1081 };
1082 let name = "word/document.xml".to_owned();
1083 package.parts.insert(name.clone(), crate::package::OoxmlPart { name, content: "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:tbl><w:tblGrid><w:gridCol w:w=\"1200\"/><w:gridCol w:w=\"1200\"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr><w:gridSpan w:val=\"2\"/><w:shd w:fill=\"FFCC00\"/></w:tcPr><w:p><w:r><w:rPr><w:b/></w:rPr><w:t>cell content</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>".into() });
1084 let model = crate::build_doc_model(&package);
1085 for include_words in [false, true] {
1086 let blocks = super::extract_document_blocks_with_options(
1087 &model,
1088 super::ExtractDocumentBlocksOptions { include_words },
1089 );
1090 let value = serde_json::to_value(&blocks).unwrap();
1091 let imported: super::StructuredDocument =
1092 serde_json::from_value(value.clone()).unwrap();
1093 assert_eq!(blocks, imported);
1094 assert_eq!(serde_json::to_value(imported).unwrap(), value);
1095 let details = &value["blocks"][0]["children"][0]["children"][0]["details"];
1096 assert_eq!(details["columnSpan"], 2);
1097 assert_eq!(details["isHeader"], true);
1098 assert_eq!(details["style"]["backgroundColor"], "#FFCC00");
1099 }
1100 }
1101}
1102
1103#[cfg(test)]
1104mod field_projection_tests {
1105 use super::paragraph_field_instructions;
1106
1107 #[test]
1108 fn borrowed_instruction_projection_matches_balanced_block_behavior() {
1109 let cases = [
1110 "", "<w:p><w:r><w:t>text</w:t></w:r></w:p>",
1111 "<w:instrText>PAGE</w:instrText>",
1112 "<W:INSTRText>NUMPAGES</W:INSTRText>",
1113 "<w:instrText>HYPER&LINK</w:instrText>",
1114 "<w:instrText><w:instrText>PAGE</w:instrText></w:instrText>",
1115 "<w:instrText>unfinished", "<w:instrText/>",
1116 "<w:instrTextExtra>text</w:instrTextExtra>",
1117 "<w:instrText xml:space=\"preserve\"> PAGE </w:instrText>",
1118 "<w:instrText>PAGE</w:instrText><w:instrText>NUMPAGES</w:instrText>",
1119 ];
1120 for xml in cases {
1121 let expected: Vec<_> = crate::xml::extract_balanced_tag_blocks(xml, "w:instrText")
1122 .iter()
1123 .map(|field| {
1124 field.split_once('>')
1125 .map(|(_, value)| crate::xml::decode_xml_entities(value.split("</").next().unwrap_or("")))
1126 .unwrap_or_default()
1127 })
1128 .collect();
1129 assert_eq!(paragraph_field_instructions(xml), expected);
1130 }
1131 }
1132}
1133
1134#[cfg(test)]
1135mod indexed_path_tests {
1136 use super::indexed_path;
1137
1138 #[test]
1139 fn indexed_paths_preserve_integer_and_unicode_spelling() {
1140 for prefix in ["", "prefix", "文😀"] {
1141 for index in [0, 1, 9, 10, 99, 100, usize::MAX] {
1142 assert_eq!(indexed_path(prefix, [("/", index)]), format!("{prefix}/{index}"));
1143 assert_eq!(indexed_path(prefix, [("/row/", index), ("/cell/", 0), ("/", 10)]), format!("{prefix}/row/{index}/cell/0/10"));
1144 assert_eq!(indexed_path(prefix, [("/word/", index), ("-", index)]), format!("{prefix}/word/{index}-{index}"));
1145 }
1146 assert_eq!(indexed_path(prefix, []), prefix);
1147 }
1148 }
1149}
1150
1151#[cfg(test)]
1152mod inplace_block_cleanup_tests {
1153 #[test]
1154 fn invisible_containers_flatten_objects_and_repair_only_parent_links() {
1155 use super::*;
1156 let extractor = Extractor {
1157 lists: ListDefinitions::new(None),
1158 document_id: "document".into(), part_name: "part".into(),
1159 story: "body".into(), include_words: false, review: None,
1160 };
1161 let figure = extractor.block("figure".into(), BlockType::Figure, String::new(),
1162 json!({}), Vec::new(), Some("container"));
1163 let source_refs = figure.source_refs.clone();
1164 let container = extractor.block("container".into(), BlockType::Text,
1165 " \u{200b}\u{feff}\u{00ad}".into(), json!({}), vec![figure], Some("stale"));
1166 let empty = extractor.block("empty".into(), BlockType::Table, String::new(),
1167 json!({}), Vec::new(), None);
1168 let text = extractor.block("text".into(), BlockType::Text, "content".into(),
1169 json!({}), Vec::new(), Some("stale"));
1170 let result = without_empty_blocks(vec![container, empty, text], Some("parent"), &HashSet::new());
1171 assert_eq!(result.len(), 2);
1172 assert_eq!(result[0].id, "figure");
1173 assert_eq!(result[0].source_refs, source_refs);
1174 assert_eq!(result[1].content, "content");
1175 assert!(result.iter().all(|block| block.parent_block_id.as_deref() == Some("parent")));
1176 let root = without_empty_blocks(result, None, &HashSet::new());
1177 assert!(root.iter().all(|block| block.parent_block_id.is_none()));
1178 }
1179}