docxide-pdf 0.11.0

Library and CLI for converting DOCX files to PDF, matching Microsoft Word's output as closely as possible
Documentation
use std::collections::HashMap;

use super::{WML_NS, parse_hex_color, twips_attr, wml, wml_attr, wml_bool};

#[derive(Clone)]
pub(super) struct LevelDef {
    pub(super) num_fmt: String,
    pub(super) lvl_text: String,
    pub(super) indent_left: f32,
    pub(super) indent_hanging: f32,
    pub(super) tab_stop: Option<f32>,
    pub(super) start: u32,
    pub(super) bullet_font: Option<String>,
    pub(super) label_font_size: Option<f32>,
    pub(super) label_bold: bool,
    pub(super) label_color: Option<[u8; 3]>,
    /// w:suff value: "tab" (default), "space", or "nothing"
    pub(super) suff: String,
    /// Font name from rPr/rFonts for non-bullet labels
    pub(super) label_font: Option<String>,
}

#[derive(Default)]
pub(super) struct ListLabelInfo {
    pub(super) indent_left: f32,
    pub(super) indent_hanging: f32,
    pub(super) tab_stop: Option<f32>,
    pub(super) label: String,
    pub(super) font: Option<String>,
    pub(super) font_size: Option<f32>,
    pub(super) bold: bool,
    pub(super) color: Option<[u8; 3]>,
    /// w:suff value: "tab" (default), "space", or "nothing"
    pub(super) suff: String,
}

#[derive(Default)]
pub(super) struct NumberingInfo {
    pub(super) abstract_nums: HashMap<String, HashMap<u8, LevelDef>>,
    pub(super) num_to_abstract: HashMap<String, String>,
    pub(super) start_overrides: HashMap<String, HashMap<u8, u32>>,
}

