Skip to main content

rust_docx/
bundle.rs

1//! Portable structured extraction with optional assets, charts, and tracked-change spans.
2use crate::{
3    ChartData, DocModel, DocumentComment, DocumentRevision, ExtractDocumentBlocksOptions,
4    OoxmlPackage, StructuredDocument,
5};
6use base64::Engine;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeMap;
10
11#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
12#[serde(default, rename_all = "camelCase")]
13pub struct ExtractDocumentBundleOptions {
14    #[serde(flatten)]
15    pub blocks: ExtractDocumentBlocksOptions,
16    pub include_revisions: bool,
17    pub include_charts: bool,
18    pub deduplicate_assets: bool,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DocumentBundle {
24    #[serde(flatten)]
25    pub document: StructuredDocument,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub revisions: Option<Vec<DocumentRevision>>,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub comments: Vec<DocumentComment>,
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub charts: BTreeMap<String, ChartData>,
32    /// IDs referenced by imageAssetId, sourceImageAssetId, previewImageAssetId, and dataAssetId.
33    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34    pub assets: BTreeMap<String, String>,
35    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
36    pub revision_inlines: BTreeMap<String, Vec<crate::RevisionInline>>,
37}
38
39pub fn extract_document_bundle(
40    model: &DocModel,
41    package: &OoxmlPackage,
42    options: ExtractDocumentBundleOptions,
43) -> DocumentBundle {
44    let (mut document, revisions, comments) = if options.include_revisions {
45        let reviewed = crate::extract_document_blocks_with_revisions(model, options.blocks);
46        (
47            reviewed.document,
48            Some(reviewed.revisions),
49            reviewed.comments,
50        )
51    } else {
52        (
53            crate::extract_document_blocks_with_options(model, options.blocks),
54            None,
55            crate::review::extract_document_comments(model),
56        )
57    };
58    let mut charts = BTreeMap::new();
59    if options.include_charts {
60        collect_chart_data(&document.blocks, package, &mut charts);
61    }
62    let revision_inlines = revisions
63        .as_ref()
64        .map(|r| crate::revision_inlines(&document.blocks, r))
65        .unwrap_or_default();
66    let assets = if options.deduplicate_assets {
67        extract_document_assets(&mut document.blocks)
68    } else {
69        BTreeMap::new()
70    };
71    DocumentBundle {
72        document,
73        revisions,
74        comments,
75        charts,
76        assets,
77        revision_inlines,
78    }
79}
80
81pub fn parse_document_bundle(
82    bytes: &[u8],
83    options: ExtractDocumentBundleOptions,
84) -> Result<DocumentBundle, String> {
85    let mut package = crate::parse_document_bytes(bytes)?;
86    let model = crate::build_doc_model_with_options(
87        &package,
88        &crate::ParseOptions {
89            include_3d_models: options.blocks.include_3d_models,
90            ..Default::default()
91        },
92    );
93    if !options.include_charts {
94        package.parts.clear();
95        package.binary_assets.clear();
96    }
97    Ok(extract_document_bundle(&model, &package, options))
98}
99
100/// Replace repeated embedded asset payloads with document-local IDs.
101pub fn extract_document_assets(blocks: &mut [crate::DocumentBlock]) -> BTreeMap<String, String> {
102    let mut assets = std::collections::HashMap::new();
103    collect_image_assets(blocks, &mut assets);
104    assets.into_iter().map(|(url, id)| (id, url)).collect()
105}
106
107pub fn collect_chart_data(
108    blocks: &[crate::DocumentBlock],
109    package: &crate::OoxmlPackage,
110    charts: &mut std::collections::BTreeMap<String, crate::ChartData>,
111) {
112    for block in blocks {
113        if let crate::BlockDetails::Raw(details) = &block.details {
114            if let Some(part) = details.get("chartPartName").and_then(Value::as_str) {
115                if !charts.contains_key(part) {
116                    if let Some(chart) = crate::extract_chart_data(package, part) {
117                        charts.insert(part.to_owned(), chart);
118                    }
119                }
120            }
121        }
122        collect_chart_data(&block.children, package, charts);
123    }
124}
125
126fn collect_image_assets(
127    blocks: &mut [crate::DocumentBlock],
128    assets: &mut std::collections::HashMap<String, String>,
129) {
130    for block in blocks {
131        if let crate::BlockDetails::Raw(details) = &mut block.details {
132            if let Some(details) = details.as_object_mut() {
133                if let Some(model) = details.get_mut("model3d") {
134                    if let Some(asset) = model.get_mut("asset").and_then(Value::as_object_mut) {
135                        if let Some(Value::Array(data)) = asset.get("data") {
136                            let bytes: Option<Vec<u8>> = data
137                                .iter()
138                                .map(|value| value.as_u64().and_then(|n| u8::try_from(n).ok()))
139                                .collect();
140                            if let Some(bytes) = bytes {
141                                asset.remove("data");
142                                let url = format!(
143                                    "data:model/gltf-binary;base64,{}",
144                                    base64::engine::general_purpose::STANDARD.encode(bytes)
145                                );
146                                let next = assets.len().to_string();
147                                let id = assets.entry(url).or_insert(next).clone();
148                                asset.insert("dataAssetId".into(), Value::String(id));
149                            }
150                        }
151                    }
152                }
153                for field in ["image", "sourceImage", "previewImage"] {
154                    let key = format!("{field}Url");
155                    if let Some(url) = details.get(&key).and_then(Value::as_str).map(str::to_owned)
156                    {
157                        details.remove(&key);
158                        let next = assets.len().to_string();
159                        let id = assets.entry(url).or_insert(next).clone();
160                        details.insert(format!("{field}AssetId"), Value::String(id));
161                    }
162                }
163            }
164        }
165        collect_image_assets(&mut block.children, assets);
166    }
167}