Skip to main content

extend_docx/parse/
metadata.rs

1use super::colors::normalize_hex_color;
2use crate::model::DocumentCompatibilitySettings;
3use crate::package::OoxmlPackage;
4use crate::xml::{
5    extract_balanced_tag_blocks, get_attribute, parse_integer_attribute, parse_on_off_attribute,
6};
7
8pub fn parse_document_default_tab_stop_twips(pkg: &OoxmlPackage) -> Option<i64> {
9    let settings_xml = crate::package::related_document_part(pkg, "settings")?
10        .content
11        .as_str();
12    let tab_stop_tag = super::scan::find_tag_token(settings_xml, "w:defaultTabStop")?;
13    parse_integer_attribute(&tab_stop_tag, "w:val").filter(|value| *value >= 0)
14}
15
16/// Mirrors TypeScript `extractBodyXml`.
17fn extract_body_xml(document_xml: &str) -> &str {
18    if let Some(body) = crate::xml_index::unique_inner_xml(document_xml, "w:body") {
19        if !body.is_empty() {
20            return body;
21        }
22    }
23    if let Some(start) = find_subsequence_ignore_ascii_case(document_xml, "<w:body") {
24        let after_open = &document_xml[start..];
25        let open_end = after_open.find('>').map(|index| start + index + 1);
26        let close_start = find_subsequence_ignore_ascii_case(document_xml, "</w:body>");
27        if let (Some(open_end), Some(close_start)) = (open_end, close_start) {
28            if open_end < close_start {
29                return &document_xml[open_end..close_start];
30            }
31        }
32    }
33    document_xml
34}
35
36/// Mirrors TypeScript `parseDocumentBackgroundColor`.
37pub fn parse_document_background_color(document_xml: &str) -> Option<String> {
38    let background_tag = super::scan::find_tag_token(document_xml, "w:background")?;
39    let color_value = find_namespaced_attribute_value(&background_tag, "w:color");
40    normalize_hex_color(color_value.as_deref())
41}
42
43/// Mirrors TypeScript `extractDocumentOpenTag`.
44pub fn extract_document_open_tag(document_xml: &str) -> Option<String> {
45    let start = find_subsequence_ignore_ascii_case(document_xml, "<w:document")?;
46    let after_open = &document_xml[start..];
47    let end = after_open.find('>')? + start + 1;
48    Some(document_xml[start..end].to_string())
49}
50
51/// Mirrors TypeScript `extractSectionPropertiesXml`.
52pub fn extract_section_properties_xml(document_xml: &str) -> Option<String> {
53    let body_xml = extract_body_xml(document_xml);
54    let blocks = extract_balanced_tag_blocks(body_xml, "w:sectPr");
55    blocks.into_iter().last()
56}
57
58/// Mirrors TypeScript `parseDocumentPageCountFromAppProperties`.
59pub fn parse_document_page_count_from_app_properties(pkg: &OoxmlPackage) -> Option<i64> {
60    let app_xml = pkg
61        .parts
62        .get("docProps/app.xml")
63        .map(|part| part.content.as_str())
64        .unwrap_or("");
65
66    if app_xml.is_empty() {
67        return None;
68    }
69
70    let pages_raw = extract_simple_tag_text(app_xml, "Pages")?;
71    let parsed = pages_raw.trim().parse::<i64>().ok()?;
72    if parsed > 0 {
73        Some(parsed)
74    } else {
75        None
76    }
77}
78
79/// Mirrors TypeScript `parseFirstOnOffSetting`.
80fn parse_first_on_off_setting(settings_xml: &str, tag_names: &[&str]) -> Option<bool> {
81    for tag_name in tag_names {
82        if let Some(tag) =
83            super::styles::direct_table_property(settings_xml, &format!("w:{tag_name}"))
84        {
85            return parse_on_off_attribute(tag, tag_name);
86        }
87    }
88    None
89}
90
91/// Mirrors TypeScript `parseDocumentCompatibilitySettings`.
92pub fn parse_document_compatibility_settings(
93    pkg: &OoxmlPackage,
94) -> Option<DocumentCompatibilitySettings> {
95    let settings_xml = crate::package::related_document_part(pkg, "settings")
96        .map(|part| part.content.as_str())
97        .unwrap_or("");
98
99    if settings_xml.is_empty() {
100        return None;
101    }
102
103    let settings_root = extract_balanced_tag_blocks(settings_xml, "w:settings")
104        .into_iter()
105        .next()
106        .unwrap_or_else(|| settings_xml.to_string());
107    let compat_xml =
108        super::styles::direct_table_property(&settings_root, "w:compat").unwrap_or_default();
109
110    let compatibility_mode = super::scan::find_all_tag_tokens(&compat_xml, "w:compatSetting")
111        .into_iter()
112        .find_map(|tag| {
113            if get_attribute(&tag, "w:name").as_deref() != Some("compatibilityMode")
114                || get_attribute(&tag, "w:uri").as_deref()
115                    != Some("http://schemas.microsoft.com/office/word")
116            {
117                return None;
118            }
119            get_attribute(&tag, "w:val")?
120                .parse::<u32>()
121                .ok()
122                .map(i64::from)
123        });
124
125    let adjust_line_height_in_table =
126        parse_first_on_off_setting(compat_xml, &["adjustLineHeightInTable"]);
127    let no_leading = parse_first_on_off_setting(compat_xml, &["noLeading"]);
128    let enable_open_type_features =
129        super::scan::find_all_tag_tokens(&compat_xml, "w:compatSetting")
130            .into_iter()
131            .find_map(|tag| {
132                if get_attribute(&tag, "w:name").as_deref() != Some("enableOpenTypeFeatures")
133                    || get_attribute(&tag, "w:uri").as_deref()
134                        != Some("http://schemas.microsoft.com/office/word")
135                {
136                    return None;
137                }
138                match get_attribute(&tag, "w:val").as_deref() {
139                    Some("1" | "true") => Some(true),
140                    Some("0" | "false") => Some(false),
141                    _ => None,
142                }
143            });
144    let suppress_top_spacing_wp = parse_first_on_off_setting(compat_xml, &["suppressTopSpacingWP"]);
145    let truncate_font_heights_like_wp6 =
146        parse_first_on_off_setting(compat_xml, &["truncateFontHeightsLikeWP6"]);
147    let no_extra_line_spacing = parse_first_on_off_setting(compat_xml, &["noExtraLineSpacing"]);
148    let space_for_ul = parse_first_on_off_setting(compat_xml, &["spaceForUL"]);
149    let suppress_bottom_spacing =
150        parse_first_on_off_setting(compat_xml, &["suppressBottomSpacing"]);
151
152    let suppress_spacing_before_after_page_break =
153        parse_first_on_off_setting(&compat_xml, &["suppressSpBfAfterPgBrk"]);
154    let use_printer_metrics = parse_first_on_off_setting(&compat_xml, &["usePrinterMetrics"]);
155    let use_fixed_html_paragraph_spacing =
156        parse_first_on_off_setting(&compat_xml, &["doNotUseHTMLParagraphAutoSpacing"]);
157    let do_not_break_wrapped_tables = parse_first_on_off_setting(
158        &compat_xml,
159        &["doNotBreakWrappedTables", "dontBreakWrappedTables"],
160    );
161    let do_not_break_constrained_forced_table = parse_first_on_off_setting(
162        &compat_xml,
163        &[
164            "doNotBreakConstrainedForcedTable",
165            "dontBreakConstrainedForcedTable",
166        ],
167    );
168    let even_and_odd_headers = parse_first_on_off_setting(&settings_root, &["evenAndOddHeaders"]);
169
170    if adjust_line_height_in_table.is_none()
171        && no_leading.is_none()
172        && suppress_top_spacing_wp.is_none()
173        && truncate_font_heights_like_wp6.is_none()
174        && no_extra_line_spacing.is_none()
175        && space_for_ul.is_none()
176        && suppress_bottom_spacing.is_none()
177        && compatibility_mode.is_none()
178        && enable_open_type_features.is_none()
179        && suppress_spacing_before_after_page_break.is_none()
180        && use_printer_metrics.is_none()
181        && use_fixed_html_paragraph_spacing.is_none()
182        && do_not_break_wrapped_tables.is_none()
183        && do_not_break_constrained_forced_table.is_none()
184        && even_and_odd_headers.is_none()
185    {
186        return None;
187    }
188
189    Some(DocumentCompatibilitySettings {
190        adjust_line_height_in_table,
191        no_leading,
192        suppress_top_spacing_wp,
193        truncate_font_heights_like_wp6,
194        no_extra_line_spacing,
195        space_for_ul,
196        suppress_bottom_spacing,
197
198        compatibility_mode,
199        enable_open_type_features,
200        suppress_spacing_before_after_page_break,
201        use_printer_metrics,
202        use_fixed_html_paragraph_spacing,
203        do_not_break_wrapped_tables,
204        do_not_break_constrained_forced_table,
205        even_and_odd_headers,
206    })
207}
208
209fn extract_simple_tag_text(xml: &str, tag_name: &str) -> Option<String> {
210    let open = format!("<{tag_name}>");
211    let close = format!("</{tag_name}>");
212    let start = find_subsequence_ignore_ascii_case(xml, &open)?;
213    let content_start = start + open.len();
214    let close_start = find_subsequence_ignore_ascii_case(&xml[content_start..], &close)?;
215    Some(xml[content_start..content_start + close_start].to_string())
216}
217
218fn find_namespaced_attribute_value(tag_xml: &str, attribute: &str) -> Option<String> {
219    crate::xml::get_attribute(tag_xml, attribute).or_else(|| {
220        let pattern = format!("{attribute}=\"");
221        let start = find_subsequence_ignore_ascii_case(tag_xml, &pattern)?;
222        let value_start = start + pattern.len();
223        let value_end = tag_xml[value_start..].find('"')? + value_start;
224        Some(tag_xml[value_start..value_end].to_string())
225    })
226}
227
228fn find_subsequence_ignore_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
229    if needle.is_empty() {
230        return Some(0);
231    }
232    crate::xml::find_ignore_ascii_case(haystack, 0, needle)
233}
234
235#[cfg(test)]
236mod indexed_body_bounds_tests {
237    #[test]
238    fn indexed_body_bounds_preserve_legacy_metadata_and_body_results() {
239        let binding = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
240        for content in [
241            "<w:body><w:p/><w:sectPr/></w:body>",
242            "<w:body></w:body><w:sectPr/>",
243            "<w:body/><w:sectPr/>",
244            "<w:bodyExtra/><w:body><w:sectPr/></w:body>",
245            "<w:body><w:body><w:sectPr/></w:body><w:sectPr/></w:body>",
246            "<w:body><w:sectPr/></w:body><w:body><w:p/></w:body>",
247            "<w:BODY><w:p/><w:sectPr/></w:BODY>",
248            "<w:body><w:p><w:r><w:t>λ😀&lt;w:body&gt;</w:t></w:r></w:p><w:sectPr/></w:body>",
249        ] {
250            let xml = format!("<w:document xmlns:w=\"{binding}\">{content}</w:document>");
251            let expected = (super::extract_section_properties_xml(&xml), super::super::body::body_xml(&xml).to_owned());
252            let Some(index) = crate::xml_index::Index::canonical(&xml) else { continue; };
253            let _scope = crate::xml_index::scope(
254                [("part", xml.as_str())].into_iter(),
255                std::collections::HashMap::from([("part".to_owned(), index)]),
256            );
257            let actual = (super::extract_section_properties_xml(&xml), super::super::body::body_xml(&xml).to_owned());
258            assert_eq!(actual, expected);
259        }
260    }
261}