extend-docx 0.1.1

Rust parser, document model, and serializer for DOCX and legacy Word documents
Documentation
use super::colors::normalize_hex_color;
use crate::model::DocumentCompatibilitySettings;
use crate::package::OoxmlPackage;
use crate::xml::{
    extract_balanced_tag_blocks, get_attribute, parse_integer_attribute, parse_on_off_attribute,
};

pub fn parse_document_default_tab_stop_twips(pkg: &OoxmlPackage) -> Option<i64> {
    let settings_xml = crate::package::related_document_part(pkg, "settings")?
        .content
        .as_str();
    let tab_stop_tag = super::scan::find_tag_token(settings_xml, "w:defaultTabStop")?;
    parse_integer_attribute(&tab_stop_tag, "w:val").filter(|value| *value >= 0)
}

/// Mirrors TypeScript `extractBodyXml`.
fn extract_body_xml(document_xml: &str) -> &str {
    if let Some(body) = crate::xml_index::unique_inner_xml(document_xml, "w:body") {
        if !body.is_empty() {
            return body;
        }
    }
    if let Some(start) = find_subsequence_ignore_ascii_case(document_xml, "<w:body") {
        let after_open = &document_xml[start..];
        let open_end = after_open.find('>').map(|index| start + index + 1);
        let close_start = find_subsequence_ignore_ascii_case(document_xml, "</w:body>");
        if let (Some(open_end), Some(close_start)) = (open_end, close_start) {
            if open_end < close_start {
                return &document_xml[open_end..close_start];
            }
        }
    }
    document_xml
}

/// Mirrors TypeScript `parseDocumentBackgroundColor`.
pub fn parse_document_background_color(document_xml: &str) -> Option<String> {
    let background_tag = super::scan::find_tag_token(document_xml, "w:background")?;
    let color_value = find_namespaced_attribute_value(&background_tag, "w:color");
    normalize_hex_color(color_value.as_deref())
}

/// Mirrors TypeScript `extractDocumentOpenTag`.
pub fn extract_document_open_tag(document_xml: &str) -> Option<String> {
    let start = find_subsequence_ignore_ascii_case(document_xml, "<w:document")?;
    let after_open = &document_xml[start..];
    let end = after_open.find('>')? + start + 1;
    Some(document_xml[start..end].to_string())
}

/// Mirrors TypeScript `extractSectionPropertiesXml`.
pub fn extract_section_properties_xml(document_xml: &str) -> Option<String> {
    let body_xml = extract_body_xml(document_xml);
    let blocks = extract_balanced_tag_blocks(body_xml, "w:sectPr");
    blocks.into_iter().last()
}

/// Mirrors TypeScript `parseDocumentPageCountFromAppProperties`.
pub fn parse_document_page_count_from_app_properties(pkg: &OoxmlPackage) -> Option<i64> {
    let app_xml = pkg
        .parts
        .get("docProps/app.xml")
        .map(|part| part.content.as_str())
        .unwrap_or("");

    if app_xml.is_empty() {
        return None;
    }

    let pages_raw = extract_simple_tag_text(app_xml, "Pages")?;
    let parsed = pages_raw.trim().parse::<i64>().ok()?;
    if parsed > 0 {
        Some(parsed)
    } else {
        None
    }
}

/// Mirrors TypeScript `parseFirstOnOffSetting`.
fn parse_first_on_off_setting(settings_xml: &str, tag_names: &[&str]) -> Option<bool> {
    for tag_name in tag_names {
        if let Some(tag) =
            super::styles::direct_table_property(settings_xml, &format!("w:{tag_name}"))
        {
            return parse_on_off_attribute(tag, tag_name);
        }
    }
    None
}