pub(super) fn parse_numbering<R: std::io::Read + std::io::Seek>(
    zip: &mut zip::ZipArchive<R>,
) -> NumberingInfo {
    let Some(xml_content) = super::read_zip_text(zip, "word/numbering.xml") else {
        return NumberingInfo::default();
    };
    let Ok(xml) = roxmltree::Document::parse(&xml_content) else {
        return NumberingInfo::default();
    };

    let mut abstract_nums: HashMap<String, HashMap<u8, LevelDef>> = HashMap::new();
    let mut num_to_abstract: HashMap<String, String> = HashMap::new();
    let mut num_style_link: HashMap<String, String> = HashMap::new();
    let mut style_link_target: HashMap<String, String> = HashMap::new();
    let mut start_overrides: HashMap<String, HashMap<u8, u32>> = HashMap::new();

    let root = xml.root_element();

    for node in root.children() {
        if node.tag_name().namespace() != Some(WML_NS) {
            continue;
        }
        match node.tag_name().name() {
            "abstractNum" => {
                let Some(abs_id) = node.attribute((WML_NS, "abstractNumId")) else {
                    continue;
                };
                let mut levels: HashMap<u8, LevelDef> = HashMap::new();
                for lvl in node.children().filter(|n| n.has_tag_name((WML_NS, "lvl"))) {
                    let Some(ilvl) = lvl
                        .attribute((WML_NS, "ilvl"))
                        .and_then(|v| v.parse::<u8>().ok())
                    else {
                        continue;
                    };
                    let num_fmt = wml_attr(lvl, "numFmt").unwrap_or("bullet").to_string();
                    let lvl_text = wml_attr(lvl, "lvlText").unwrap_or("").to_string();
                    let start = wml_attr(lvl, "start")
                        .and_then(|v| v.parse::<u32>().ok())
                        .unwrap_or(1);
                    let ind = wml(lvl, "pPr").and_then(|ppr| wml(ppr, "ind"));
                    let indent_left = ind
                        .and_then(|n| twips_attr(n, "start").or_else(|| twips_attr(n, "left")))
                        .unwrap_or(0.0);
                    let indent_hanging = ind.and_then(|n| twips_attr(n, "hanging")).unwrap_or(0.0);
                    let tab_stop = wml(lvl, "pPr")
                        .and_then(|ppr| wml(ppr, "tabs"))
                        .and_then(|tabs| {
                            tabs.children()
                                .filter(|n| n.has_tag_name((WML_NS, "tab")))
                                .find_map(|t| twips_attr(t, "pos"))
                        });
                    let rpr = wml(lvl, "rPr");
                    let bullet_font = rpr
                        .and_then(|r| wml(r, "rFonts"))
                        .and_then(|rf| {
                            rf.attribute((WML_NS, "ascii"))
                                .or_else(|| rf.attribute((WML_NS, "hAnsi")))
                        })
                        .map(|s| s.to_string());
                    let label_font_size = rpr
                        .and_then(|r| wml_attr(r, "sz"))
                        .and_then(|v| v.parse::<f32>().ok())
                        .map(|hp| hp / 2.0);
                    let label_bold = rpr.and_then(|r| wml_bool(r, "b")).unwrap_or(false);
                    let label_color = rpr
                        .and_then(|r| wml_attr(r, "color"))
                        .and_then(parse_hex_color);
                    let suff = wml_attr(lvl, "suff").unwrap_or("tab").to_string();
                    let label_font = rpr
                        .and_then(|r| wml(r, "rFonts"))
                        .and_then(|rf| {
                            rf.attribute((WML_NS, "ascii"))
                                .or_else(|| rf.attribute((WML_NS, "hAnsi")))
                        })
                        .map(|s| s.to_string());
                    levels.insert(
                        ilvl,
                        LevelDef {
                            num_fmt,
                            lvl_text,
                            indent_left,
                            indent_hanging,
                            tab_stop,
                            start,
                            bullet_font,
                            label_font_size,
                            label_bold,
                            label_color,
                            suff,
                            label_font,
                        },
                    );
                }
                abstract_nums.insert(abs_id.to_string(), levels);
                if let Some(link) = wml_attr(node, "numStyleLink") {
                    num_style_link.insert(abs_id.to_string(), link.to_string());
                }
                if let Some(link) = wml_attr(node, "styleLink") {
                    style_link_target.insert(link.to_string(), abs_id.to_string());
                }
            }
            "num" => {
                let Some(num_id) = node.attribute((WML_NS, "numId")) else {
                    continue;
                };
                let Some(abs_id) = wml_attr(node, "abstractNumId") else {
                    continue;
                };
                num_to_abstract.insert(num_id.to_string(), abs_id.to_string());
                let overrides: HashMap<u8, u32> = node
                    .children()
                    .filter(|n| n.has_tag_name((WML_NS, "lvlOverride")))
                    .filter_map(|ovr| {
                        let ilvl = ovr
                            .attribute((WML_NS, "ilvl"))
                            .and_then(|v| v.parse::<u8>().ok())?;
                        let val =
                            wml_attr(ovr, "startOverride").and_then(|v| v.parse::<u32>().ok())?;
                        Some((ilvl, val))
                    })
                    .collect();
                if !overrides.is_empty() {
                    start_overrides.insert(num_id.to_string(), overrides);
                }
            }
            _ => {}
        }
    }

    // Resolve numStyleLink → styleLink chains
    for (abs_id, style_name) in &num_style_link {
        let Some(target_abs_id) = style_link_target.get(style_name) else {
            continue;
        };
        let Some(source_levels) = abstract_nums.get(target_abs_id).cloned() else {
            continue;
        };
        if source_levels.is_empty() {
            continue;
        }
        let entry = abstract_nums.entry(abs_id.clone()).or_default();
        if entry.is_empty() {
            *entry = source_levels;
        }
    }

    NumberingInfo {
        abstract_nums,
        num_to_abstract,
        start_overrides,
    }
}

fn to_roman(mut n: u32) -> String {
    const TABLE: &[(u32, &str)] = &[
        (1000, "m"),
        (900, "cm"),
        (500, "d"),
        (400, "cd"),
        (100, "c"),
        (90, "xc"),
        (50, "l"),
        (40, "xl"),
        (10, "x"),
        (9, "ix"),
        (5, "v"),
        (4, "iv"),
        (1, "i"),
    ];
    let mut result = String::new();
    for &(value, numeral) in TABLE {
        while n >= value {
            result.push_str(numeral);
            n -= value;
        }
    }
    result
}

fn to_letter(value: u32, base: u8) -> String {
    if value == 0 {
        return String::new();
    }
    let mut n = value - 1;
    let mut result = String::new();
    loop {
        result.insert(0, (base + (n % 26) as u8) as char);
        if n < 26 {
            break;
        }
        n = n / 26 - 1;
    }
    result
}

pub(crate) fn format_number(value: u32, num_fmt: &str) -> String {
    match num_fmt {
        "decimal" => value.to_string(),
        "decimalZero" => format!("{value:02}"),
        "lowerLetter" => to_letter(value, b'a'),
        "upperLetter" => to_letter(value, b'A'),
        "lowerRoman" => to_roman(value),
        "upperRoman" => to_roman(value).to_uppercase(),
        "none" => String::new(),
        _ => value.to_string(),
    }
}

