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}
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    /// Visible list marker, separate from the text addressed by source offsets.
140    #[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/// Eager semantic details with an unrestricted JSON representation for imported data.
160#[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            // Cropping snaps the frame, scaled image, and offsets to whole CSS pixels in that order.
316            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(presentation) = image.presentation.as_ref() {
721                        details.as_object_mut().unwrap()
722                            .insert("presentation".into(), json!(presentation));
723                    }
724                    if let Some(url) = image_url(image) {
725                        if let Some((rendered, presentation)) = presented_image_url(image, &url) {
726                            details["sourceImageUrl"] = Value::String(url);
727                            details["imageUrl"] = Value::String(rendered);
728                            details["imagePresentation"] = json!(presentation);
729                            details["contentType"] = json!("image/svg+xml");
730                        } else {
731                            details["imageUrl"] = Value::String(url);
732                        }
733                    }
734                    children.push(self.block(
735                        image_id.to_string(),
736                        kind,
737                        text,
738                        details,
739                        nested,
740                        Some(id),
741                    ));
742                }
743            }
744        }
745        let details = ParagraphDetails {
746            r#type: "text_details".into(),
747            kind: "paragraph".into(),
748            style: p.style.clone(),
749            style_id: style_id.into(),
750            heading_level: style.and_then(|s| s.heading_level),
751            numbering: style.and_then(|s| s.numbering.clone()),
752            list_label: lists.label(p, &self.lists),
753            field_instructions,
754            inlines: inline,
755        };
756        let mut block = self.block(id.to_string(), kind, content, details, children, parent);
757        if let (Some(review), Some(xml)) = (&mut self.review, &p.source_xml) {
758            review.paragraph(xml, &block, p);
759        }
760        if self.include_words {
761            block.words = Some(self.words(id, &block.content));
762        }
763        block
764    }
765
766    fn table(
767        &mut self,
768        table: &TableNode,
769        fallback: &str,
770        parent: Option<&str>,
771        lists: &mut StoryLists,
772    ) -> DocumentBlock {
773        let id = table.block_id.as_deref().unwrap_or(fallback);
774        if let (Some(review), Some(xml)) = (&mut self.review, &table.source_xml) {
775            let source = SourceRef {
776                document_id: self.document_id.clone(),
777                node_id: id.to_owned(),
778                part_name: self.part_name.clone(),
779                story: self.story.clone(),
780                start: None,
781                end: None,
782                offset_encoding: "utf-16".into(),
783            };
784            review.table(xml, &source, table);
785        }
786        let mut children = Vec::new();
787        let mut row_text = String::new();
788        let mut column_count = 0;
789        for (ri, row) in table.rows.iter().enumerate() {
790            let row_id = indexed_path(id, [("/row/", ri)]);
791            let is_header = row
792                .style
793                .as_ref()
794                .and_then(|s| s.is_header)
795                .unwrap_or(false);
796            let mut cells = Vec::with_capacity(row.cells.len());
797            let mut column_index = row.style.as_ref().and_then(|s| s.grid_before).unwrap_or(0);
798            for (ci, cell) in row.cells.iter().enumerate() {
799                let cell_id = indexed_path(&row_id, [("/cell/", ci)]);
800                lists.break_paragraph_flow();
801                let content = cell
802                    .nodes
803                    .iter()
804                    .enumerate()
805                    .map(|(ni, node)| match node {
806                        TableCellContentNode::Paragraph(p) => {
807                            self.paragraph(p, &indexed_path(&cell_id, [("/", ni)]), Some(&cell_id), lists)
808                        }
809                        TableCellContentNode::Table(t) => {
810                            self.table(t, &indexed_path(&cell_id, [("/", ni)]), Some(&cell_id), lists)
811                        }
812                    })
813                    .collect::<Vec<_>>();
814                let text = joined_block_content(&content, "\n");
815                cells.push(self.block(
816                    cell_id,
817                    BlockType::TableCell,
818                    text,
819                    TableCellDetails {
820                        r#type: "table_cell_details".into(),
821                        row_index: ri,
822                        column_index,
823                        cell_index: ci,
824                        column_span: cell.style.as_ref().and_then(|s| s.grid_span).unwrap_or(1),
825                        row_span: cell.style.as_ref().and_then(|s| s.row_span).unwrap_or(1),
826                        is_header,
827                        style: cell.style.clone(),
828                    },
829                    content,
830                    Some(if is_header { &row_id } else { id }),
831                ));
832                column_index += cell
833                    .style
834                    .as_ref()
835                    .and_then(|s| s.grid_span)
836                    .unwrap_or(1)
837                    .max(1);
838            }
839            column_count = column_count.max(column_index);
840            let text = joined_block_content(&cells, "\t");
841            if ri > 0 {
842                row_text.push('\n');
843            }
844            row_text.push_str(&text);
845            if is_header {
846                children.push(self.block(
847                    row_id,
848                    BlockType::TableHead,
849                    text,
850                    json!({"rowIndex":ri}),
851                    cells,
852                    Some(id),
853                ));
854            } else {
855                children.extend(cells);
856            }
857        }
858        lists.break_paragraph_flow();
859        self.block(
860            id.to_string(),
861            BlockType::Table,
862            row_text,
863            json!({"type":"table_details", "rowCount":table.rows.len(), "columnCount":column_count}),
864            children,
865            parent,
866        )
867    }
868}
869
870fn block_has_visible_text(block: &DocumentBlock) -> bool {
871    if matches!(&block.details, BlockDetails::Paragraph(details) if details.list_label.is_some()) {
872        return true;
873    }
874    block.content.chars().any(|c| {
875        !c.is_whitespace() && !matches!(c, '\u{200b}' | '\u{feff}' | '\u{00ad}')
876    })
877}
878
879fn without_empty_blocks(
880    mut blocks: Vec<DocumentBlock>,
881    parent: Option<&str>,
882    anchors: &HashSet<&str>,
883) -> Vec<DocumentBlock> {
884    let mut needs_flattening = false;
885    blocks.retain_mut(|block| {
886        block.children = without_empty_blocks(std::mem::take(&mut block.children), Some(&block.id), anchors);
887        if block.parent_block_id.as_deref() != parent {
888            block.parent_block_id = parent.map(str::to_string);
889        }
890        if anchors.contains(block.id.as_str()) || block_has_visible_text(block) || matches!(block.r#type,
891            BlockType::Figure | BlockType::Formula | BlockType::Barcode
892                | BlockType::Watermark | BlockType::KeyValue)
893        {
894            return true;
895        }
896        if block.children.is_empty() {
897            return false;
898        }
899        needs_flattening |= matches!(block.r#type, BlockType::Text);
900        true
901    });
902    if !needs_flattening {
903        if blocks.capacity() > blocks.len().saturating_mul(4).max(64) {
904            blocks.shrink_to_fit();
905        }
906        return blocks;
907    }
908    let mut retained = Vec::with_capacity(blocks.len());
909    for mut block in blocks {
910        if matches!(block.r#type, BlockType::Text) && !block_has_visible_text(&block) && !anchors.contains(block.id.as_str()) {
911            for child in &mut block.children {
912                if child.parent_block_id.as_deref() != parent {
913                    child.parent_block_id = parent.map(str::to_string);
914                }
915            }
916            retained.extend(block.children);
917        } else {
918            retained.push(block);
919        }
920    }
921    retained
922}
923
924pub fn extract_document_blocks(model: &DocModel) -> StructuredDocument {
925    extract_document_blocks_with_options(model, ExtractDocumentBlocksOptions::default())
926}
927
928pub fn extract_document_blocks_with_options(
929    model: &DocModel,
930    options: ExtractDocumentBlocksOptions,
931) -> StructuredDocument {
932    extract(model, options, false).document
933}
934
935/// Extract the current document and the tracked edits present in its Word source.
936/// Source references use the same identities and UTF-16 coordinates as the blocks.
937pub fn extract_document_blocks_with_revisions(
938    model: &DocModel,
939    options: ExtractDocumentBlocksOptions,
940) -> ReviewedDocument {
941    extract(model, options, true)
942}
943
944fn extract(
945    model: &DocModel,
946    options: ExtractDocumentBlocksOptions,
947    include_revisions: bool,
948) -> ReviewedDocument {
949    let document_id = model
950        .metadata
951        .document_id
952        .clone()
953        .unwrap_or_else(|| "unsaved".into());
954    let mut extractor = Extractor {
955        lists: ListDefinitions::new(model.metadata.numbering_definitions.as_ref()),
956        document_id: document_id.clone(),
957        part_name: source_part(model, "document"),
958        story: "body".into(),
959        include_words: options.include_words,
960        review: include_revisions.then(RevisionCollector::default),
961    };
962    let mut blocks = extractor.story_nodes(&model.nodes, None);
963    if let (Some(review), Some(section), Some(source)) = (
964        &mut extractor.review,
965        model.metadata.section_properties_xml.as_deref(),
966        blocks.last().and_then(|b| b.source_refs.first()),
967    ) {
968        // A terminal section can also be the last paragraph's section break.
969        let in_paragraph = matches!(model.nodes.last(), Some(DocNode::Paragraph(p))
970            if p.source_xml.as_deref().is_some_and(|xml| xml.contains(section)));
971        if !in_paragraph {
972            review.final_section(section, source);
973        }
974    }
975    let mut seen = HashSet::new();
976    let headers = model.metadata.header_sections.iter().chain(
977        model
978            .metadata
979            .sections
980            .iter()
981            .flatten()
982            .flat_map(|s| s.header_sections.iter()),
983    );
984    for header in headers {
985        if !seen.insert(header.part_name.clone()) {
986            continue;
987        }
988        extractor.part_name = header.part_name.clone();
989        extractor.story = "header".into();
990        blocks.push(extractor.story_block(&header.nodes, BlockType::Header));
991    }
992    let footers = model.metadata.footer_sections.iter().chain(
993        model
994            .metadata
995            .sections
996            .iter()
997            .flatten()
998            .flat_map(|s| s.footer_sections.iter()),
999    );
1000    for footer in footers {
1001        if !seen.insert(footer.part_name.clone()) {
1002            continue;
1003        }
1004        extractor.part_name = footer.part_name.clone();
1005        extractor.story = "footer".into();
1006        blocks.push(extractor.story_block(&footer.nodes, BlockType::Footer));
1007    }
1008    for (kind, notes) in [
1009        ("footnote", &model.metadata.footnotes),
1010        ("endnote", &model.metadata.endnotes),
1011    ] {
1012        extractor.part_name = source_part(model, &format!("{kind}s"));
1013        extractor.story = kind.into();
1014        for note in notes.iter().flatten() {
1015            if let Some(nodes) = &note.nodes {
1016                blocks.extend(extractor.story_nodes(nodes, None));
1017            }
1018        }
1019    }
1020    extractor.part_name = source_part(model, "comments");
1021    extractor.story = "comment".into();
1022    for comment in model.metadata.comments.iter().flatten() {
1023        if let Some(nodes) = &comment.nodes {
1024            blocks.extend(extractor.story_nodes(nodes, None));
1025        }
1026    }
1027    let revisions = extractor.review.map(|review| review.records).unwrap_or_default();
1028    let anchors = revisions
1029        .iter()
1030        .flat_map(|revision| revision.source_refs.iter().map(|source| source.node_id.as_str()))
1031        .collect();
1032    let blocks = without_empty_blocks(blocks, None, &anchors);
1033    let document = StructuredDocument {
1034        schema_version: 1,
1035        parser_version: env!("CARGO_PKG_VERSION").into(),
1036        document_id,
1037        blocks,
1038        warnings: model.metadata.warnings.clone(),
1039        geometry_status: "requires_layout".into(),
1040    };
1041    ReviewedDocument { document, revisions, comments: if include_revisions { crate::review::extract_document_comments(model) } else { Vec::new() } }
1042}
1043
1044#[cfg(test)]
1045mod image_presentation_tests {
1046    use super::*;
1047
1048    #[test]
1049    fn embedded_crops_snap_to_pixels_without_changing_the_source() {
1050        let mut image: ImageRunNode = serde_json::from_value(json!({
1051            "type":"image", "widthPx":431.7, "heightPx":264.25,
1052            "crop":{"leftFraction":0.17321,"topFraction":0.16321,
1053                    "rightFraction":0.10987,"bottomFraction":0.05132},
1054            "cssOpacity":0.37654,
1055            "src":"data:image/png;base64,AQID"
1056        }))
1057        .unwrap();
1058        let raw = image_url(&image).unwrap();
1059        let (url, region) = presented_image_url(&image, &raw).unwrap();
1060        assert_eq!((region.left, region.top), (104.0 / 603.0, 55.0 / 336.0));
1061        let expected = crate::parse::svg_data_uri(
1062            r#"<image x="-104" y="-55" width="603" height="336" preserveAspectRatio="none" opacity="0.37654""#,
1063        );
1064        assert!(url.contains(expected.split_once(',').unwrap().1));
1065        assert_eq!(image_url(&image).unwrap(), raw);
1066        assert!(presented_image_url(&image, "https://invalid.test/image.png").is_none());
1067        assert!(presented_image_url(&image, "data:text/html,invalid").is_none());
1068
1069        image.css_opacity = None;
1070        image.crop.as_mut().unwrap().left_fraction = Some(1.0);
1071        assert!(presented_image_url(&image, &raw).is_none());
1072        image.crop = None;
1073        assert!(presented_image_url(&image, &raw).is_none());
1074    }
1075}
1076
1077#[cfg(test)]
1078mod typed_table_tests {
1079    #[test]
1080    fn table_details_round_trip_through_imported_json() {
1081        let mut package = crate::package::OoxmlPackage {
1082            parts: Default::default(),
1083            binary_assets: Default::default(),
1084            warnings: vec![],
1085        };
1086        let name = "word/document.xml".to_owned();
1087        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() });
1088        let model = crate::build_doc_model(&package);
1089        for include_words in [false, true] {
1090            let blocks = super::extract_document_blocks_with_options(
1091                &model,
1092                super::ExtractDocumentBlocksOptions { include_words },
1093            );
1094            let value = serde_json::to_value(&blocks).unwrap();
1095            let imported: super::StructuredDocument =
1096                serde_json::from_value(value.clone()).unwrap();
1097            assert_eq!(blocks, imported);
1098            assert_eq!(serde_json::to_value(imported).unwrap(), value);
1099            let details = &value["blocks"][0]["children"][0]["children"][0]["details"];
1100            assert_eq!(details["columnSpan"], 2);
1101            assert_eq!(details["isHeader"], true);
1102            assert_eq!(details["style"]["backgroundColor"], "#FFCC00");
1103        }
1104    }
1105}
1106
1107#[cfg(test)]
1108mod field_projection_tests {
1109    use super::paragraph_field_instructions;
1110
1111    #[test]
1112    fn borrowed_instruction_projection_matches_balanced_block_behavior() {
1113        let cases = [
1114            "", "<w:p><w:r><w:t>text</w:t></w:r></w:p>",
1115            "<w:instrText>PAGE</w:instrText>",
1116            "<W:INSTRText>NUMPAGES</W:INSTRText>",
1117            "<w:instrText>HYPER&amp;LINK</w:instrText>",
1118            "<w:instrText><w:instrText>PAGE</w:instrText></w:instrText>",
1119            "<w:instrText>unfinished", "<w:instrText/>",
1120            "<w:instrTextExtra>text</w:instrTextExtra>",
1121            "<w:instrText xml:space=\"preserve\"> PAGE </w:instrText>",
1122            "<w:instrText>PAGE</w:instrText><w:instrText>NUMPAGES</w:instrText>",
1123        ];
1124        for xml in cases {
1125            let expected: Vec<_> = crate::xml::extract_balanced_tag_blocks(xml, "w:instrText")
1126                .iter()
1127                .map(|field| {
1128                    field.split_once('>')
1129                        .map(|(_, value)| crate::xml::decode_xml_entities(value.split("</").next().unwrap_or("")))
1130                        .unwrap_or_default()
1131                })
1132                .collect();
1133            assert_eq!(paragraph_field_instructions(xml), expected);
1134        }
1135    }
1136}
1137
1138#[cfg(test)]
1139mod indexed_path_tests {
1140    use super::indexed_path;
1141
1142    #[test]
1143    fn indexed_paths_preserve_integer_and_unicode_spelling() {
1144        for prefix in ["", "prefix", "文😀"] {
1145            for index in [0, 1, 9, 10, 99, 100, usize::MAX] {
1146                assert_eq!(indexed_path(prefix, [("/", index)]), format!("{prefix}/{index}"));
1147                assert_eq!(indexed_path(prefix, [("/row/", index), ("/cell/", 0), ("/", 10)]), format!("{prefix}/row/{index}/cell/0/10"));
1148                assert_eq!(indexed_path(prefix, [("/word/", index), ("-", index)]), format!("{prefix}/word/{index}-{index}"));
1149            }
1150            assert_eq!(indexed_path(prefix, []), prefix);
1151        }
1152    }
1153}
1154
1155#[cfg(test)]
1156mod inplace_block_cleanup_tests {
1157    #[test]
1158    fn invisible_containers_flatten_objects_and_repair_only_parent_links() {
1159        use super::*;
1160        let extractor = Extractor {
1161            lists: ListDefinitions::new(None),
1162            document_id: "document".into(), part_name: "part".into(),
1163            story: "body".into(), include_words: false, review: None,
1164        };
1165        let figure = extractor.block("figure".into(), BlockType::Figure, String::new(),
1166            json!({}), Vec::new(), Some("container"));
1167        let source_refs = figure.source_refs.clone();
1168        let container = extractor.block("container".into(), BlockType::Text,
1169            " \u{200b}\u{feff}\u{00ad}".into(), json!({}), vec![figure], Some("stale"));
1170        let empty = extractor.block("empty".into(), BlockType::Table, String::new(),
1171            json!({}), Vec::new(), None);
1172        let text = extractor.block("text".into(), BlockType::Text, "content".into(),
1173            json!({}), Vec::new(), Some("stale"));
1174        let result = without_empty_blocks(vec![container, empty, text], Some("parent"), &HashSet::new());
1175        assert_eq!(result.len(), 2);
1176        assert_eq!(result[0].id, "figure");
1177        assert_eq!(result[0].source_refs, source_refs);
1178        assert_eq!(result[1].content, "content");
1179        assert!(result.iter().all(|block| block.parent_block_id.as_deref() == Some("parent")));
1180        let root = without_empty_blocks(result, None, &HashSet::new());
1181        assert!(root.iter().all(|block| block.parent_block_id.is_none()));
1182    }
1183}