extend-docx 0.1.1

Rust parser, document model, and serializer for DOCX and legacy Word documents
Documentation
use std::collections::{BTreeMap, HashMap};

use super::{fmt::escape_xml, DocFile};

pub(super) struct LinkRange {
    pub start: u32,
    pub end: u32,
    pub instruction: String,
}

pub(super) fn spanning_links(doc: &DocFile) -> Vec<LinkRange> {
    let mut links = Vec::new();
    let Some(data) = doc.fib.table_slice(&doc.table, 16) else {
        return links;
    };
    let Some(payload) = data.len().checked_sub(4).filter(|n| n % 6 == 0) else {
        return links;
    };
    let count = payload / 6;
    let mut stack = Vec::<(u32, u8, Option<u32>)>::new();
    for index in 0..count {
        let Some(cp) = u32_at(data, index * 4) else {
            continue;
        };
        if cp >= doc.fib.ccp.text {
            continue;
        }
        let at = (count + 1) * 4 + index * 2;
        match data[at] & 0x1f {
            0x13 => stack.push((cp, data[at + 1], None)),
            0x14 => {
                if let Some(field) = stack.last_mut() {
                    field.2 = Some(cp);
                }
            }
            0x15 => {
                let Some((start, kind, Some(separator))) = stack.pop() else {
                    continue;
                };
                if start >= separator || separator >= cp || separator - start > 65536 {
                    continue;
                }
                let keyword = match kind {
                    0x03 => "REF",
                    0x05 | 0x48 => "NOTEREF",
                    0x25 => "PAGEREF",
                    0x58 => "HYPERLINK",
                    _ => continue,
                };
                if !doc.text(separator + 1, cp).contains(&13) {
                    continue;
                }
                let original = String::from_utf16_lossy(&doc.text(start + 1, separator));
                let original = original.trim_start();
                let split = original
                    .find(|c: char| c.is_whitespace() || c == '"' || c == '\\')
                    .unwrap_or(original.len());
                let tail: String = original[split..]
                    .chars()
                    .filter(|c| *c >= ' ' || matches!(c, '\t' | '\r' | '\n'))
                    .collect();
                links.push(LinkRange {
                    start: separator + 1,
                    end: cp,
                    instruction: format!("{keyword}{tail}"),
                });
            }
            _ => {}
        }
    }
    links
}

fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
    Some(u16::from_le_bytes(
        bytes.get(at..at.checked_add(2)?)?.try_into().ok()?,
    ))
}

fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
    Some(u32::from_le_bytes(
        bytes.get(at..at.checked_add(4)?)?.try_into().ok()?,
    ))
}

fn stories(doc: &DocFile) -> [(usize, u32, u32); 7] {
    let ccp = &doc.fib.ccp;
    [
        (16, 0, ccp.text),
        (17, ccp.hdd_start(), ccp.hdd),
        (18, ccp.ftn_start(), ccp.ftn),
        (
            19,
            ccp.hdd_start()
                .saturating_add(ccp.hdd)
                .saturating_add(ccp.mcr),
            ccp.atn,
        ),
        (48, ccp.edn_start(), ccp.edn),
        (57, ccp.txbx_start(), ccp.txbx),
        (59, ccp.hdr_txbx_start(), ccp.hdr_txbx),
    ]
}

#[derive(Default)]
pub(super) struct FieldText {
    pub instructions: HashMap<u32, String>,
    pub phonetic_results: HashMap<u32, String>,
}

pub(super) fn field_text(doc: &DocFile) -> FieldText {
    let mut result = FieldText::default();
    for (index, base, length) in stories(doc) {
        let Some(data) = doc.fib.table_slice(&doc.table, index) else {
            continue;
        };
        let Some(payload) = data.len().checked_sub(4).filter(|n| n % 6 == 0) else {
            continue;
        };
        let count = payload / 6;
        for field in 0..count.saturating_sub(1) {
            let at = (count + 1) * 4 + field * 2;
            if data[at] & 0x1f != 0x13 {
                continue;
            }
            let keyword = match (data[at + 1], data[at + 2] & 0x1f) {
                (0x03, 0x14) => Some("REF"),
                (0x05 | 0x48, 0x14) => Some("NOTEREF"),
                (0x25, 0x14) => Some("PAGEREF"),
                (0x58, 0x14) => Some("HYPERLINK"),
                (0x31, 0x15) => None,
                _ => continue,
            };
            let Some(start) = u32_at(data, field * 4) else {
                continue;
            };
            let Some(end) = u32_at(data, (field + 1) * 4) else {
                continue;
            };
            if start >= end || end >= length || end - start > 65536 {
                continue;
            }
            let instruction = String::from_utf16_lossy(&doc.text(base + start + 1, base + end));
            let Some(keyword) = keyword else {
                if let Some(text) = phonetic_text(&instruction) {
                    result.phonetic_results.insert(base + end, text);
                }
                continue;
            };
            let instruction = instruction.trim_start();
            let split = instruction
                .find(|c: char| c.is_whitespace() || c == '"' || c == '\\')
                .unwrap_or(instruction.len());
            if instruction[..split].eq_ignore_ascii_case(keyword) {
                continue;
            }
            let tail: String = instruction[split..]
                .chars()
                .filter(|c| *c >= ' ' || matches!(c, '\t' | '\r' | '\n'))
                .collect();
            result
                .instructions
                .insert(base + start, format!("{keyword}{tail}"));
        }
    }
    result
}

