From 1c4ead3703affd261addbdf501a2a267c29b6b83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 05:44:14 -0700 Subject: [PATCH] Place: Word .docx files place as area type with their formatting File > Place could not place a Word document: a .docx file was "not a format VectorCraft reads". The reference app's File > Place reads Microsoft Word files as text. File > Place now reads .docx files, written from ECMA-376 Part 1 (WordprocessingML, Transitional and Strict), and sets their text as area type the way a text or RTF file is placed. The package is read through its relationships. The formatting comes from the document defaults, the paragraph style (else the default paragraph style), the character style (else the default character style), the styles each is based on, and direct formatting. Bold, italic, caps, small caps, strikethrough and hidden text are toggle properties: a style that turns one on reverses the state the styles before it give. Each run keeps its font (theme fonts from the theme part), size, bold and italic, underline, strikethrough, all caps, small caps, superscript or subscript (in Document Setup's proportions), raised or lowered text and color. Each paragraph keeps its alignment, start, end, first-line and hanging indents, space before and after, and direction; the start indent of a right-to-left paragraph is its right indent. Table cells are separated by tabs and each table row is a paragraph. Text in insertions, hyperlinks, content controls and fields' results is read. Hidden and deleted text, field instructions, pictures, text boxes, headers, footers, notes and list numbers are left out. A ZIP archive named .docx, or one that holds word/document.xml, is read as a Word document. fileio/zip.rs gains a reader for stored and deflated files that checks each file's CRC. A package with more than 10,000 files, a part that unpacks to more than 16 MiB or nests elements more than 256 deep, and a document with more than 4 MiB of text or 100,000 paragraphs or runs are refused with an error. The Place dialog lists Word files, and file.place, file.place.info and file.place.queue report the format `docx`. .doc files are still not read. The Word document tests in tests_textimport.rs fail without the change, and text_import_fuzz.rs places random and damaged packages. Docs: docs/file-format-parity.md, docs/mcp.md, docs/target-app-parity.md. --- Cargo.lock | 1 + crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/fileio/mod.rs | 16 +- crates/engine/src/cmd/fileio/zip.rs | 144 ++++ crates/engine/src/cmd/place/docx.rs | 977 ++++++++++++++++++++++++ crates/engine/src/cmd/place/mod.rs | 14 +- crates/engine/src/cmd/place/text.rs | 5 +- crates/engine/src/tests_textimport.rs | 279 ++++++- crates/engine/tests/text_import_fuzz.rs | 120 ++- docs/file-format-parity.md | 5 +- docs/mcp.md | 9 + docs/target-app-parity.md | 1 + 12 files changed, 1556 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/place/docx.rs diff --git a/Cargo.lock b/Cargo.lock index d0adfeda5..af0f070c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,6 +4657,7 @@ dependencies = [ "kurbo 0.13.1", "log", "proptest", + "roxmltree 0.21.1", "ruzstd", "serde", "serde_json", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4e871482f..0a615dcdb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,6 +35,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } flate2 = { workspace = true } +# The XML parts of Word documents placed as type (place/docx.rs). +roxmltree = "0.21" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e6b745bb4..5da4fa250 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -8,7 +8,7 @@ //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. //! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip` -//! packs its files as one download. +//! packs its files as one download, and reads the files of Word documents placed as type. //! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. @@ -45,7 +45,7 @@ mod save; mod screens; mod svg; mod text; -mod zip; +pub mod zip; use serde_json::{Value, json}; @@ -710,7 +710,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"]; /// RTF files: File → Place sets them as area type with their formatting. pub const RTF_EXTS: &[&str] = &["rtf"]; -/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`]. +/// Word documents: File → Place sets them as area type with their formatting. +pub const DOCX_EXTS: &[&str] = &["docx"]; + +/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and +/// [`DOCX_EXTS`]. pub const PLACE_EXTS: &[&str] = &[ "vectorcraft", "drawcraft", @@ -741,6 +745,7 @@ pub const PLACE_EXTS: &[&str] = &[ "afpub", "txt", "rtf", + "docx", ]; /// One dialog filter per readable format. @@ -764,13 +769,14 @@ pub fn open_filters() -> impl Iterator impl Iterator { std::iter::once(("All placeable files", PLACE_EXTS)) .chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext)))) .chain(std::iter::once(("Text", TEXT_EXTS))) .chain(std::iter::once(("RTF", RTF_EXTS))) + .chain(std::iter::once(("Word", DOCX_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`). diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs index b3a9a397b..f863c3cfd 100644 --- a/crates/engine/src/cmd/fileio/zip.rs +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -1,5 +1,10 @@ //! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are //! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read. +//! +//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT +//! describes the format (). + +use std::io::Read as _; use flate2::Crc; @@ -65,6 +70,145 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, Ok(out) } +/// Does `bytes` start with a ZIP archive's first local file header? +pub fn is_zip(bytes: &[u8]) -> bool { + bytes.starts_with(&0x0403_4b50u32.to_le_bytes()) +} + +/// Why [`Archive`] can't read an archive or one of its files. +#[derive(Debug, PartialEq)] +pub enum ReadError { + /// Not a ZIP archive: no end of central directory record. + NotZip, + /// More files than the reader was asked to accept. + TooManyFiles, + /// The archive needs the 64-bit (ZIP64) records, which the reader does not read. + Zip64, + /// The file is encrypted. + Encrypted(String), + /// The file is compressed with a method other than stored (0) or deflated (8). + Method(String, u16), + /// The file unpacks to more bytes than the reader was asked to accept. + TooLarge(String), + /// The archive (empty name) or the file is truncated, inconsistent or fails its CRC. + Damaged(String), +} + +/// One file of an archive, as its central directory record describes it. +struct Entry { + name: String, + flags: u16, + method: u16, + crc: u32, + packed: usize, + size: usize, + local: usize, +} + +/// A ZIP archive's files, listed from its central directory. +pub struct Archive<'a> { + zip: &'a [u8], + entries: Vec, +} + +impl<'a> Archive<'a> { + /// The archive `zip`, with at most `max_files` files. + pub fn open(zip: &'a [u8], max_files: usize) -> Result { + let damaged = || ReadError::Damaged(String::new()); + let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + // The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature + // whose comment ends the archive, else the last signature (the archive has bytes after it). + let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?; + let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50)); + let end = + candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?; + let count = u16_at(end + 10).ok_or_else(damaged)?; + let start = u32_at(end + 16).ok_or_else(damaged)?; + if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) { + return Err(ReadError::Zip64); + } + let count = usize::from(count); + if count > max_files { + return Err(ReadError::TooManyFiles); + } + let mut at = start as usize; + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + if u32_at(at) != Some(0x0201_4b50) { + return Err(damaged()); + } + let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged); + let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged); + let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?)); + let name_at = at.checked_add(46).ok_or_else(damaged)?; + let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?; + entries.push(Entry { + name: String::from_utf8_lossy(name).into_owned(), + flags: short(8)?, + method: short(10)?, + crc: long(16)?, + packed: long(20)? as usize, + size: long(24)? as usize, + local: long(42)? as usize, + }); + at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?; + } + Ok(Self { zip, entries }) + } + + /// The file `name`, matched in any ASCII case. + fn entry(&self, name: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name)) + } + + /// Does the archive hold the file `name` (in any ASCII case)? + pub fn contains(&self, name: &str) -> bool { + self.entry(name).is_some() + } + + /// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such + /// file), when it unpacks to at most `max` bytes and passes its CRC. + pub fn read(&self, name: &str, max: usize) -> Result>, ReadError> { + let Some(e) = self.entry(name) else { return Ok(None) }; + let damaged = || ReadError::Damaged(e.name.clone()); + if e.flags & 1 != 0 { + return Err(ReadError::Encrypted(e.name.clone())); + } + if e.size > max { + return Err(ReadError::TooLarge(e.name.clone())); + } + // The local header: its signature, then its name and extra field lengths at 26 and 28. + let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?; + let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) }; + if sig != 0x0403_4b50u32.to_le_bytes() { + return Err(damaged()); + } + let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged()); + let start = e.local + 30 + len(name_len)? + len(extra)?; + let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?; + let bytes = match e.method { + 0 if e.packed != e.size => return Err(damaged()), + 0 => data.to_vec(), + 8 => { + let mut out = Vec::with_capacity(e.size); + flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?; + if out.len() > max { + return Err(ReadError::TooLarge(e.name.clone())); + } + out + } + m => return Err(ReadError::Method(e.name.clone(), m)), + }; + let mut crc = Crc::new(); + crc.update(&bytes); + if bytes.len() != e.size || crc.sum() != e.crc { + return Err(damaged()); + } + Ok(Some(bytes)) + } +} + /// The entries of a store-only archive written by [`store`] (read through its central directory, /// checking every CRC) → `(name, bytes)`. #[cfg(test)] diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs new file mode 100644 index 000000000..400f4d168 --- /dev/null +++ b/crates/engine/src/cmd/place/docx.rs @@ -0,0 +1,977 @@ +//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph +//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML +//! (), in its +//! Transitional and Strict namespaces. +//! +//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part +//! (else `word/document.xml`), and that part's relationships name its styles and theme parts +//! (else `styles.xml` and `theme/theme1.xml` next to it). +//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are +//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new +//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and +//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks, +//! smart tags, content controls, custom XML and simple fields is read, and of a complex field, +//! its result. +//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then +//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic, +//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small +//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and +//! the color. +//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`, +//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and +//! hanging indents, space before and after, and direction (`w:bidi`). +//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph +//! style) and the styles it is based on, then the run's character style (else the default +//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps, +//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns +//! one on reverses the state the levels before it give, and direct formatting sets it. +//! +//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol +//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list +//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more +//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with +//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or +//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers +//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper. + +use std::collections::HashMap; + +use roxmltree::Node; +use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; + +use super::text::{self, Story}; +use super::*; +use crate::cmd::fileio::zip::{self, Archive, ReadError}; + +/// Word documents as `file.place` reports them (not a format `document.open` reads). +pub(super) const FORMAT: fileio::Format = fileio::Format { + id: "docx", + label: "Word", + extensions: fileio::DOCX_EXTS, + mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + read: true, + write: false, + raster: false, + options: &[], +}; + +/// The most files a Word document's package may hold. +const MAX_FILES: usize = 10_000; +/// The most bytes a part read from the package may unpack to. +const MAX_PART: usize = 16 << 20; +/// The most XML nodes a part read from the package may have. +const MAX_NODES: u32 = 2_000_000; +/// The deepest nesting of XML elements a part read from the package may have. +const MAX_NESTING: usize = 256; +/// The deepest nesting of tables, content controls and other containers read; deeper ones are +/// skipped, and so are fields nested deeper. +const MAX_DEPTH: usize = 64; +/// The most styles read from the styles part. +const MAX_STYLES: usize = 4096; +/// The most styles one style's `w:basedOn` chain takes in. +const MAX_BASED_ON: usize = 32; +/// The most fonts the document's formatting names. +const MAX_FONTS: usize = 4096; +/// The most bytes of a font name kept. +const MAX_NAME: usize = 256; +/// The most paragraphs placed type takes from one Word document. +const MAX_PARAGRAPHS: usize = 100_000; +/// The most runs of one character formatting placed type takes from one Word document. +const MAX_RUNS: usize = 100_000; + +/// WordprocessingML's namespaces: Transitional, then Strict. +const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]; + +/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds +/// `word/document.xml`? +pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool { + zip::is_zip(bytes) + && (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str()) + || Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml"))) +} + +/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph +/// formatting. +pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { + let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?; + let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into()); + let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) }; + let doc = parse(&xml, &main, cmd)?; + if local(doc.root_element()) != Some("document") { + return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document")); + } + let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default(); + let mut r = Reader::default(); + let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml")); + if let Some(xml) = part(&zip, &theme, cmd)? { + r.theme_fonts(&parse(&xml, &theme, cmd)?); + } + let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml")); + if let Some(xml) = part(&zip, &styles, cmd)? { + r.styles(&parse(&xml, &styles, cmd)?); + } + if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { + r.block(body, 0); + } + r.finish(cmd) +} + +/// The error for a package (or a part of it) the ZIP reader can't read. +fn package_error(cmd: &str, e: ReadError) -> EngineError { + bad( + cmd, + match e { + ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(), + ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"), + ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(), + ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"), + ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"), + ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20), + ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(), + ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"), + }, + ) +} + +/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the +/// package has no such part. +fn part(zip: &Archive, name: &str, cmd: &str) -> Result> { + let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) }; + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + let text = match &bytes[..] { + [0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string), + [0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)), + [0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)), + _ => std::str::from_utf8(&bytes).map(str::to_string), + }; + text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text"))) +} + +/// The XML of the part `name`. +fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result> { + // roxmltree parses nested elements recursively. + if nesting(xml) > MAX_NESTING { + return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; + roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) +} + +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) + }; + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most +} + +/// The part that the first relationship of part `source` (`""`: the package) whose type ends in +/// `/{kind}` points to; None when the package has no relationships part for `source` or it names +/// no such part. +fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result> { + let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default())); + let rels = format!("{folder}_rels/{file}.rels"); + let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) }; + let doc = parse(&xml, &rels, cmd)?; + let target = doc + .root_element() + .children() + .filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External")) + .find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) + .and_then(|n| n.attribute("Target")); + Ok(target.map(|t| resolve(folder, t))) +} + +/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`). +fn resolve(folder: &str, target: &str) -> String { + let joined = match target.strip_prefix('/') { + Some(absolute) => absolute.to_string(), + None => format!("{folder}{target}"), + }; + let mut parts: Vec<&str> = vec![]; + for segment in joined.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +/// `n`'s local name when it is a WordprocessingML element. +fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> { + (n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name()) +} + +/// `n`'s first WordprocessingML child element named `name`. +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option> { + n.children().find(|c| local(*c) == Some(name)) +} + +/// `n`'s WordprocessingML attribute `name`. +fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> { + n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value()) +} + +/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. +fn on(n: Node) -> bool { + !matches!(attr(n, "val"), Some("false" | "0" | "off")) +} + +/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit +/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`). +fn measure(v: &str, per_pt: f64) -> Option { + const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)]; + let v = v.trim(); + let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) { + Some((n, pt)) => n.trim().parse::().ok()? * pt * per_pt, + None => v.parse::().ok()?, + }; + n.is_finite().then_some(n) +} + +/// Twentieths of a point. +fn twips(n: Node, name: &str) -> Option { + attr(n, name).and_then(|v| measure(v, 20.0)) +} + +/// Half points. +fn half_points(n: Node) -> Option { + attr(n, "val").and_then(|v| measure(v, 2.0)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Script { + #[default] + Normal, + Super, + Sub, +} + +/// The run properties one level of the style hierarchy sets (None: it doesn't set that one). +#[derive(Clone, Copy, Debug, Default)] +struct RunProps { + /// The font's number in [`Reader::fonts`]. + font: Option, + half_points: Option, + bold: Option, + italic: Option, + caps: Option, + small_caps: Option, + strike: Option, + dstrike: Option, + hidden: Option, + underline: Option, + script: Option