Skip to main content

rust_docx/
structured.rs

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