/// Mirrors TypeScript `parseDocumentCompatibilitySettings`.
pub fn parse_document_compatibility_settings(
    pkg: &OoxmlPackage,
) -> Option<DocumentCompatibilitySettings> {
    let settings_xml = crate::package::related_document_part(pkg, "settings")
        .map(|part| part.content.as_str())
        .unwrap_or("");

    if settings_xml.is_empty() {
        return None;
    }

    let settings_root = extract_balanced_tag_blocks(settings_xml, "w:settings")
        .into_iter()
        .next()
        .unwrap_or_else(|| settings_xml.to_string());
    let compat_xml =
        super::styles::direct_table_property(&settings_root, "w:compat").unwrap_or_default();

    let compatibility_mode = super::scan::find_all_tag_tokens(&compat_xml, "w:compatSetting")
        .into_iter()
        .find_map(|tag| {
            if get_attribute(&tag, "w:name").as_deref() != Some("compatibilityMode")
                || get_attribute(&tag, "w:uri").as_deref()
                    != Some("http://schemas.microsoft.com/office/word")
            {
                return None;
            }
            get_attribute(&tag, "w:val")?
                .parse::<u32>()
                .ok()
                .map(i64::from)
        });

    let adjust_line_height_in_table =
        parse_first_on_off_setting(compat_xml, &["adjustLineHeightInTable"]);
    let no_leading = parse_first_on_off_setting(compat_xml, &["noLeading"]);
    let enable_open_type_features =
        super::scan::find_all_tag_tokens(&compat_xml, "w:compatSetting")
            .into_iter()
            .find_map(|tag| {
                if get_attribute(&tag, "w:name").as_deref() != Some("enableOpenTypeFeatures")
                    || get_attribute(&tag, "w:uri").as_deref()
                        != Some("http://schemas.microsoft.com/office/word")
                {
                    return None;
                }
                match get_attribute(&tag, "w:val").as_deref() {
                    Some("1" | "true") => Some(true),
                    Some("0" | "false") => Some(false),
                    _ => None,
                }
            });
    let suppress_top_spacing_wp = parse_first_on_off_setting(compat_xml, &["suppressTopSpacingWP"]);
    let truncate_font_heights_like_wp6 =
        parse_first_on_off_setting(compat_xml, &["truncateFontHeightsLikeWP6"]);
    let no_extra_line_spacing = parse_first_on_off_setting(compat_xml, &["noExtraLineSpacing"]);
    let space_for_ul = parse_first_on_off_setting(compat_xml, &["spaceForUL"]);
    let suppress_bottom_spacing =
        parse_first_on_off_setting(compat_xml, &["suppressBottomSpacing"]);

    let suppress_spacing_before_after_page_break =
        parse_first_on_off_setting(&compat_xml, &["suppressSpBfAfterPgBrk"]);
    let use_printer_metrics = parse_first_on_off_setting(&compat_xml, &["usePrinterMetrics"]);
    let use_fixed_html_paragraph_spacing =
        parse_first_on_off_setting(&compat_xml, &["doNotUseHTMLParagraphAutoSpacing"]);
    let do_not_break_wrapped_tables = parse_first_on_off_setting(
        &compat_xml,
        &["doNotBreakWrappedTables", "dontBreakWrappedTables"],
    );
    let do_not_break_constrained_forced_table = parse_first_on_off_setting(
        &compat_xml,
        &[
            "doNotBreakConstrainedForcedTable",
            "dontBreakConstrainedForcedTable",
        ],
    );
    let even_and_odd_headers = parse_first_on_off_setting(&settings_root, &["evenAndOddHeaders"]);

    if adjust_line_height_in_table.is_none()
        && no_leading.is_none()
        && suppress_top_spacing_wp.is_none()
        && truncate_font_heights_like_wp6.is_none()
        && no_extra_line_spacing.is_none()
        && space_for_ul.is_none()
        && suppress_bottom_spacing.is_none()
        && compatibility_mode.is_none()
        && enable_open_type_features.is_none()
        && suppress_spacing_before_after_page_break.is_none()
        && use_printer_metrics.is_none()
        && use_fixed_html_paragraph_spacing.is_none()
        && do_not_break_wrapped_tables.is_none()
        && do_not_break_constrained_forced_table.is_none()
        && even_and_odd_headers.is_none()
    {
        return None;
    }

    Some(DocumentCompatibilitySettings {
        adjust_line_height_in_table,
        no_leading,
        suppress_top_spacing_wp,
        truncate_font_heights_like_wp6,
        no_extra_line_spacing,
        space_for_ul,
        suppress_bottom_spacing,

        compatibility_mode,
        enable_open_type_features,
        suppress_spacing_before_after_page_break,
        use_printer_metrics,
        use_fixed_html_paragraph_spacing,
        do_not_break_wrapped_tables,
        do_not_break_constrained_forced_table,
        even_and_odd_headers,
    })
}

fn extract_simple_tag_text(xml: &str, tag_name: &str) -> Option<String> {
    let open = format!("<{tag_name}>");
    let close = format!("</{tag_name}>");
    let start = find_subsequence_ignore_ascii_case(xml, &open)?;
    let content_start = start + open.len();
    let close_start = find_subsequence_ignore_ascii_case(&xml[content_start..], &close)?;
    Some(xml[content_start..content_start + close_start].to_string())
}

fn find_namespaced_attribute_value(tag_xml: &str, attribute: &str) -> Option<String> {
    crate::xml::get_attribute(tag_xml, attribute).or_else(|| {
        let pattern = format!("{attribute}=\"");
        let start = find_subsequence_ignore_ascii_case(tag_xml, &pattern)?;
        let value_start = start + pattern.len();
        let value_end = tag_xml[value_start..].find('"')? + value_start;
        Some(tag_xml[value_start..value_end].to_string())
    })
}

fn find_subsequence_ignore_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
    if needle.is_empty() {
        return Some(0);
    }
    crate::xml::find_ignore_ascii_case(haystack, 0, needle)
}

#[cfg(test)]
mod indexed_body_bounds_tests {
    #[test]
    fn indexed_body_bounds_preserve_legacy_metadata_and_body_results() {
        let binding = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
        for content in [
            "<w:body><w:p/><w:sectPr/></w:body>",
            "<w:body></w:body><w:sectPr/>",
            "<w:body/><w:sectPr/>",
            "<w:bodyExtra/><w:body><w:sectPr/></w:body>",
            "<w:body><w:body><w:sectPr/></w:body><w:sectPr/></w:body>",
            "<w:body><w:sectPr/></w:body><w:body><w:p/></w:body>",
            "<w:BODY><w:p/><w:sectPr/></w:BODY>",
            "<w:body><w:p><w:r><w:t>λ😀&lt;w:body&gt;</w:t></w:r></w:p><w:sectPr/></w:body>",
        ] {
            let xml = format!("<w:document xmlns:w=\"{binding}\">{content}</w:document>");
            let expected = (super::extract_section_properties_xml(&xml), super::super::body::body_xml(&xml).to_owned());
            let Some(index) = crate::xml_index::Index::canonical(&xml) else { continue; };
            let _scope = crate::xml_index::scope(
                [("part", xml.as_str())].into_iter(),
                std::collections::HashMap::from([("part".to_owned(), index)]),
            );
            let actual = (super::extract_section_properties_xml(&xml), super::super::body::body_xml(&xml).to_owned());
            assert_eq!(actual, expected);
        }
    }
}