fn normalize_bullet_text(text: &str) -> String {
    text.chars()
        .map(|c| {
            let cp = c as u32;
            if (0xF000..=0xF0FF).contains(&cp) {
                symbol_pua_to_unicode(cp).unwrap_or(c)
            } else {
                c
            }
        })
        .collect()
}

fn symbol_pua_to_unicode(cp: u32) -> Option<char> {
    let sym = cp - 0xF000;
    let mapped = match sym {
        0xB7 => '\u{2022}', // bullet •
        0xA7 => '\u{25A0}', // black square ■ (Wingdings §)
        0xA8 => '\u{25CB}', // white circle ○
        0xD8 => '\u{2666}', // diamond ◆
        0x76 => '\u{221A}', // check mark √
        _ => return char::from_u32(sym),
    };
    Some(mapped)
}

pub(super) fn parse_list_info(
    num_pr: Option<roxmltree::Node>,
    style_num_id: Option<&str>,
    style_num_ilvl: Option<u8>,
    numbering: &NumberingInfo,
    counters: &mut HashMap<(String, u8), u32>,
    last_seen_level: &mut HashMap<String, u8>,
) -> ListLabelInfo {
    let (num_id, ilvl) = if let Some(np) = num_pr {
        let nid = wml_attr(np, "numId");
        let il = wml_attr(np, "ilvl")
            .and_then(|v| v.parse::<u8>().ok())
            .unwrap_or(0);
        (nid, il)
    } else if let Some(sn) = style_num_id {
        (Some(sn), style_num_ilvl.unwrap_or(0))
    } else {
        return ListLabelInfo::default();
    };
    let Some(num_id) = num_id else {
        return ListLabelInfo::default();
    };
    if num_id == "0" {
        return ListLabelInfo::default();
    }
    let Some(abs_id) = numbering.num_to_abstract.get(num_id) else {
        return ListLabelInfo::default();
    };
    let Some(levels) = numbering.abstract_nums.get(abs_id.as_str()) else {
        return ListLabelInfo::default();
    };
    let Some(def) = levels.get(&ilvl) else {
        return ListLabelInfo::default();
    };

    // Reset deeper-level counters when returning to a higher level
    if let Some(&prev) = last_seen_level.get(num_id) {
        for deeper in (ilvl + 1)..=prev {
            counters.remove(&(num_id.to_string(), deeper));
        }
    }
    last_seen_level.insert(num_id.to_string(), ilvl);

    // Increment or initialize counter using the level's start value
    let start = numbering
        .start_overrides
        .get(num_id)
        .and_then(|m| m.get(&ilvl))
        .copied()
        .unwrap_or(def.start);
    let current_counter = *counters
        .entry((num_id.to_string(), ilvl))
        .and_modify(|c| *c += 1)
        .or_insert(start);

    let is_bullet = def.num_fmt == "bullet";
    let original_had_pua = is_bullet
        && def
            .lvl_text
            .chars()
            .any(|c| (0xF000..=0xF0FF).contains(&(c as u32)));
    let label = if is_bullet {
        if original_had_pua && def.bullet_font.is_some() {
            // Keep PUA chars for symbol fonts — their cmaps expect PUA encoding
            def.lvl_text.clone()
        } else {
            let text = normalize_bullet_text(&def.lvl_text);
            if text.is_empty() {
                "\u{2022}".to_string()
            } else {
                text
            }
        }
    } else {
        let mut label = def.lvl_text.clone();
        for lvl_idx in 0..9u8 {
            let placeholder = format!("%{}", lvl_idx + 1);
            if label.contains(&placeholder) {
                let lvl_counter = if lvl_idx == ilvl {
                    current_counter
                } else {
                    counters
                        .get(&(num_id.to_string(), lvl_idx))
                        .copied()
                        .unwrap_or(levels.get(&lvl_idx).map(|d| d.start).unwrap_or(1))
                };
                let lvl_fmt = levels
                    .get(&lvl_idx)
                    .map(|d| d.num_fmt.as_str())
                    .unwrap_or("decimal");
                label = label.replace(&placeholder, &format_number(lvl_counter, lvl_fmt));
            }
        }
        label
    };
    ListLabelInfo {
        indent_left: def.indent_left,
        indent_hanging: def.indent_hanging,
        tab_stop: def.tab_stop,
        label,
        font: if is_bullet {
            def.bullet_font.clone()
        } else if def.suff == "nothing" {
            // suff=nothing: label flows inline with text and needs
            // its own font for the synthetic Run we prepend.
            def.label_font.clone()
        } else {
            None
        },
        font_size: def.label_font_size,
        bold: def.label_bold,
        color: def.label_color,
        suff: def.suff.clone(),
    }
}