fn equation_arguments(input: &str) -> Option<(Vec<&str>, &str)> {
    let input = input.trim_start().strip_prefix('(')?;
    let mut depth = 1usize;
    let mut escaped = false;
    let mut start = 0;
    let mut arguments = Vec::new();
    for (index, character) in input.char_indices() {
        if escaped {
            escaped = false;
            continue;
        }
        match character {
            '\\' => escaped = true,
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth == 0 {
                    arguments.push(&input[start..index]);
                    return Some((arguments, &input[index + 1..]));
                }
            }
            ',' if depth == 1 => {
                arguments.push(&input[start..index]);
                start = index + 1;
            }
            _ => {}
        }
    }
    None
}

fn equation_literal(input: &str) -> Option<String> {
    let mut output = String::new();
    let mut characters = input.chars();
    while let Some(character) = characters.next() {
        match character {
            '\\' => match characters.next()? {
                escaped @ ('\\' | '(' | ')' | ',') => output.push(escaped),
                _ => return None,
            },
            character if character.is_control() => return None,
            character => output.push(character),
        }
    }
    Some(output)
}

fn phonetic_text(instruction: &str) -> Option<String> {
    static PREFIX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
        regex::Regex::new(
            r#"(?i)^[ \t\r\n]*EQ[ \t\r\n]+(?:\\\*[ \t\r\n]*(?:"[^"]*"|[^ \t\r\n\\()]+)[ \t\r\n]*)*\\o[ \t\r\n]*(?:\\a[dlr][ \t\r\n]*)?"#,
        )
        .expect("valid field grammar")
    });
    static SHIFT: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
        regex::Regex::new(r"(?i)^\\s[ \t\r\n]*\\up[ \t\r\n]*[+-]?[0-9]+(?:\.[0-9]+)?[ \t\r\n]*")
            .expect("valid field grammar")
    });
    let prefix = PREFIX.find(instruction)?;
    let (overlay, tail) = equation_arguments(&instruction[prefix.end()..])?;
    if overlay.len() != 2 || !tail.trim().is_empty() {
        return None;
    }
    let raised = overlay[0].trim();
    let shift = SHIFT.find(raised)?;
    let (annotation, tail) = equation_arguments(&raised[shift.end()..])?;
    if annotation.len() != 1 || !tail.trim().is_empty() {
        return None;
    }
    let mut result = equation_literal(annotation[0])?;
    result.push_str(&equation_literal(overlay[1])?);
    Some(result)
}

pub(super) fn bookmarks(doc: &DocFile) -> BTreeMap<u32, String> {
    let mut result = BTreeMap::<u32, String>::new();
    let Some(names) = doc.fib.table_slice(&doc.table, 21) else {
        return result;
    };
    let Some(starts) = doc.fib.table_slice(&doc.table, 22) else {
        return result;
    };
    let Some(ends) = doc.fib.table_slice(&doc.table, 23) else {
        return result;
    };
    if u16_at(names, 0) != Some(0xffff) {
        return result;
    }
    let Some(count) = u16_at(names, 2) else {
        return result;
    };
    let Some(extra) = u16_at(names, 4) else {
        return result;
    };
    let Some(payload) = starts.len().checked_sub(4).filter(|n| n % 8 == 0) else {
        return result;
    };
    let records = payload / 8;
    let mut cursor = 6;
    for index in 0..usize::from(count).min(records) {
        let Some(length) = u16_at(names, cursor) else {
            break;
        };
        cursor += 2;
        let Some(text) = names.get(cursor..cursor + usize::from(length) * 2) else {
            break;
        };
        cursor += text.len() + usize::from(extra);
        let name = String::from_utf16_lossy(
            &text
                .chunks_exact(2)
                .map(|b| u16::from_le_bytes([b[0], b[1]]))
                .collect::<Vec<_>>(),
        );
        let Some(start) = u32_at(starts, index * 4) else {
            continue;
        };
        let Some(end_index) = u16_at(starts, (records + 1) * 4 + index * 4) else {
            continue;
        };
        if usize::from(end_index) >= records {
            continue;
        }
        let Some(end) = u32_at(ends, usize::from(end_index) * 4) else {
            continue;
        };
        if name.is_empty() || start > end || end >= doc.fib.ccp.text {
            continue;
        }
        result.entry(start).or_default().push_str(&format!(
            r#"<w:bookmarkStart w:id="{index}" w:name="{}"/>"#,
            escape_xml(&name)
        ));
        result
            .entry(end)
            .or_default()
            .push_str(&format!(r#"<w:bookmarkEnd w:id="{index}"/>"#));
    }
    result
}