From 8e40cbe86132823d55c59e735a151857f566cb9f Mon Sep 17 00:00:00 2001 From: Claude Code <1wgrumph+claude-code@gmail.com> Date: Wed, 30 Sep 2026 00:47:55 -0500 Subject: [PATCH 1/4] feat(document): add the DOCX adapter with fidelity receipts and export Map WordprocessingML on top of the shared package intake into a flow model: sections, headings, lists, tables with spans and merges, runs, hyperlinks, bookmarks, captions, footnotes, endnotes, comments, tracked changes kept as tracked changes, and content-addressed images. Every normalized or skipped feature is recorded as a feature:status receipt code. Fields are never evaluated and hyperlinks never fetched. Export writes the model as a deterministic DOCX with a canonical JSON fidelity receipt. DLP and public-boundary findings refuse before any bytes are built, and export_file writes through the shared no-overwrite gate. Import, export, and import again keep the model and anchors. Register the adapter, so every package row runs through it, and make the three DOCX adapter rows executable in the shared corpus. Refs #21 --- crates/bran-document/src/conformance.rs | 2 +- crates/bran-document/src/docx.rs | 2951 +++++++++++++++++ crates/bran-document/src/lib.rs | 1 + crates/bran-document/tests/conformance.rs | 123 +- crates/bran-document/tests/docx.rs | 841 +++++ .../docx/representative.parts | 79 + .../docx/unsupported-benign.parts | 38 + tools/ci/test-budget.json | 14 + 8 files changed, 4041 insertions(+), 8 deletions(-) create mode 100644 crates/bran-document/src/docx.rs create mode 100644 crates/bran-document/tests/docx.rs create mode 100644 fixtures/enterprise-documents/docx/representative.parts create mode 100644 fixtures/enterprise-documents/docx/unsupported-benign.parts diff --git a/crates/bran-document/src/conformance.rs b/crates/bran-document/src/conformance.rs index 9ba191f..298f271 100644 --- a/crates/bran-document/src/conformance.rs +++ b/crates/bran-document/src/conformance.rs @@ -29,7 +29,7 @@ pub trait Adapter: Sync { /// Adapters register here. The shared corpus runs every row of a format /// against every adapter registered for that format. pub fn registered() -> Vec<&'static dyn Adapter> { - Vec::new() + vec![&crate::docx::Docx] } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/bran-document/src/docx.rs b/crates/bran-document/src/docx.rs new file mode 100644 index 0000000..270beda --- /dev/null +++ b/crates/bran-document/src/docx.rs @@ -0,0 +1,2951 @@ +//! DOCX adapter (issue #21): WordprocessingML to BRAN's flow model and back. +//! +//! Import runs on the shared package intake (`opc`), so package safety is not +//! this module's job. It maps the main document, styles, numbering, notes, and +//! comments into a small typed model and records every normalization or +//! omission in a versioned fidelity map. Nothing is fetched, evaluated, or +//! executed: fields keep their cached result, hyperlinks are recorded, and +//! unsupported content is skipped with a receipt entry. Export writes that +//! model as a deterministic DOCX, so import, export, and import again give the +//! same model and the same citation anchors. + +use crate::canonical::{sha256_hex, Json}; +use crate::conformance::{Adapter, Anchor, Imported}; +use crate::opc::{self, Package, Part, Relationship}; +use crate::xml::{self, Event}; +use crate::{zip, Cancel, Format, Limits, Refusal}; +use bran_core::export::{validate_emitted_string, ExportError}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +/// Version of the canonical model, fidelity vocabulary, and export receipt. +pub const MODEL_VERSION: &str = "1"; +const MAIN_TYPE: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; +const WML_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml."; +const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +/// Nesting of tables and block content controls. Deeper input is refused, so +/// the recursive import, serialization, and export stay far from stack limits +/// whatever XML depth the caller allows. +const MAX_BLOCK_NESTING: usize = 32; +const MAX_JSON_DEPTH: usize = 256; +/// Largest DrawingML extent (ST_PositiveCoordinate) and the 1-inch fallback. +const MAX_EMU: i64 = 27_273_042_316_900; +const DEFAULT_EMU: i64 = 914_400; + +/// Fidelity of one Word feature in the model, worst observation wins. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum Status { + Exact, + Normalized, + Approximated, + Unsupported, +} + +impl Status { + pub const fn code(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Normalized => "normalized", + Self::Approximated => "approximated", + Self::Unsupported => "unsupported", + } + } + + fn parse(code: &str) -> Option { + [ + Self::Exact, + Self::Normalized, + Self::Approximated, + Self::Unsupported, + ] + .into_iter() + .find(|status| status.code() == code) + } +} + +/// An imported document: content, the fidelity of every observed feature, and +/// package-level diagnostics (hyperlinks not fetched, DLP findings, ...). +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Document { + pub content: Content, + pub fidelity: BTreeMap, + pub diagnostics: BTreeSet, +} + +/// The structural model an export writes and a re-import must reproduce. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Content { + pub blocks: Vec, + pub footnotes: Vec, + pub endnotes: Vec, + pub comments: Vec, + /// Images keyed by the SHA-256 of their bytes. + pub assets: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Block { + Paragraph(Paragraph), + Table(Vec>), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Paragraph { + pub kind: Kind, + /// The paragraph ends a body section (a `sectPr` in its properties). + pub section_break: bool, + pub inlines: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Kind { + Body, + Title, + Caption, + Heading(u8), + List { num: u32, level: u8, format: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Cell { + pub span: u32, + pub merge: Merge, + pub blocks: Vec, +} + +/// Vertical merge state of a table cell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Merge { + Single, + Restart, + Continue, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Inline { + Run(Run), + CommentStart(i64), + CommentEnd(i64), + BookmarkStart(String), + BookmarkEnd(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Run { + pub content: RunContent, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub link: Option, + pub change: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RunContent { + Text(String), + Image(Image), + FootnoteRef(i64), + EndnoteRef(i64), + CommentRef(i64), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Image { + pub asset: String, + /// Extent in EMU. + pub width: i64, + pub height: i64, + pub name: String, + pub description: String, +} + +/// A hyperlink target. External targets are recorded, never fetched. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Link { + External(String), + Internal(String), +} + +/// A tracked change, kept as-is: import neither accepts nor rejects it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Change { + pub kind: ChangeKind, + pub id: i64, + pub author: String, + pub date: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChangeKind { + Insert, + Delete, + MoveFrom, + MoveTo, +} + +impl ChangeKind { + const ALL: [Self; 4] = [Self::Insert, Self::Delete, Self::MoveFrom, Self::MoveTo]; + + const fn element(self) -> &'static str { + match self { + Self::Insert => "ins", + Self::Delete => "del", + Self::MoveFrom => "moveFrom", + Self::MoveTo => "moveTo", + } + } + + const fn removes(self) -> bool { + matches!(self, Self::Delete | Self::MoveFrom) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Note { + pub id: i64, + pub blocks: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Comment { + pub id: i64, + pub author: String, + pub date: String, + pub initials: String, + pub blocks: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Asset { + pub media_type: String, + pub data: Vec, +} + +/// A citable unit with a flow locator matching the evidence envelope: +/// `section` plus a 1-based `ordinal` inside it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Citation { + pub id: String, + pub role: &'static str, + pub section: String, + pub ordinal: u32, + pub text: String, +} + +/// An export: DOCX bytes plus the canonical JSON fidelity receipt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Exported { + pub bytes: Vec, + pub receipt: Vec, +} + +impl Paragraph { + /// Citable text: inserted and moved-in text, not deleted or moved-away text. + pub fn text(&self) -> String { + self.collect(false) + } + + /// Every run's text, deleted text included. + pub fn full_text(&self) -> String { + self.collect(true) + } + + fn collect(&self, removed: bool) -> String { + self.inlines + .iter() + .filter_map(|inline| match inline { + Inline::Run(Run { + content: RunContent::Text(text), + change, + .. + }) if removed || !change.as_ref().is_some_and(|c| c.kind.removes()) => { + Some(text.as_str()) + } + _ => None, + }) + .collect() + } +} + +impl Block { + /// Citable text; table cells are separated by tabs and rows by newlines. + pub fn text(&self) -> String { + match self { + Self::Paragraph(paragraph) => paragraph.text(), + Self::Table(rows) => rows + .iter() + .map(|row| { + row.iter() + .map(|cell| blocks_text(&cell.blocks)) + .collect::>() + .join("\t") + }) + .collect::>() + .join("\n"), + } + } +} + +fn blocks_text(blocks: &[Block]) -> String { + blocks + .iter() + .map(Block::text) + .collect::>() + .join("\n") +} + +fn each_paragraph<'c>(blocks: &'c [Block], visit: &mut impl FnMut(&'c Paragraph)) { + for block in blocks { + match block { + Block::Paragraph(paragraph) => visit(paragraph), + Block::Table(rows) => rows + .iter() + .flatten() + .for_each(|cell| each_paragraph(&cell.blocks, visit)), + } + } +} + +fn each_paragraph_mut(blocks: &mut [Block], visit: &mut impl FnMut(&mut Paragraph)) { + for block in blocks { + match block { + Block::Paragraph(paragraph) => visit(paragraph), + Block::Table(rows) => rows + .iter_mut() + .flatten() + .for_each(|cell| each_paragraph_mut(&mut cell.blocks, visit)), + } + } +} + +impl Content { + fn stories(&self) -> impl Iterator { + std::iter::once(self.blocks.as_slice()) + .chain(self.footnotes.iter().map(|note| note.blocks.as_slice())) + .chain(self.endnotes.iter().map(|note| note.blocks.as_slice())) + .chain( + self.comments + .iter() + .map(|comment| comment.blocks.as_slice()), + ) + } + + fn paragraphs<'c>(&'c self, visit: &mut impl FnMut(&'c Paragraph)) { + for story in self.stories() { + each_paragraph(story, visit); + } + } +} + +impl Document { + /// Citable units: body blocks by section and ordinal, then notes and comments. + pub fn citations(&self) -> Vec { + let mut citations = Vec::new(); + let mut cite = |id: String, role, section: String, ordinal: usize, text: String| { + if !text.is_empty() { + citations.push(Citation { + id, + role, + section, + ordinal: ordinal as u32, + text, + }); + } + }; + let (mut section, mut ordinal) = (1, 0); + for block in &self.content.blocks { + ordinal += 1; + let role = match block { + Block::Table(_) => "table", + Block::Paragraph(paragraph) => match paragraph.kind { + Kind::Heading(_) => "heading", + Kind::Title => "title", + Kind::List { .. } => "list", + Kind::Body | Kind::Caption => "paragraph", + }, + }; + cite( + format!("docx:s{section}:b{ordinal}"), + role, + format!("s{section}"), + ordinal, + block.text(), + ); + if matches!(block, Block::Paragraph(paragraph) if paragraph.section_break) { + section += 1; + ordinal = 0; + } + } + for (kind, notes) in [ + ("footnote", &self.content.footnotes), + ("endnote", &self.content.endnotes), + ] { + for (index, note) in notes.iter().enumerate() { + cite( + format!("docx:{kind}:{}", note.id), + "paragraph", + format!("{kind}s"), + index + 1, + blocks_text(¬e.blocks), + ); + } + } + for (index, comment) in self.content.comments.iter().enumerate() { + cite( + format!("docx:comment:{}", comment.id), + "paragraph", + "comments".to_owned(), + index + 1, + blocks_text(&comment.blocks), + ); + } + citations + } + + /// Receipt codes: package diagnostics plus `feature:status` per feature. + pub fn receipt(&self) -> Vec { + let mut codes = self.diagnostics.clone(); + codes.extend( + self.fidelity + .iter() + .map(|(feature, status)| format!("{feature}:{}", status.code())), + ); + codes.into_iter().collect() + } + + pub fn imported(&self) -> Imported { + Imported { + canonical: self.canonical(), + receipt: self.receipt(), + anchors: self + .citations() + .into_iter() + .map(|citation| Anchor { + text_digest: sha256_hex(citation.text.as_bytes()), + id: citation.id, + }) + .collect(), + } + } +} + +// ---- Import ---- + +/// Imports an untrusted DOCX. Refusals are typed; nothing partial is returned. +pub fn read(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result { + let package = opc::open(bytes, limits, cancel)?; + let main = package + .relationships + .iter() + .find(|rel| rel.source.is_empty() && !rel.external && kind_of(rel) == "officeDocument") + .and_then(|rel| part(&package, &rel.target)) + .ok_or(Refusal::MalformedContainer)?; + if main.content_type != MAIN_TYPE { + return Err(Refusal::UnsupportedContainer); + } + let mut importer = Importer { + package: &package, + limits, + cancel, + source: String::new(), + styles: BTreeMap::new(), + default_style: None, + numbers: BTreeMap::new(), + formats: BTreeMap::new(), + bookmarks: BTreeMap::new(), + bookmark_names: BTreeSet::new(), + fields: Vec::new(), + document: Document::default(), + }; + importer.survey(&main.name); + if let Some(styles) = importer.related(&main.name, "styles") { + importer.read_styles(styles)?; + } + if let Some(numbering) = importer.related(&main.name, "numbering") { + importer.read_numbering(numbering)?; + } + importer.document.content.blocks = importer.body(main)?; + if let Some(notes) = importer.related(&main.name, "footnotes") { + importer.document.content.footnotes = importer.notes(notes, "footnote")?; + } + if let Some(notes) = importer.related(&main.name, "endnotes") { + importer.document.content.endnotes = importer.notes(notes, "endnote")?; + } + if let Some(comments) = importer.related(&main.name, "comments") { + importer.document.content.comments = importer.comments(comments)?; + } + importer.enter(""); + importer.prune(); + let mut document = importer.document; + document + .diagnostics + .extend(package.diagnostics.iter().map(|code| (*code).to_owned())); + for text in emitted_texts(&document.content) { + if let Err(refusal) = check_texts(std::slice::from_ref(&text)) { + document.diagnostics.insert(refusal.code().to_owned()); + } + } + Ok(document) +} + +fn part<'p>(package: &'p Package, name: &str) -> Option<&'p Part> { + package + .parts + .iter() + .find(|part| part.name.eq_ignore_ascii_case(name)) +} + +fn kind_of(rel: &Relationship) -> &str { + rel.kind.rsplit('/').next().unwrap_or_default() +} + +fn attr<'e>(attributes: &'e [(String, String)], key: &str) -> Option<&'e str> { + attributes + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.as_str()) +} + +fn number(value: Option<&str>) -> Option { + value.and_then(|value| value.parse().ok()) +} + +/// OOXML on/off value: absent means on. +fn on(value: Option<&str>) -> bool { + !matches!(value, Some("0" | "false" | "off" | "none")) +} + +/// Walks a flat event list. Each method that takes an element expects its +/// `Open` event to have been consumed and consumes through its `Close`. +struct Cursor<'e> { + events: &'e [Event], + at: usize, +} + +type Element<'e> = (usize, &'e str, &'e [(String, String)]); + +impl<'e> Cursor<'e> { + fn next(&mut self) -> Result<&'e Event, Refusal> { + let event = self.events.get(self.at).ok_or(Refusal::MalformedXml)?; + self.at += 1; + Ok(event) + } + + fn skip(&mut self) -> Result<(), Refusal> { + self.elements().map(|_| ()) + } + + /// Text directly inside the element, nested elements skipped. + fn text(&mut self) -> Result { + let mut text = String::new(); + loop { + match self.next()? { + Event::Close => return Ok(text), + Event::Text(value) => text.push_str(value), + Event::Open { .. } => self.skip()?, + } + } + } + + /// Every descendant element with its depth (1 = child). + fn elements(&mut self) -> Result>, Refusal> { + let (mut depth, mut found) = (1, Vec::new()); + while depth > 0 { + match self.next()? { + Event::Open { name, attributes } => { + found.push((depth, name.as_str(), attributes.as_slice())); + depth += 1; + } + Event::Close => depth -= 1, + Event::Text(_) => {} + } + } + Ok(found) + } + + /// Skips whitespace and returns the next element name without consuming it. + fn peek_open(&mut self) -> Option<&'e str> { + while let Some(Event::Text(_)) = self.events.get(self.at) { + self.at += 1; + } + match self.events.get(self.at) { + Some(Event::Open { name, .. }) => Some(name), + _ => None, + } + } +} + +#[derive(Default)] +struct Style { + name: String, + based_on: Option, + outline: Option, + num: Option, + level: Option, +} + +#[derive(Default)] +struct ParagraphProps { + style: Option, + num: Option, + level: Option, + outline: Option, + section_break: bool, +} + +enum Frame { + Plain, + Link(Option), + Change(Change), +} + +struct Importer<'a> { + package: &'a Package, + limits: &'a Limits, + cancel: &'a Cancel, + /// Part being read, for relationship lookups. + source: String, + styles: BTreeMap, + default_style: Option, + numbers: BTreeMap, + formats: BTreeMap<(u32, u8), String>, + /// Open bookmarks of the current part by `w:id`. + bookmarks: BTreeMap, + bookmark_names: BTreeSet, + /// Complex field stack of the current part; `true` while in the field code. + fields: Vec, + document: Document, +} + +impl<'a> Importer<'a> { + fn note(&mut self, feature: &str, status: Status) { + let entry = self + .document + .fidelity + .entry(feature.to_owned()) + .or_insert(status); + *entry = (*entry).max(status); + } + + /// Drops characters XML 1.0 cannot carry (and CR), recording the change. + fn clean(&mut self, value: &str) -> String { + let kept = |c: &char| match c { + '\t' | '\n' => true, + '\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => false, + _ => true, + }; + let cleaned: String = value.chars().filter(kept).collect(); + if cleaned.len() != value.len() { + self.note("text", Status::Normalized); + } + cleaned + } + + fn enter(&mut self, source: &str) { + if !self.bookmarks.is_empty() { + self.note("bookmarks", Status::Normalized); + } + if !self.fields.is_empty() { + // An unterminated field hid everything after its code. + self.note("fields", Status::Approximated); + } + self.bookmarks.clear(); + self.fields.clear(); + self.source = source.to_owned(); + } + + fn related(&self, source: &str, kind: &str) -> Option<&'a Part> { + let package = self.package; + package + .relationships + .iter() + .find(|rel| { + rel.source.eq_ignore_ascii_case(source) && !rel.external && kind_of(rel) == kind + }) + .and_then(|rel| part(package, &rel.target)) + } + + fn relationship(&self, id: &str) -> Option<&'a Relationship> { + let package = self.package; + package + .relationships + .iter() + .find(|rel| rel.source.eq_ignore_ascii_case(&self.source) && rel.id == id) + } + + fn parse(&self, part: &Part) -> Result, Refusal> { + self.cancel.check()?; + xml::parse(&part.data, self.limits, self.cancel) + } + + /// Records package and main-part relationships this model does not carry. + fn survey(&mut self, main: &str) { + let package = self.package; + for rel in &package.relationships { + let feature = if rel.source.is_empty() { + match kind_of(rel) { + "officeDocument" => None, + "core-properties" + | "extended-properties" + | "custom-properties" + | "thumbnail" => Some("document-properties"), + "origin" | "signature" => Some("digital-signatures"), + _ => Some("unrecognized-parts"), + } + } else if rel.source.eq_ignore_ascii_case(main) { + match kind_of(rel) { + // Read below, reached from content, or formatting only. + "styles" | "numbering" | "footnotes" | "endnotes" | "comments" + | "hyperlink" | "image" | "chart" | "diagramData" | "diagramLayout" + | "diagramQuickStyle" | "diagramColors" | "diagramDrawing" | "settings" + | "webSettings" | "fontTable" | "theme" | "stylesWithEffects" => None, + "header" | "footer" => Some("headers-footers"), + "commentsExtended" | "commentsIds" | "commentsExtensible" | "people" => { + Some("comment-threads") + } + _ => Some("unrecognized-parts"), + } + } else { + None + }; + if let Some(feature) = feature { + self.note(feature, Status::Unsupported); + } + } + } + + fn read_styles(&mut self, part: &Part) -> Result<(), Refusal> { + self.note("styles", Status::Normalized); + let events = self.parse(part)?; + let mut cursor = Cursor { + events: &events, + at: 1, + }; + let mut path: Vec<&str> = Vec::new(); + let mut current: Option<(String, Style)> = None; + for (depth, name, attributes) in cursor.elements()? { + path.truncate(depth - 1); + path.push(name); + let val = attr(attributes, "val"); + if depth == 1 { + if let Some((id, style)) = current.take() { + self.styles.entry(id).or_insert(style); + } + if name == "style" && attr(attributes, "type") == Some("paragraph") { + if let Some(id) = attr(attributes, "styleId") { + if matches!(attr(attributes, "default"), Some("1" | "true" | "on")) { + self.default_style.get_or_insert_with(|| id.to_owned()); + } + current = Some((id.to_owned(), Style::default())); + } + } + continue; + } + let Some((_, style)) = current.as_mut() else { + continue; + }; + match &path[1..] { + ["name"] => style.name = val.unwrap_or_default().to_ascii_lowercase(), + ["basedOn"] => style.based_on = val.map(str::to_owned), + ["pPr", "outlineLvl"] => style.outline = number(val), + ["pPr", "numPr", "numId"] => style.num = number(val), + ["pPr", "numPr", "ilvl"] => style.level = number(val), + _ => {} + } + } + if let Some((id, style)) = current { + self.styles.entry(id).or_insert(style); + } + Ok(()) + } + + fn read_numbering(&mut self, part: &Part) -> Result<(), Refusal> { + let events = self.parse(part)?; + let mut cursor = Cursor { + events: &events, + at: 1, + }; + let mut path: Vec<&str> = Vec::new(); + let (mut abstract_id, mut level, mut num) = (None, None, None); + for (depth, name, attributes) in cursor.elements()? { + path.truncate(depth - 1); + path.push(name); + match path.as_slice() { + ["abstractNum"] => abstract_id = number(attr(attributes, "abstractNumId")), + ["abstractNum", "lvl"] => level = number(attr(attributes, "ilvl")), + ["abstractNum", "lvl", "numFmt"] => { + if let (Some(abstract_id), Some(level), Some(format)) = + (abstract_id, level, attr(attributes, "val")) + { + self.formats + .entry((abstract_id, level)) + .or_insert_with(|| format.to_owned()); + } + } + ["num"] => num = number(attr(attributes, "numId")), + ["num", "abstractNumId"] => { + if let (Some(num), Some(abstract_id)) = (num, number(attr(attributes, "val"))) { + self.numbers.entry(num).or_insert(abstract_id); + } + } + ["num", "lvlOverride", ..] => self.note("lists", Status::Normalized), + _ => {} + } + } + Ok(()) + } + + fn body(&mut self, part: &Part) -> Result, Refusal> { + self.enter(&part.name); + let events = self.parse(part)?; + let mut cursor = Cursor { + events: &events, + at: 1, + }; + loop { + match cursor.next()? { + Event::Open { name, .. } if name == "body" => { + return self.blocks(&mut cursor, 0, true); + } + Event::Open { .. } => cursor.skip()?, + Event::Text(_) => {} + Event::Close => return Err(Refusal::MalformedContainer), + } + } + } + + fn notes(&mut self, part: &Part, item: &str) -> Result, Refusal> { + let feature = if item == "footnote" { + "footnotes" + } else { + "endnotes" + }; + self.enter(&part.name); + let events = self.parse(part)?; + let mut cursor = Cursor { + events: &events, + at: 1, + }; + let (mut notes, mut ids) = (Vec::new(), BTreeSet::new()); + loop { + let attributes = match cursor.next()? { + Event::Close => return Ok(notes), + Event::Text(_) => continue, + Event::Open { name, attributes } if name == item => attributes, + Event::Open { .. } => { + cursor.skip()?; + continue; + } + }; + // Separator notes are layout, not content. + if attr(attributes, "type").is_some_and(|kind| kind != "normal") { + cursor.skip()?; + continue; + } + self.note(feature, Status::Normalized); + match number::(attr(attributes, "id")) { + Some(id) if ids.insert(id) => { + let blocks = self.story(&mut cursor, 0, feature)?; + notes.push(Note { id, blocks }); + } + _ => cursor.skip()?, + } + } + } + + fn comments(&mut self, part: &Part) -> Result, Refusal> { + self.enter(&part.name); + let events = self.parse(part)?; + let mut cursor = Cursor { + events: &events, + at: 1, + }; + let (mut comments, mut ids) = (Vec::new(), BTreeSet::new()); + loop { + let attributes = match cursor.next()? { + Event::Close => return Ok(comments), + Event::Text(_) => continue, + Event::Open { name, attributes } if name == "comment" => attributes, + Event::Open { .. } => { + cursor.skip()?; + continue; + } + }; + self.note("comments", Status::Normalized); + match number::(attr(attributes, "id")) { + Some(id) if ids.insert(id) => { + let author = self.clean(attr(attributes, "author").unwrap_or_default()); + let date = self.clean(attr(attributes, "date").unwrap_or_default()); + let initials = self.clean(attr(attributes, "initials").unwrap_or_default()); + let blocks = self.story(&mut cursor, 0, "comments")?; + comments.push(Comment { + id, + author, + date, + initials, + blocks, + }); + } + _ => cursor.skip()?, + } + } + } + + /// Blocks of a cell, note, or comment. Word needs a trailing paragraph, so + /// one is added (and recorded) when missing, keeping export round-trips exact. + fn story( + &mut self, + cursor: &mut Cursor<'_>, + depth: usize, + feature: &str, + ) -> Result, Refusal> { + let mut blocks = self.blocks(cursor, depth, false)?; + if !matches!(blocks.last(), Some(Block::Paragraph(_))) { + self.note(feature, Status::Normalized); + blocks.push(Block::Paragraph(Paragraph { + kind: Kind::Body, + section_break: false, + inlines: Vec::new(), + })); + } + Ok(blocks) + } + + fn blocks( + &mut self, + cursor: &mut Cursor<'_>, + depth: usize, + body: bool, + ) -> Result, Refusal> { + if depth > MAX_BLOCK_NESTING { + return Err(Refusal::XmlDepthLimit); + } + self.cancel.check()?; + let mut blocks = Vec::new(); + loop { + let name = match cursor.next()? { + Event::Close => return Ok(blocks), + Event::Text(_) => continue, + Event::Open { name, .. } => name.as_str(), + }; + match name { + "p" => blocks.push(Block::Paragraph(self.paragraph(cursor, body)?)), + "tbl" => { + if let Some(table) = self.table(cursor, depth + 1)? { + blocks.push(table); + } + } + "sdt" => { + self.note("content-controls", Status::Normalized); + blocks.extend(self.blocks(cursor, depth + 1, body)?); + } + "sdtContent" | "customXml" | "AlternateContent" | "Choice" => { + blocks.extend(self.blocks(cursor, depth + 1, body)?); + } + "sectPr" => { + self.note("sections", Status::Normalized); + cursor.skip()?; + } + "bookmarkStart" | "bookmarkEnd" => { + self.note("bookmarks", Status::Normalized); + cursor.skip()?; + } + "commentRangeStart" | "commentRangeEnd" => { + self.note("comments", Status::Normalized); + cursor.skip()?; + } + "tcPr" => { + self.note("tables", Status::Normalized); + cursor.skip()?; + } + "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, + "altChunk" => self.unsupported(cursor, "embedded-documents")?, + "sdtPr" | "sdtEndPr" | "customXmlPr" | "Fallback" | "proofErr" | "permStart" + | "permEnd" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + } + + fn unsupported(&mut self, cursor: &mut Cursor<'_>, feature: &str) -> Result<(), Refusal> { + self.note(feature, Status::Unsupported); + cursor.skip() + } + + fn table(&mut self, cursor: &mut Cursor<'_>, depth: usize) -> Result, Refusal> { + if depth > MAX_BLOCK_NESTING { + return Err(Refusal::XmlDepthLimit); + } + self.note("tables", Status::Normalized); + let mut rows = Vec::new(); + loop { + let name = match cursor.next()? { + Event::Close => break, + Event::Text(_) => continue, + Event::Open { name, .. } => name.as_str(), + }; + match name { + "tr" => { + let row = self.row(cursor, depth)?; + if !row.is_empty() { + rows.push(row); + } + } + "tblPr" | "tblGrid" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + // Word cannot open a table without rows, so an empty one is dropped. + Ok((!rows.is_empty()).then_some(Block::Table(rows))) + } + + fn row(&mut self, cursor: &mut Cursor<'_>, depth: usize) -> Result, Refusal> { + let mut cells = Vec::new(); + loop { + let name = match cursor.next()? { + Event::Close => return Ok(cells), + Event::Text(_) => continue, + Event::Open { name, .. } => name.as_str(), + }; + match name { + "tc" => cells.push(self.cell(cursor, depth)?), + "trPr" => { + for (_, element, _) in cursor.elements()? { + if matches!(element, "ins" | "del") { + self.note("table-row-changes", Status::Unsupported); + } + } + } + "tblPrEx" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + } + + fn cell(&mut self, cursor: &mut Cursor<'_>, depth: usize) -> Result { + let (mut span, mut merge) = (1, Merge::Single); + if cursor.peek_open() == Some("tcPr") { + cursor.next()?; + for (level, name, attributes) in cursor.elements()? { + match (level, name) { + (1, "gridSpan") => { + span = number(attr(attributes, "val")) + .filter(|span| (1..=63).contains(span)) + .unwrap_or(1); + } + (1, "vMerge") => { + merge = if attr(attributes, "val") == Some("restart") { + Merge::Restart + } else { + Merge::Continue + }; + } + (1, "cellIns" | "cellDel" | "cellMerge" | "tcPrChange") => { + self.note("table-cell-changes", Status::Unsupported); + } + _ => {} + } + } + } + let blocks = self.story(cursor, depth, "tables")?; + Ok(Cell { + span, + merge, + blocks, + }) + } + + fn paragraph(&mut self, cursor: &mut Cursor<'_>, body: bool) -> Result { + let mut inlines = Vec::new(); + let mut props = ParagraphProps::default(); + let mut frames: Vec = Vec::new(); + loop { + let (name, attributes) = match cursor.next()? { + Event::Close => match frames.pop() { + None => break, + Some(_) => continue, + }, + Event::Text(_) => continue, + Event::Open { name, attributes } => (name.as_str(), attributes.as_slice()), + }; + match name { + "pPr" => props = self.paragraph_properties(cursor)?, + "r" => self.run(cursor, &frames, &mut inlines)?, + "hyperlink" => frames.push(Frame::Link(self.link(attributes))), + "ins" | "del" | "moveFrom" | "moveTo" => { + let nested = frames.iter().any(|frame| matches!(frame, Frame::Change(_))); + self.note( + "tracked-changes", + if nested { + Status::Approximated + } else { + Status::Exact + }, + ); + frames.push(Frame::Change(self.change(name, attributes))); + } + "fldSimple" => { + self.note("fields", Status::Normalized); + frames.push(Frame::Plain); + } + "sdt" => { + self.note("content-controls", Status::Normalized); + frames.push(Frame::Plain); + } + "sdtContent" | "smartTag" | "customXml" | "dir" | "bdo" | "AlternateContent" + | "Choice" => frames.push(Frame::Plain), + "bookmarkStart" => { + match (attr(attributes, "id"), attr(attributes, "name")) { + (Some(id), Some(name)) if self.bookmark_names.insert(name.to_owned()) => { + let name = self.clean(name); + self.note("bookmarks", Status::Exact); + self.bookmarks.insert(id.to_owned(), name.clone()); + inlines.push(Inline::BookmarkStart(name)); + } + _ => self.note("bookmarks", Status::Normalized), + } + cursor.skip()?; + } + "bookmarkEnd" => { + match attr(attributes, "id").and_then(|id| self.bookmarks.remove(id)) { + Some(name) => inlines.push(Inline::BookmarkEnd(name)), + None => self.note("bookmarks", Status::Normalized), + } + cursor.skip()?; + } + "commentRangeStart" | "commentRangeEnd" => { + if let Some(id) = number(attr(attributes, "id")) { + inlines.push(if name == "commentRangeStart" { + Inline::CommentStart(id) + } else { + Inline::CommentEnd(id) + }); + } + cursor.skip()?; + } + "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, + "sdtPr" | "sdtEndPr" | "smartTagPr" | "customXmlPr" | "Fallback" | "proofErr" + | "permStart" | "permEnd" | "moveFromRangeStart" | "moveFromRangeEnd" + | "moveToRangeStart" | "moveToRangeEnd" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + Ok(Paragraph { + kind: self.kind(&props), + section_break: body && props.section_break, + inlines, + }) + } + + fn paragraph_properties(&mut self, cursor: &mut Cursor<'_>) -> Result { + let mut props = ParagraphProps::default(); + let mut parent = ""; + for (depth, name, attributes) in cursor.elements()? { + let val = attr(attributes, "val"); + if depth == 1 { + parent = name; + } + match (depth, parent, name) { + (1, _, "pStyle") => props.style = val.map(str::to_owned), + (1, _, "outlineLvl") => props.outline = number(val), + (1, _, "sectPr") => { + self.note("sections", Status::Normalized); + props.section_break = true; + } + (1, _, "numPr" | "rPr") => {} + (1, _, "pPrChange") => self.note("formatting-changes", Status::Unsupported), + (1, _, _) => self.note("paragraph-formatting", Status::Normalized), + (2, "numPr", "numId") => props.num = number(val), + (2, "numPr", "ilvl") => props.level = number(val), + (2, "rPr", "ins" | "del" | "moveFrom" | "moveTo") => { + self.note("paragraph-mark-changes", Status::Unsupported); + } + (2, "rPr", "rPrChange") => self.note("formatting-changes", Status::Unsupported), + _ => {} + } + } + Ok(props) + } + + fn kind(&mut self, props: &ParagraphProps) -> Kind { + let (mut outline, mut num, mut level) = (props.outline, props.num, props.level); + let mut named = None; + let mut next = props.style.clone().or_else(|| self.default_style.clone()); + let mut seen = BTreeSet::new(); + while let Some(id) = next { + let Some(style) = self.styles.get(&id) else { + break; + }; + if !seen.insert(id) || seen.len() > MAX_BLOCK_NESTING { + break; + } + outline = outline.or(style.outline); + num = num.or(style.num); + level = level.or(style.level); + named = named.or_else(|| named_kind(&style.name)); + next = style.based_on.clone(); + } + let num = num.filter(|num| *num != 0); + let heading = match (outline.filter(|level| *level < 9), &named) { + (Some(level), _) => Some(level + 1), + (None, Some(Kind::Heading(level))) => Some(*level), + _ => None, + }; + if let Some(level) = heading { + self.note("headings", Status::Normalized); + if num.is_some() { + // Heading numbering is not carried; the heading level is. + self.note("lists", Status::Normalized); + } + return Kind::Heading(level); + } + match named { + Some(Kind::Title) => { + self.note("headings", Status::Normalized); + return Kind::Title; + } + Some(Kind::Caption) => { + self.note("captions", Status::Normalized); + return Kind::Caption; + } + _ => {} + } + let Some(num) = num else { + return Kind::Body; + }; + self.note("lists", Status::Normalized); + let Some(abstract_id) = self.numbers.get(&num).copied() else { + return Kind::Body; + }; + let level = level.unwrap_or(0).min(8); + let format = self + .formats + .get(&(abstract_id, level)) + .filter(|format| valid_format(format)) + .cloned() + .unwrap_or_else(|| "decimal".to_owned()); + Kind::List { num, level, format } + } + + fn link(&mut self, attributes: &[(String, String)]) -> Option { + let anchor = attr(attributes, "anchor"); + let link = match attr(attributes, "id") { + Some(id) => match self.relationship(id) { + Some(rel) if rel.external && kind_of(rel) == "hyperlink" => { + let target = match anchor { + Some(anchor) => format!("{}#{anchor}", rel.target), + None => rel.target.clone(), + }; + Some(Link::External(self.clean(&target))) + } + _ => None, + }, + None => anchor.map(|anchor| Link::Internal(self.clean(anchor))), + }; + self.note( + "hyperlinks", + if link.is_some() { + Status::Exact + } else { + Status::Normalized + }, + ); + link + } + + fn change(&mut self, name: &str, attributes: &[(String, String)]) -> Change { + Change { + kind: ChangeKind::ALL + .into_iter() + .find(|kind| kind.element() == name) + .unwrap_or(ChangeKind::Insert), + id: number(attr(attributes, "id")).unwrap_or(0), + author: self.clean(attr(attributes, "author").unwrap_or_default()), + date: self.clean(attr(attributes, "date").unwrap_or_default()), + } + } + + fn run( + &mut self, + cursor: &mut Cursor<'_>, + frames: &[Frame], + inlines: &mut Vec, + ) -> Result<(), Refusal> { + let mut template = Run { + content: RunContent::Text(String::new()), + bold: false, + italic: false, + underline: false, + link: frames + .iter() + .rev() + .find_map(|frame| match frame { + Frame::Link(link) => Some(link.clone()), + _ => None, + }) + .flatten(), + change: frames.iter().rev().find_map(|frame| match frame { + Frame::Change(change) => Some(change.clone()), + _ => None, + }), + }; + let mut text = String::new(); + let mut open = 0; + let emit = |content: RunContent, template: &Run, inlines: &mut Vec| { + inlines.push(Inline::Run(Run { + content, + ..template.clone() + })); + }; + loop { + let (name, attributes) = match cursor.next()? { + Event::Close if open > 0 => { + open -= 1; + continue; + } + Event::Close => break, + Event::Text(_) => continue, + Event::Open { name, attributes } => (name.as_str(), attributes.as_slice()), + }; + // Content between a field's begin and separate is its code. + let hidden = self.fields.iter().any(|code| *code); + match name { + "rPr" => { + for (depth, element, attributes) in cursor.elements()? { + let val = attr(attributes, "val"); + match (depth, element) { + (1, "b") => template.bold = on(val), + (1, "i") => template.italic = on(val), + (1, "u") => template.underline = on(val), + (1, "rPrChange") => { + self.note("formatting-changes", Status::Unsupported); + } + (1, _) => self.note("run-formatting", Status::Normalized), + _ => {} + } + } + } + "t" | "delText" => { + let value = cursor.text()?; + if !hidden { + text.push_str(&self.clean(&value)); + } + } + "tab" | "ptab" | "br" | "cr" | "noBreakHyphen" | "softHyphen" => { + if name == "ptab" + || attr(attributes, "type").is_some_and(|t| t != "textWrapping") + { + self.note("breaks", Status::Normalized); + } + if !hidden { + text.push(match name { + "tab" | "ptab" => '\t', + "noBreakHyphen" => '\u{2011}', + "softHyphen" => '\u{ad}', + _ => '\n', + }); + } + cursor.skip()?; + } + "fldChar" => { + self.note("fields", Status::Normalized); + match attr(attributes, "fldCharType") { + Some("begin") => self.fields.push(true), + Some("separate") => { + if let Some(code) = self.fields.last_mut() { + *code = false; + } + } + Some("end") => { + self.fields.pop(); + } + _ => {} + } + cursor.skip()?; + } + "drawing" => { + let image = self.drawing(cursor)?; + if let (Some(image), false) = (image, hidden) { + if !text.is_empty() { + emit( + RunContent::Text(std::mem::take(&mut text)), + &template, + inlines, + ); + } + emit(RunContent::Image(image), &template, inlines); + } + } + "footnoteReference" | "endnoteReference" | "commentReference" => { + if let (Some(id), false) = (number(attr(attributes, "id")), hidden) { + if !text.is_empty() { + emit( + RunContent::Text(std::mem::take(&mut text)), + &template, + inlines, + ); + } + let content = match name { + "footnoteReference" => RunContent::FootnoteRef(id), + "endnoteReference" => RunContent::EndnoteRef(id), + _ => RunContent::CommentRef(id), + }; + emit(content, &template, inlines); + } + cursor.skip()?; + } + "AlternateContent" | "Choice" => open += 1, + "pict" => self.unsupported(cursor, "vml")?, + "object" => self.unsupported(cursor, "embedded-objects")?, + "sym" => self.unsupported(cursor, "symbols")?, + "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, + "instrText" + | "delInstrText" + | "footnoteRef" + | "endnoteRef" + | "annotationRef" + | "lastRenderedPageBreak" + | "separator" + | "continuationSeparator" + | "Fallback" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + if !text.is_empty() { + emit(RunContent::Text(text), &template, inlines); + } + Ok(()) + } + + fn drawing(&mut self, cursor: &mut Cursor<'_>) -> Result, Refusal> { + let (mut floating, mut blip, mut other) = (false, None, None); + let (mut width, mut height, mut name, mut description) = (None, None, "", ""); + for (depth, element, attributes) in cursor.elements()? { + match (depth, element) { + (1, "anchor") => floating = true, + (2, "extent") => { + width = number::(attr(attributes, "cx")); + height = number::(attr(attributes, "cy")); + } + (2, "docPr") => { + name = attr(attributes, "name").unwrap_or_default(); + description = attr(attributes, "descr").unwrap_or_default(); + } + (_, "blip") => blip = blip.or(attr(attributes, "embed")), + (_, "svgBlip") => self.note("svg-images", Status::Normalized), + (_, "chart") => other = Some("charts"), + (_, "relIds") => other = Some("smartart"), + (_, "wsp" | "wgp" | "wpc" | "txbx" | "lockedCanvas") => other = Some("shapes"), + _ => {} + } + } + if let Some(feature) = other { + self.note(feature, Status::Unsupported); + return Ok(None); + } + let Some(blip) = blip else { + self.note("unrecognized-content", Status::Unsupported); + return Ok(None); + }; + let target = self + .relationship(blip) + .filter(|rel| !rel.external) + .and_then(|rel| part(self.package, &rel.target)); + let Some(target) = target else { + self.note("images", Status::Unsupported); + return Ok(None); + }; + if floating { + self.note("floating-images", Status::Normalized); + } + let extent = |value: Option| value.filter(|value| (1..=MAX_EMU).contains(value)); + let (width, height) = match (extent(width), extent(height)) { + (Some(width), Some(height)) => { + self.note("images", Status::Exact); + (width, height) + } + _ => { + self.note("images", Status::Normalized); + (DEFAULT_EMU, DEFAULT_EMU) + } + }; + let asset = sha256_hex(&target.data); + self.document + .content + .assets + .entry(asset.clone()) + .or_insert_with(|| Asset { + media_type: target.content_type.clone(), + data: target.data.clone(), + }); + Ok(Some(Image { + asset, + width, + height, + name: self.clean(name), + description: self.clean(description), + })) + } + + /// Drops references to notes and comments that do not exist, so an export + /// never points at a missing part entry. + fn prune(&mut self) { + let ids = |notes: &[Note]| notes.iter().map(|note| note.id).collect::>(); + let content = &mut self.document.content; + let (footnotes, endnotes) = (ids(&content.footnotes), ids(&content.endnotes)); + let comments: BTreeSet = content.comments.iter().map(|comment| comment.id).collect(); + let mut dropped = false; + let mut keep = |paragraph: &mut Paragraph| { + let before = paragraph.inlines.len(); + paragraph.inlines.retain(|inline| match inline { + Inline::Run(Run { + content: RunContent::FootnoteRef(id), + .. + }) => footnotes.contains(id), + Inline::Run(Run { + content: RunContent::EndnoteRef(id), + .. + }) => endnotes.contains(id), + Inline::Run(Run { + content: RunContent::CommentRef(id), + .. + }) + | Inline::CommentStart(id) + | Inline::CommentEnd(id) => comments.contains(id), + _ => true, + }); + dropped |= paragraph.inlines.len() != before; + }; + each_paragraph_mut(&mut content.blocks, &mut keep); + for note in content.footnotes.iter_mut().chain(&mut content.endnotes) { + each_paragraph_mut(&mut note.blocks, &mut keep); + } + for comment in &mut content.comments { + each_paragraph_mut(&mut comment.blocks, &mut keep); + } + if dropped { + self.note("references", Status::Normalized); + } + } +} + +fn named_kind(name: &str) -> Option { + match name { + "title" => Some(Kind::Title), + "caption" => Some(Kind::Caption), + _ => name + .strip_prefix("heading ") + .and_then(|level| level.parse().ok()) + .filter(|level| (1..=9).contains(level)) + .map(Kind::Heading), + } +} + +fn valid_format(format: &str) -> bool { + (1..=32).contains(&format.len()) && format.bytes().all(|byte| byte.is_ascii_alphabetic()) +} + +/// Every string an export would emit, for the DLP and public-boundary check. +/// Paragraph text is joined across runs, so a marker split over runs is found. +fn emitted_texts(content: &Content) -> Vec { + let mut texts = Vec::new(); + content.paragraphs(&mut |paragraph| { + texts.push(paragraph.full_text()); + texts.push(paragraph.text()); + for inline in ¶graph.inlines { + match inline { + Inline::Run(run) => { + if let RunContent::Image(image) = &run.content { + texts.extend([image.name.clone(), image.description.clone()]); + } + if let Some(Link::External(target) | Link::Internal(target)) = &run.link { + texts.push(target.clone()); + } + if let Some(change) = &run.change { + texts.extend([change.author.clone(), change.date.clone()]); + } + } + Inline::BookmarkStart(name) | Inline::BookmarkEnd(name) => texts.push(name.clone()), + Inline::CommentStart(_) | Inline::CommentEnd(_) => {} + } + } + }); + for comment in &content.comments { + texts.extend([ + comment.author.clone(), + comment.date.clone(), + comment.initials.clone(), + ]); + } + texts.retain(|text| !text.is_empty()); + texts +} + +fn check_texts(texts: &[String]) -> Result<(), Refusal> { + for text in texts { + match validate_emitted_string(text) { + Err(ExportError::DlpViolation(_)) => return Err(Refusal::DlpFindings), + Err(_) => return Err(Refusal::PublicBoundary), + Ok(()) => {} + } + } + Ok(()) +} + +// ---- Canonical JSON ---- + +fn text_json(value: &str) -> Json { + Json::Str(value.to_owned()) +} + +fn fields(entries: [(&str, Json); N]) -> Map { + entries + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect() +} + +fn object(entries: [(&str, Json); N]) -> Json { + Json::Obj(fields(entries)) +} + +fn blocks_json(blocks: &[Block]) -> Json { + Json::Arr(blocks.iter().map(block_json).collect()) +} + +fn block_json(block: &Block) -> Json { + let paragraph = match block { + Block::Paragraph(paragraph) => paragraph, + Block::Table(rows) => { + let cell = |cell: &Cell| { + object([ + ("span", Json::Int(cell.span.into())), + ("merge", text_json(merge_code(cell.merge))), + ("blocks", blocks_json(&cell.blocks)), + ]) + }; + let rows = rows + .iter() + .map(|row| Json::Arr(row.iter().map(cell).collect())); + return object([ + ("type", text_json("table")), + ("rows", Json::Arr(rows.collect())), + ]); + } + }; + let mut map = fields([ + ("type", text_json("paragraph")), + ("section_break", Json::Bool(paragraph.section_break)), + ( + "inlines", + Json::Arr(paragraph.inlines.iter().map(inline_json).collect()), + ), + ]); + let kind = match ¶graph.kind { + Kind::Body => "body", + Kind::Title => "title", + Kind::Caption => "caption", + Kind::Heading(level) => { + map.insert("level".to_owned(), Json::Int((*level).into())); + "heading" + } + Kind::List { num, level, format } => { + map.insert("level".to_owned(), Json::Int((*level).into())); + map.insert("num".to_owned(), Json::Int((*num).into())); + map.insert("format".to_owned(), text_json(format)); + "list" + } + }; + map.insert("kind".to_owned(), text_json(kind)); + Json::Obj(map) +} + +const fn merge_code(merge: Merge) -> &'static str { + match merge { + Merge::Single => "single", + Merge::Restart => "restart", + Merge::Continue => "continue", + } +} + +fn inline_json(inline: &Inline) -> Json { + let run = match inline { + Inline::CommentStart(id) => { + return object([("type", text_json("comment-start")), ("id", Json::Int(*id))]) + } + Inline::CommentEnd(id) => { + return object([("type", text_json("comment-end")), ("id", Json::Int(*id))]) + } + Inline::BookmarkStart(name) => { + return object([ + ("type", text_json("bookmark-start")), + ("name", text_json(name)), + ]) + } + Inline::BookmarkEnd(name) => { + return object([ + ("type", text_json("bookmark-end")), + ("name", text_json(name)), + ]) + } + Inline::Run(run) => run, + }; + let mut map = match &run.content { + RunContent::Text(text) => fields([("type", text_json("text")), ("text", text_json(text))]), + RunContent::Image(image) => fields([ + ("type", text_json("image")), + ("asset", text_json(&image.asset)), + ("width", Json::Int(image.width)), + ("height", Json::Int(image.height)), + ("name", text_json(&image.name)), + ("description", text_json(&image.description)), + ]), + RunContent::FootnoteRef(id) => { + fields([("type", text_json("footnote-ref")), ("id", Json::Int(*id))]) + } + RunContent::EndnoteRef(id) => { + fields([("type", text_json("endnote-ref")), ("id", Json::Int(*id))]) + } + RunContent::CommentRef(id) => { + fields([("type", text_json("comment-ref")), ("id", Json::Int(*id))]) + } + }; + map.insert("bold".to_owned(), Json::Bool(run.bold)); + map.insert("italic".to_owned(), Json::Bool(run.italic)); + map.insert("underline".to_owned(), Json::Bool(run.underline)); + map.insert( + "link".to_owned(), + match &run.link { + None => Json::Null, + Some(Link::External(target)) => object([("external", text_json(target))]), + Some(Link::Internal(target)) => object([("internal", text_json(target))]), + }, + ); + map.insert( + "change".to_owned(), + match &run.change { + None => Json::Null, + Some(change) => object([ + ("kind", text_json(change.kind.element())), + ("id", Json::Int(change.id)), + ("author", text_json(&change.author)), + ("date", text_json(&change.date)), + ]), + }, + ); + Json::Obj(map) +} + +impl Document { + /// Canonical JSON (the envelope byte rule) of the model, citation anchors, + /// fidelity, and diagnostics. Asset bytes are carried base64-encoded, so an + /// export needs nothing but these bytes. + pub fn canonical(&self) -> Vec { + let content = &self.content; + let notes = |notes: &[Note]| { + Json::Arr( + notes + .iter() + .map(|note| { + object([ + ("id", Json::Int(note.id)), + ("blocks", blocks_json(¬e.blocks)), + ]) + }) + .collect(), + ) + }; + let comments = content.comments.iter().map(|comment| { + object([ + ("id", Json::Int(comment.id)), + ("author", text_json(&comment.author)), + ("date", text_json(&comment.date)), + ("initials", text_json(&comment.initials)), + ("blocks", blocks_json(&comment.blocks)), + ]) + }); + let assets = content.assets.iter().map(|(digest, asset)| { + object([ + ("sha256", text_json(digest)), + ("media_type", text_json(&asset.media_type)), + ("byte_length", Json::Int(asset.data.len() as i64)), + ("data", Json::Str(base64(&asset.data))), + ]) + }); + let anchors = self.citations().into_iter().map(|citation| { + object([ + ("id", text_json(&citation.id)), + ("family", text_json("flow")), + ("role", text_json(citation.role)), + ( + "locator", + object([ + ("family", text_json("flow")), + ("section", text_json(&citation.section)), + ("ordinal", Json::Int(citation.ordinal.into())), + ]), + ), + ( + "text_digest", + text_json(&sha256_hex(citation.text.as_bytes())), + ), + ("text", Json::Str(citation.text)), + ]) + }); + object([ + ("schema_version", text_json(MODEL_VERSION)), + ("format", text_json("docx")), + ("family", text_json("flow")), + ("blocks", blocks_json(&content.blocks)), + ("footnotes", notes(&content.footnotes)), + ("endnotes", notes(&content.endnotes)), + ("comments", Json::Arr(comments.collect())), + ("assets", Json::Arr(assets.collect())), + ("anchors", Json::Arr(anchors.collect())), + ( + "fidelity", + Json::Obj( + self.fidelity + .iter() + .map(|(feature, status)| (feature.clone(), text_json(status.code()))) + .collect(), + ), + ), + ( + "diagnostics", + Json::Arr( + self.diagnostics + .iter() + .map(|code| text_json(code)) + .collect(), + ), + ), + ]) + .to_bytes() + } + + /// Reads canonical bytes back. Anything that does not re-serialize to the + /// same bytes, or whose assets do not match their digests, is refused. + pub fn from_canonical(bytes: &[u8]) -> Result { + let root = parse_json(bytes)?; + let map = as_map(&root)?; + if string(map, "schema_version")? != MODEL_VERSION || string(map, "format")? != "docx" { + return Err(Refusal::MalformedContainer); + } + let mut assets = BTreeMap::new(); + for asset in array(map, "assets")? { + let asset = as_map(asset)?; + let digest = string(asset, "sha256")?; + let data = unbase64(&string(asset, "data")?).ok_or(Refusal::MalformedContainer)?; + if sha256_hex(&data) != digest { + return Err(Refusal::MalformedContainer); + } + let media_type = string(asset, "media_type")?; + assets.insert(digest, Asset { media_type, data }); + } + let notes = |key| { + array(map, key)? + .iter() + .map(|note| { + let note = as_map(note)?; + Ok(Note { + id: integer(note, "id")?, + blocks: blocks_from(array(note, "blocks")?)?, + }) + }) + .collect::, Refusal>>() + }; + let comments = array(map, "comments")? + .iter() + .map(|comment| { + let comment = as_map(comment)?; + Ok(Comment { + id: integer(comment, "id")?, + author: string(comment, "author")?, + date: string(comment, "date")?, + initials: string(comment, "initials")?, + blocks: blocks_from(array(comment, "blocks")?)?, + }) + }) + .collect::, Refusal>>()?; + let content = Content { + blocks: blocks_from(array(map, "blocks")?)?, + footnotes: notes("footnotes")?, + endnotes: notes("endnotes")?, + comments, + assets, + }; + let mut missing = false; + content.paragraphs(&mut |paragraph| { + for inline in ¶graph.inlines { + if let Inline::Run(Run { + content: RunContent::Image(image), + .. + }) = inline + { + missing |= !content.assets.contains_key(&image.asset); + } + } + }); + let fidelity = as_map(field(map, "fidelity")?)? + .iter() + .map(|(feature, status)| match status { + Json::Str(code) => Status::parse(code) + .map(|status| (feature.clone(), status)) + .ok_or(Refusal::MalformedContainer), + _ => Err(Refusal::MalformedContainer), + }) + .collect::>()?; + let diagnostics = array(map, "diagnostics")? + .iter() + .map(|code| match code { + Json::Str(code) => Ok(code.clone()), + _ => Err(Refusal::MalformedContainer), + }) + .collect::>()?; + let document = Document { + content, + fidelity, + diagnostics, + }; + if missing || document.canonical() != bytes { + return Err(Refusal::MalformedContainer); + } + Ok(document) + } +} + +type Map = BTreeMap; + +fn as_map(json: &Json) -> Result<&Map, Refusal> { + match json { + Json::Obj(map) => Ok(map), + _ => Err(Refusal::MalformedContainer), + } +} + +fn field<'j>(map: &'j Map, key: &str) -> Result<&'j Json, Refusal> { + map.get(key).ok_or(Refusal::MalformedContainer) +} + +fn string(map: &Map, key: &str) -> Result { + match field(map, key)? { + Json::Str(value) => Ok(value.clone()), + _ => Err(Refusal::MalformedContainer), + } +} + +fn integer(map: &Map, key: &str) -> Result { + match field(map, key)? { + Json::Int(value) => Ok(*value), + _ => Err(Refusal::MalformedContainer), + } +} + +fn ranged>(map: &Map, key: &str, min: i64, max: i64) -> Result { + let value = integer(map, key)?; + if !(min..=max).contains(&value) { + return Err(Refusal::MalformedContainer); + } + T::try_from(value).map_err(|_| Refusal::MalformedContainer) +} + +fn flag(map: &Map, key: &str) -> Result { + match field(map, key)? { + Json::Bool(value) => Ok(*value), + _ => Err(Refusal::MalformedContainer), + } +} + +fn array<'j>(map: &'j Map, key: &str) -> Result<&'j [Json], Refusal> { + match field(map, key)? { + Json::Arr(items) => Ok(items), + _ => Err(Refusal::MalformedContainer), + } +} + +fn blocks_from(items: &[Json]) -> Result, Refusal> { + items.iter().map(block_from).collect() +} + +fn block_from(json: &Json) -> Result { + let map = as_map(json)?; + if string(map, "type")? == "table" { + let rows = array(map, "rows")?.iter().map(|row| { + let Json::Arr(cells) = row else { + return Err(Refusal::MalformedContainer); + }; + cells + .iter() + .map(|cell| { + let cell = as_map(cell)?; + let merge = [Merge::Single, Merge::Restart, Merge::Continue] + .into_iter() + .find(|merge| { + merge_code(*merge) == string(cell, "merge").unwrap_or_default() + }) + .ok_or(Refusal::MalformedContainer)?; + Ok(Cell { + span: ranged(cell, "span", 1, 63)?, + merge, + blocks: blocks_from(array(cell, "blocks")?)?, + }) + }) + .collect() + }); + return Ok(Block::Table(rows.collect::>()?)); + } + let kind = match string(map, "kind")?.as_str() { + "body" => Kind::Body, + "title" => Kind::Title, + "caption" => Kind::Caption, + "heading" => Kind::Heading(ranged(map, "level", 1, 9)?), + "list" => { + let format = string(map, "format")?; + if !valid_format(&format) { + return Err(Refusal::MalformedContainer); + } + Kind::List { + num: ranged(map, "num", 1, u32::MAX.into())?, + level: ranged(map, "level", 0, 8)?, + format, + } + } + _ => return Err(Refusal::MalformedContainer), + }; + Ok(Block::Paragraph(Paragraph { + kind, + section_break: flag(map, "section_break")?, + inlines: array(map, "inlines")? + .iter() + .map(inline_from) + .collect::>()?, + })) +} + +fn inline_from(json: &Json) -> Result { + let map = as_map(json)?; + let content = match string(map, "type")?.as_str() { + "comment-start" => return Ok(Inline::CommentStart(integer(map, "id")?)), + "comment-end" => return Ok(Inline::CommentEnd(integer(map, "id")?)), + "bookmark-start" => return Ok(Inline::BookmarkStart(string(map, "name")?)), + "bookmark-end" => return Ok(Inline::BookmarkEnd(string(map, "name")?)), + "text" => RunContent::Text(string(map, "text")?), + "image" => RunContent::Image(Image { + asset: string(map, "asset")?, + width: ranged(map, "width", 1, MAX_EMU)?, + height: ranged(map, "height", 1, MAX_EMU)?, + name: string(map, "name")?, + description: string(map, "description")?, + }), + "footnote-ref" => RunContent::FootnoteRef(integer(map, "id")?), + "endnote-ref" => RunContent::EndnoteRef(integer(map, "id")?), + "comment-ref" => RunContent::CommentRef(integer(map, "id")?), + _ => return Err(Refusal::MalformedContainer), + }; + let link = match field(map, "link")? { + Json::Null => None, + link => { + let link = as_map(link)?; + match (link.get("external"), link.get("internal")) { + (Some(Json::Str(target)), None) => Some(Link::External(target.clone())), + (None, Some(Json::Str(target))) => Some(Link::Internal(target.clone())), + _ => return Err(Refusal::MalformedContainer), + } + } + }; + let change = match field(map, "change")? { + Json::Null => None, + change => { + let change = as_map(change)?; + let kind = string(change, "kind")?; + Some(Change { + kind: ChangeKind::ALL + .into_iter() + .find(|candidate| candidate.element() == kind) + .ok_or(Refusal::MalformedContainer)?, + id: integer(change, "id")?, + author: string(change, "author")?, + date: string(change, "date")?, + }) + } + }; + Ok(Inline::Run(Run { + content, + bold: flag(map, "bold")?, + italic: flag(map, "italic")?, + underline: flag(map, "underline")?, + link, + change, + })) +} + +/// Parses canonical JSON only: no whitespace, integers only, bounded depth. +/// `from_canonical` then insists the value re-serializes to the same bytes. +fn parse_json(bytes: &[u8]) -> Result { + let mut reader = JsonReader { bytes, at: 0 }; + let value = reader.value(0)?; + if reader.at != bytes.len() { + return Err(Refusal::MalformedContainer); + } + Ok(value) +} + +struct JsonReader<'b> { + bytes: &'b [u8], + at: usize, +} + +impl JsonReader<'_> { + fn byte(&mut self) -> Result { + let byte = *self.bytes.get(self.at).ok_or(Refusal::MalformedContainer)?; + self.at += 1; + Ok(byte) + } + + fn literal(&mut self, literal: &[u8]) -> Result<(), Refusal> { + if !self.bytes[self.at..].starts_with(literal) { + return Err(Refusal::MalformedContainer); + } + self.at += literal.len(); + Ok(()) + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_JSON_DEPTH { + return Err(Refusal::MalformedContainer); + } + match self.bytes.get(self.at) { + Some(b'{') => { + self.at += 1; + let mut map = BTreeMap::new(); + if self.bytes.get(self.at) == Some(&b'}') { + self.at += 1; + return Ok(Json::Obj(map)); + } + loop { + let key = self.string()?; + self.literal(b":")?; + if map.insert(key, self.value(depth + 1)?).is_some() { + return Err(Refusal::MalformedContainer); + } + match self.byte()? { + b',' => {} + b'}' => return Ok(Json::Obj(map)), + _ => return Err(Refusal::MalformedContainer), + } + } + } + Some(b'[') => { + self.at += 1; + let mut items = Vec::new(); + if self.bytes.get(self.at) == Some(&b']') { + self.at += 1; + return Ok(Json::Arr(items)); + } + loop { + items.push(self.value(depth + 1)?); + match self.byte()? { + b',' => {} + b']' => return Ok(Json::Arr(items)), + _ => return Err(Refusal::MalformedContainer), + } + } + } + Some(b'"') => Ok(Json::Str(self.string()?)), + Some(b't') => self.literal(b"true").map(|()| Json::Bool(true)), + Some(b'f') => self.literal(b"false").map(|()| Json::Bool(false)), + Some(b'n') => self.literal(b"null").map(|()| Json::Null), + Some(b'-' | b'0'..=b'9') => { + let start = self.at; + self.at += 1; + while matches!(self.bytes.get(self.at), Some(b'0'..=b'9')) { + self.at += 1; + } + std::str::from_utf8(&self.bytes[start..self.at]) + .ok() + .and_then(|digits| digits.parse().ok()) + .map(Json::Int) + .ok_or(Refusal::MalformedContainer) + } + _ => Err(Refusal::MalformedContainer), + } + } + + fn string(&mut self) -> Result { + if self.byte()? != b'"' { + return Err(Refusal::MalformedContainer); + } + let mut out = Vec::new(); + loop { + match self.byte()? { + b'"' => return String::from_utf8(out).map_err(|_| Refusal::MalformedContainer), + b'\\' => { + let decoded = match self.byte()? { + b'"' => '"', + b'\\' => '\\', + b'n' => '\n', + b'r' => '\r', + b't' => '\t', + b'b' => '\u{8}', + b'f' => '\u{c}', + // The canonical writer escapes only C0 controls. + b'u' => char::from_u32(self.hex()?) + .filter(|c| (*c as u32) < 0x20) + .ok_or(Refusal::MalformedContainer)?, + _ => return Err(Refusal::MalformedContainer), + }; + out.extend_from_slice(decoded.encode_utf8(&mut [0; 4]).as_bytes()); + } + byte if byte < 0x20 => return Err(Refusal::MalformedContainer), + byte => out.push(byte), + } + } + } + + fn hex(&mut self) -> Result { + let digits = self + .bytes + .get(self.at..self.at + 4) + .filter(|digits| digits.iter().all(u8::is_ascii_hexdigit)) + .ok_or(Refusal::MalformedContainer)?; + self.at += 4; + let digits = std::str::from_utf8(digits).map_err(|_| Refusal::MalformedContainer)?; + u32::from_str_radix(digits, 16).map_err(|_| Refusal::MalformedContainer) + } +} + +const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let bytes = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let value = u32::from_be_bytes([0, bytes[0], bytes[1], bytes[2]]); + for index in 0..4 { + out.push(if index <= chunk.len() { + BASE64[(value >> (18 - 6 * index)) as usize & 63] as char + } else { + '=' + }); + } + } + out +} + +/// Strict inverse of `base64`: anything it would not have written is refused. +fn unbase64(text: &str) -> Option> { + let sextet = |byte: u8| match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + }; + if !text.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(text.len() / 4 * 3); + for chunk in text.as_bytes().chunks(4) { + let padding = chunk.iter().rev().take_while(|byte| **byte == b'=').count(); + let mut value = 0u32; + for byte in &chunk[..4 - padding] { + value = value << 6 | u32::from(sextet(*byte)?); + } + value <<= 6 * padding; + out.extend_from_slice(&value.to_be_bytes()[1..4 - padding.min(2)]); + } + (base64(&out) == text).then_some(out) +} + +// ---- Export ---- + +/// Exports canonical DOCX bytes and a fidelity receipt. DLP and the public +/// boundary are checked on every emitted string before any bytes are built. +pub fn export(imported: &Imported) -> Result { + let (document, _) = prepare(imported)?; + Ok(render(&document, &imported.canonical)) +} + +/// Exports to `root/relative` through the shared gate: explicit `.docx` +/// destination, contained, never overwritten, DLP checked before writing. +/// Returns the written path and the fidelity receipt. +pub fn export_file( + root: &Path, + relative: &str, + imported: &Imported, +) -> Result<(PathBuf, Vec), Refusal> { + let (document, texts) = prepare(imported)?; + let exported = render(&document, &imported.canonical); + let texts: Vec<&str> = texts.iter().map(String::as_str).collect(); + let path = crate::export::write_new(root, relative, Format::Docx, &exported.bytes, &texts)?; + Ok((path, exported.receipt)) +} + +fn prepare(imported: &Imported) -> Result<(Document, Vec), Refusal> { + let document = Document::from_canonical(&imported.canonical)?; + for code in imported.receipt.iter().chain(&document.diagnostics) { + match code.as_str() { + "dlp-findings" => return Err(Refusal::DlpFindings), + "public-boundary-violation" => return Err(Refusal::PublicBoundary), + _ => {} + } + } + let texts = emitted_texts(&document.content); + check_texts(&texts)?; + Ok((document, texts)) +} + +fn render(document: &Document, canonical: &[u8]) -> Exported { + let bytes = write_docx(&document.content); + let status = |statuses: &[(&str, Status)]| { + Json::Obj( + statuses + .iter() + .map(|(feature, status)| ((*feature).to_owned(), text_json(status.code()))) + .collect(), + ) + }; + let receipt = object([ + ("schema_version", text_json(MODEL_VERSION)), + ("format", text_json("docx")), + ("source_canonical_sha256", text_json(&sha256_hex(canonical))), + ("output_sha256", text_json(&sha256_hex(&bytes))), + ("output_byte_length", Json::Int(bytes.len() as i64)), + ( + "import_fidelity", + Json::Obj( + document + .fidelity + .iter() + .map(|(feature, status)| (feature.clone(), text_json(status.code()))) + .collect(), + ), + ), + // Everything in the model is written; formatting outside it is regenerated. + ( + "export_fidelity", + status(&[ + ("model", Status::Exact), + ("styles", Status::Normalized), + ("numbering", Status::Normalized), + ("sections", Status::Normalized), + ("tables", Status::Normalized), + ("run-formatting", Status::Normalized), + ]), + ), + ( + "anchors", + Json::Arr( + document + .citations() + .iter() + .map(|citation| text_json(&citation.id)) + .collect(), + ), + ), + ]) + .to_bytes(); + Exported { bytes, receipt } +} + +const XML_DECL: &str = "\n"; +const NAMESPACES: &str = + "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" \ +xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \ +xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" \ +xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \ +xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\""; +const SECTION: &str = ""; + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Relationships of one part, numbered in first-use order. +#[derive(Default)] +struct Rels(Vec<(String, String, bool)>); + +impl Rels { + fn id(&mut self, kind: &str, target: &str, external: bool) -> String { + let entry = (kind.to_owned(), target.to_owned(), external); + let index = match self.0.iter().position(|existing| *existing == entry) { + Some(index) => index, + None => { + self.0.push(entry); + self.0.len() - 1 + } + }; + format!("rId{}", index + 1) + } + + fn xml(&self) -> Vec { + let mut out = format!( + "{XML_DECL}" + ); + for (index, (kind, target, external)) in self.0.iter().enumerate() { + out.push_str(&format!( + "", + index + 1, + escape(target), + if *external { + " TargetMode=\"External\"" + } else { + "" + } + )); + } + out.push_str(""); + out.into_bytes() + } +} + +fn extension(media_type: &str) -> &'static str { + match media_type.to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpeg", + "image/gif" => "gif", + "image/bmp" => "bmp", + "image/tiff" => "tiff", + "image/x-emf" | "image/emf" => "emf", + "image/x-wmf" | "image/wmf" => "wmf", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +struct Writer<'c> { + content: &'c Content, + media: BTreeSet<&'c str>, + bookmarks: BTreeMap<&'c str, usize>, + drawings: usize, +} + +fn write_docx(content: &Content) -> Vec { + let mut writer = Writer { + content, + media: BTreeSet::new(), + bookmarks: BTreeMap::new(), + drawings: 0, + }; + let mut parts: Vec<(String, Vec)> = Vec::new(); + let mut types = vec![ + ("/word/document.xml".to_owned(), MAIN_TYPE.to_owned()), + ( + "/word/styles.xml".to_owned(), + format!("{WML_TYPE}styles+xml"), + ), + ]; + let mut rels = Rels::default(); + rels.id("styles", "styles.xml", false); + parts.push(("word/styles.xml".to_owned(), styles_xml().into_bytes())); + if let Some(numbering) = numbering_xml(content) { + rels.id("numbering", "numbering.xml", false); + parts.push(("word/numbering.xml".to_owned(), numbering.into_bytes())); + types.push(( + "/word/numbering.xml".to_owned(), + format!("{WML_TYPE}numbering+xml"), + )); + } + let stories: [(&str, &[Note]); 2] = [ + ("footnote", &content.footnotes), + ("endnote", &content.endnotes), + ]; + for (item, notes) in stories { + if notes.is_empty() { + continue; + } + let mut part_rels = Rels::default(); + let xml = writer.notes(item, notes, &mut part_rels); + add_story( + &mut parts, + &mut types, + &mut rels, + &format!("{item}s"), + xml, + part_rels, + ); + } + if !content.comments.is_empty() { + let mut part_rels = Rels::default(); + let xml = writer.comments(&mut part_rels); + add_story( + &mut parts, &mut types, &mut rels, "comments", xml, part_rels, + ); + } + let mut body = format!("{XML_DECL}"); + writer.blocks(&mut body, &content.blocks, &mut rels, None); + body.push_str(SECTION); + body.push_str(""); + parts.push(("word/document.xml".to_owned(), body.into_bytes())); + parts.push(("word/_rels/document.xml.rels".to_owned(), rels.xml())); + for digest in &writer.media { + let asset = &content.assets[*digest]; + let name = format!("word/media/{digest}.{}", extension(&asset.media_type)); + types.push((format!("/{name}"), asset.media_type.clone())); + parts.push((name, asset.data.clone())); + } + let mut package_rels = Rels::default(); + package_rels.id("officeDocument", "word/document.xml", false); + parts.push(("_rels/.rels".to_owned(), package_rels.xml())); + let mut content_types = format!( + "{XML_DECL}\ +\ +" + ); + types.sort(); + for (name, content_type) in &types { + content_types.push_str(&format!( + "", + escape(name), + escape(content_type) + )); + } + content_types.push_str(""); + parts.push(("[Content_Types].xml".to_owned(), content_types.into_bytes())); + parts.sort(); + let entries: Vec> = parts + .iter() + .map(|(name, data)| zip::WriteEntry { + name, + data, + deflate: true, + dos_time: 0, + dos_date: 0x0021, + }) + .collect(); + zip::write(&entries) +} + +/// Adds a notes or comments part, its content type, and its relationships. +fn add_story( + parts: &mut Vec<(String, Vec)>, + types: &mut Vec<(String, String)>, + rels: &mut Rels, + name: &str, + xml: String, + part_rels: Rels, +) { + rels.id(name, &format!("{name}.xml"), false); + types.push((format!("/word/{name}.xml"), format!("{WML_TYPE}{name}+xml"))); + parts.push((format!("word/{name}.xml"), xml.into_bytes())); + if !part_rels.0.is_empty() { + parts.push((format!("word/_rels/{name}.xml.rels"), part_rels.xml())); + } +} + +impl<'c> Writer<'c> { + fn notes(&mut self, item: &str, notes: &'c [Note], rels: &mut Rels) -> String { + let mut out = format!("{XML_DECL}"); + if !notes.iter().any(|note| note.id == -1 || note.id == 0) { + for (id, kind) in [(-1, "separator"), (0, "continuationSeparator")] { + out.push_str(&format!( + "" + )); + } + } + for note in notes { + out.push_str(&format!("", note.id)); + self.blocks(&mut out, ¬e.blocks, rels, Some(&format!("{item}Ref"))); + out.push_str(&format!("")); + } + out.push_str(&format!("")); + out + } + + fn comments(&mut self, rels: &mut Rels) -> String { + let mut out = format!("{XML_DECL}"); + for comment in &self.content.comments { + out.push_str(&format!( + "'); + self.blocks(&mut out, &comment.blocks, rels, None); + out.push_str(""); + } + out.push_str(""); + out + } + + /// `mark` is the note reference mark written at the start of the first paragraph. + fn blocks( + &mut self, + out: &mut String, + blocks: &'c [Block], + rels: &mut Rels, + mark: Option<&str>, + ) { + for (index, block) in blocks.iter().enumerate() { + match block { + Block::Paragraph(paragraph) => { + self.paragraph(out, paragraph, rels, mark.filter(|_| index == 0)); + } + Block::Table(rows) => self.table(out, rows, rels), + } + } + } + + fn table(&mut self, out: &mut String, rows: &'c [Vec], rels: &mut Rels) { + let columns = rows + .iter() + .map(|row| row.iter().map(|cell| cell.span as usize).sum::()) + .max() + .unwrap_or(1) + .max(1); + out.push_str(""); + for _ in 0..columns { + out.push_str(&format!("", (9360 / columns).max(1))); + } + out.push_str(""); + for row in rows { + out.push_str(""); + for cell in row { + out.push_str(""); + if cell.span > 1 || cell.merge != Merge::Single { + out.push_str(""); + if cell.span > 1 { + out.push_str(&format!("", cell.span)); + } + match cell.merge { + Merge::Single => {} + Merge::Restart => out.push_str(""), + Merge::Continue => out.push_str(""), + } + out.push_str(""); + } + self.blocks(out, &cell.blocks, rels, None); + out.push_str(""); + } + out.push_str(""); + } + out.push_str(""); + } + + fn paragraph( + &mut self, + out: &mut String, + paragraph: &'c Paragraph, + rels: &mut Rels, + mark: Option<&str>, + ) { + out.push_str(""); + let style = match ¶graph.kind { + Kind::Body => None, + Kind::Title => Some("Title".to_owned()), + Kind::Caption => Some("Caption".to_owned()), + Kind::Heading(level) => Some(format!("Heading{level}")), + Kind::List { .. } => Some("ListParagraph".to_owned()), + }; + if style.is_some() || paragraph.section_break { + out.push_str(""); + if let Some(style) = style { + out.push_str(&format!("")); + } + if let Kind::List { num, level, .. } = ¶graph.kind { + out.push_str(&format!( + "" + )); + } + if paragraph.section_break { + out.push_str(SECTION); + } + out.push_str(""); + } + if let Some(mark) = mark { + out.push_str(&format!( + "" + )); + } + self.inlines(out, ¶graph.inlines, rels); + out.push_str(""); + } + + /// Runs are grouped into one hyperlink per link and, inside it, one + /// change element per tracked change, matching WordprocessingML nesting. + fn inlines(&mut self, out: &mut String, inlines: &'c [Inline], rels: &mut Rels) { + let mut at = 0; + while at < inlines.len() { + let Inline::Run(first) = &inlines[at] else { + self.marker(out, &inlines[at]); + at += 1; + continue; + }; + let runs: Vec<&Run> = inlines[at..] + .iter() + .map_while(|inline| match inline { + Inline::Run(run) if run.link == first.link => Some(run), + _ => None, + }) + .collect(); + at += runs.len(); + match &first.link { + None => {} + Some(Link::External(target)) => out.push_str(&format!( + "", + rels.id("hyperlink", target, true) + )), + Some(Link::Internal(name)) => { + out.push_str(&format!("", escape(name))); + } + } + for group in runs.chunk_by(|left, right| left.change == right.change) { + let change = &group[0].change; + if let Some(change) = change { + out.push_str(&format!( + "'); + } + for run in group { + self.run(out, run, rels); + } + if let Some(change) = change { + out.push_str(&format!("", change.kind.element())); + } + } + if first.link.is_some() { + out.push_str(""); + } + } + } + + fn marker(&mut self, out: &mut String, inline: &'c Inline) { + let next = self.bookmarks.len(); + match inline { + Inline::CommentStart(id) => { + out.push_str(&format!("")); + } + Inline::CommentEnd(id) => out.push_str(&format!("")), + Inline::BookmarkStart(name) => { + let id = *self.bookmarks.entry(name).or_insert(next); + out.push_str(&format!( + "", + escape(name) + )); + } + Inline::BookmarkEnd(name) => { + let id = *self.bookmarks.entry(name).or_insert(next); + out.push_str(&format!("")); + } + Inline::Run(_) => {} + } + } + + fn run(&mut self, out: &mut String, run: &'c Run, rels: &mut Rels) { + out.push_str(""); + let mut props = String::new(); + for (on, element) in [ + (run.bold, ""), + (run.italic, ""), + (run.underline, ""), + ( + matches!( + run.content, + RunContent::FootnoteRef(_) | RunContent::EndnoteRef(_) + ), + "", + ), + ] { + if on { + props.push_str(element); + } + } + if !props.is_empty() { + out.push_str(&format!("{props}")); + } + match &run.content { + RunContent::Text(text) => { + let removed = run + .change + .as_ref() + .is_some_and(|change| change.kind.removes()); + let tag = if removed { "w:delText" } else { "w:t" }; + for (line_index, line) in text.split('\n').enumerate() { + if line_index > 0 { + out.push_str(""); + } + for (index, piece) in line.split('\t').enumerate() { + if index > 0 { + out.push_str(""); + } + if !piece.is_empty() { + out.push_str(&format!( + "<{tag} xml:space=\"preserve\">{}", + escape(piece) + )); + } + } + } + } + RunContent::Image(image) => self.drawing(out, image, rels), + RunContent::FootnoteRef(id) => { + out.push_str(&format!("")); + } + RunContent::EndnoteRef(id) => { + out.push_str(&format!("")); + } + RunContent::CommentRef(id) => { + out.push_str(&format!("")); + } + } + out.push_str(""); + } + + fn drawing(&mut self, out: &mut String, image: &'c Image, rels: &mut Rels) { + let Some(asset) = self.content.assets.get(&image.asset) else { + return; + }; + self.media.insert(&image.asset); + self.drawings += 1; + let target = format!("media/{}.{}", image.asset, extension(&asset.media_type)); + let id = rels.id("image", &target, false); + let (drawing, cx, cy) = (self.drawings, image.width, image.height); + let (name, description) = (escape(&image.name), escape(&image.description)); + out.push_str(&format!( + "\ +\ +\ +\ +\ +\ +\ +" + )); + } +} + +fn numbering_xml(content: &Content) -> Option { + let mut lists: BTreeMap> = BTreeMap::new(); + content.paragraphs(&mut |paragraph| { + if let Kind::List { num, level, format } = ¶graph.kind { + lists + .entry(*num) + .or_default() + .entry(*level) + .or_insert(format); + } + }); + if lists.is_empty() { + return None; + } + let mut out = format!( + "{XML_DECL}" + ); + for (num, levels) in &lists { + out.push_str(&format!( + "" + )); + for level in 0..9u8 { + let format = levels.get(&level).copied().unwrap_or("decimal"); + let text = if format == "bullet" { + "\u{2022}".to_owned() + } else { + format!("%{}.", level + 1) + }; + out.push_str(&format!( + "\ +", + 720 * (u32::from(level) + 1) + )); + } + out.push_str(""); + } + for num in lists.keys() { + out.push_str(&format!( + "" + )); + } + out.push_str(""); + Some(out) +} + +/// The fixed style sheet every export carries: only the styles the model uses. +fn styles_xml() -> String { + let paragraph = |id: &str, name: &str, extra: &str| { + format!( + "\ +{extra}" + ) + }; + let mut out = format!( + "{XML_DECL}\ +\ +\ +" + ); + out.push_str(¶graph( + "Title", + "Title", + "", + )); + for level in 1..=9u32 { + out.push_str(¶graph( + &format!("Heading{level}"), + &format!("heading {level}"), + &format!( + "", + level - 1, + (36 - 2 * level.min(6)).max(22) + ), + )); + } + out.push_str(¶graph( + "Caption", + "caption", + "", + )); + out.push_str(¶graph("ListParagraph", "List Paragraph", "")); + let border = |side: &str| { + format!("") + }; + out.push_str(&format!( + "{}", + ["top", "left", "bottom", "right", "insideH", "insideV"] + .map(border) + .concat() + )); + out +} + +// ---- Adapter ---- + +pub struct Docx; + +impl Adapter for Docx { + fn format(&self) -> Format { + Format::Docx + } + + fn import(&self, bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result { + read(bytes, limits, cancel).map(|document| document.imported()) + } + + fn export(&self, imported: &Imported) -> Result, Refusal> { + export(imported).map(|exported| exported.bytes) + } +} diff --git a/crates/bran-document/src/lib.rs b/crates/bran-document/src/lib.rs index 1f98d98..1c52f71 100644 --- a/crates/bran-document/src/lib.rs +++ b/crates/bran-document/src/lib.rs @@ -8,6 +8,7 @@ pub mod canonical; pub mod conformance; +pub mod docx; pub mod export; pub mod opc; pub mod xml; diff --git a/crates/bran-document/tests/conformance.rs b/crates/bran-document/tests/conformance.rs index 308ebe9..f9e15d3 100644 --- a/crates/bran-document/tests/conformance.rs +++ b/crates/bran-document/tests/conformance.rs @@ -32,6 +32,12 @@ const XLSX_BASE: &str = include_str!("../../../fixtures/enterprise-documents/conformance/xlsx-base.parts"); const PPTX_BASE: &str = include_str!("../../../fixtures/enterprise-documents/conformance/pptx-base.parts"); +const DOCX_REPRESENTATIVE: &str = + include_str!("../../../fixtures/enterprise-documents/docx/representative.parts"); +const DOCX_UNSUPPORTED: &str = + include_str!("../../../fixtures/enterprise-documents/docx/unsupported-benign.parts"); +/// A valid 1x1 PNG for the DOCX image part; `.parts` fixtures hold text only. +const PIXEL_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x78\xda\x63\xd0\xcb\x5e\x01\x00\x02\x0c\x01\x42\x16\x7d\x65\x4c\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; const CONFORMANCE_DOC: &str = include_str!("../../../docs/enterprise-document-conformance.md"); const CANARIES: &str = include_str!("../../../fixtures/public-boundary/rejected/synthetic-canaries.txt"); @@ -601,20 +607,113 @@ fn run_corpus(tier: Tier) -> Report { report.checked += 1; } for (format, row) in ADAPTER_ROWS { - if conformance::registered() - .iter() - .all(|adapter| adapter.format() != *format) - { + let adapters: Vec<&dyn Adapter> = conformance::registered() + .into_iter() + .filter(|adapter| adapter.format() == *format) + .collect(); + if adapters.is_empty() { report .unavailable .push((*format, row, format.adapter_issue())); - } else { - panic!("{format:?} adapter registered: replace {row} with an executable row"); + } + for adapter in adapters { + adapter_row(adapter, row, tier) + .unwrap_or_else(|error| panic!("{format:?}/{row}: {error}")); + report.checked += 1; } } report } +/// Recorded canonical digest of the DOCX representative projection. A change +/// here is a change to the DOCX content model and must be reviewed as one. +const DOCX_PROJECTION: &str = "b8572ed2ebb59a213503f4873a76fbb9b3a6553910e4044f5089e7cf95c9c14f"; + +fn docx_representative() -> Parts { + Parts::parse(DOCX_REPRESENTATIVE).add("word/media/pixel.png", PIXEL_PNG) +} + +fn reencodings(parts: &Parts) -> Vec> { + vec![ + parts.zip_with(true, false, 0x4A21), + parts.zip_with(false, true, 0x3C01), + ] +} + +/// Executable adapter rows. A registered adapter whose rows are not listed +/// here fails the corpus, so no row can stay unavailable after registration. +fn adapter_row(adapter: &dyn Adapter, row: &str, tier: Tier) -> Result<(), String> { + let (limits, cancel) = (limits(tier), Cancel::default()); + let run = |parts: &Parts, expect: &Expect| { + conformance::check( + adapter, + &parts.zip(), + &reencodings(parts), + expect, + &limits, + &cancel, + ) + }; + match row { + "docx-ordinary-projection" => { + let parts = docx_representative(); + run( + &parts, + &Expect::Admit(vec![ + "hyperlink-not-fetched", + "headings:normalized", + "lists:normalized", + "tables:normalized", + "hyperlinks:exact", + "bookmarks:exact", + "captions:normalized", + "comments:normalized", + "tracked-changes:exact", + "footnotes:normalized", + "endnotes:normalized", + "images:exact", + "sections:normalized", + ]), + )?; + let imported = adapter + .import(&parts.zip(), &limits, &cancel) + .map_err(|refusal| format!("refused: {refusal}"))?; + let digest = sha256_hex(&imported.canonical); + if digest != DOCX_PROJECTION { + return Err(format!("projection changed: {digest}")); + } + Ok(()) + } + "docx-unsupported-benign-fidelity" => run( + &Parts::parse(DOCX_UNSUPPORTED), + &Expect::Admit(vec![ + "charts:unsupported", + "math:unsupported", + "shapes:unsupported", + "headers-footers:unsupported", + "document-properties:unsupported", + "symbols:unsupported", + "formatting-changes:unsupported", + "paragraph-mark-changes:unsupported", + "fields:normalized", + "content-controls:normalized", + ]), + ) + .map(|_| ()), + "docx-round-trip-anchors" => { + let outcome = run(&docx_representative(), &Expect::Admit(vec![]))?; + if outcome.round_trip { + Ok(()) + } else { + Err("export refused, so no round trip was exercised".to_owned()) + } + } + other => Err(format!( + "adapter registered: replace {other} with an executable row" + )), + } +} + /// The ordinary package's canonical projection matches the recorded digest. fn check_recorded_digest(format: Format) { let limits = limits(Tier::Fast); @@ -667,7 +766,17 @@ fn enterprise_conformance_fast() { for (format, row, issue) in &report.unavailable { println!("unavailable {format:?}/{row}: needs adapter #{issue}"); } - assert_eq!(report.unavailable.len(), ADAPTER_ROWS.len()); + let registered: Vec = conformance::registered() + .iter() + .map(|adapter| adapter.format()) + .collect(); + assert_eq!( + report.unavailable.len(), + ADAPTER_ROWS + .iter() + .filter(|(format, _)| !registered.contains(format)) + .count() + ); assert!( elapsed <= FAST_RUNTIME_BUDGET, "fast tier exceeded its runtime budget: {elapsed:?}" diff --git a/crates/bran-document/tests/docx.rs b/crates/bran-document/tests/docx.rs new file mode 100644 index 0000000..3a1ae9a --- /dev/null +++ b/crates/bran-document/tests/docx.rs @@ -0,0 +1,841 @@ +//! DOCX adapter (issue #21): import, fidelity receipts, export, and round trips. +//! +//! Every package is built in memory from the reviewable `.parts` fixtures in +//! `fixtures/enterprise-documents/docx/`. The only binary is a one-pixel PNG, +//! kept inline below so readers can decode the image part. + +use bran_document::canonical::sha256_hex; +use bran_document::conformance::{Adapter, Imported, PackageIntake}; +use bran_document::docx::{ + self, Block, ChangeKind, Document, Docx, Inline, Kind, Link, Merge, Paragraph, RunContent, + Status, +}; +use bran_document::zip::{self, WriteEntry}; +use bran_document::{Cancel, Format, Limits, Refusal}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const REPRESENTATIVE: &str = + include_str!("../../../fixtures/enterprise-documents/docx/representative.parts"); +const UNSUPPORTED: &str = + include_str!("../../../fixtures/enterprise-documents/docx/unsupported-benign.parts"); +const CANARIES: &str = + include_str!("../../../fixtures/public-boundary/rejected/synthetic-canaries.txt"); +const MAX_FIXTURE_FILE_BYTES: usize = 8 * 1024; +const PIXEL_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x78\xda\x63\xd0\xcb\x5e\x01\x00\x02\x0c\x01\x42\x16\x7d\x65\x4c\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; +const MAIN: &str = "word/document.xml"; +const RELS: &str = "word/_rels/document.xml.rels"; +const TYPES: &str = "[Content_Types].xml"; +const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +type Parts = Vec<(String, Vec)>; + +fn parse_parts(text: &str) -> Parts { + let mut parts: Parts = Vec::new(); + for line in text.lines() { + if let Some(name) = line.strip_prefix("--- ") { + parts.push((name.to_owned(), Vec::new())); + } else { + let body = &mut parts.last_mut().expect("part header first").1; + if !body.is_empty() { + body.push(b'\n'); + } + body.extend_from_slice(line.as_bytes()); + } + } + parts +} + +fn representative() -> Parts { + let mut parts = parse_parts(REPRESENTATIVE); + parts.push(("word/media/pixel.png".to_owned(), PIXEL_PNG.to_vec())); + parts +} + +fn edit(mut parts: Parts, name: &str, from: &str, to: &str) -> Parts { + let part = parts + .iter_mut() + .find(|(part, _)| part == name) + .expect("part"); + let text = String::from_utf8(part.1.clone()).expect("utf-8 part"); + assert!(text.contains(from), "edit anchor missing in {name}: {from}"); + part.1 = text.replacen(from, to, 1).into_bytes(); + parts +} + +fn add(mut parts: Parts, name: &str, data: impl Into>) -> Parts { + parts.push((name.to_owned(), data.into())); + parts +} + +fn override_type(parts: Parts, part: &str, content_type: &str) -> Parts { + let line = format!("\n"); + edit(parts, TYPES, "", &line) +} + +fn relationship(parts: Parts, rels: &str, line: &str) -> Parts { + edit( + parts, + rels, + "", + &format!("{line}\n"), + ) +} + +fn zip_with(parts: &Parts, reverse: bool, deflate: bool, dos_date: u16) -> Vec { + let mut entries: Vec> = parts + .iter() + .map(|(name, data)| WriteEntry { + name, + data, + deflate, + dos_time: 0x6000, + dos_date, + }) + .collect(); + if reverse { + entries.reverse(); + } + zip::write(&entries) +} + +fn package(parts: &Parts) -> Vec { + zip_with(parts, false, true, 0x5921) +} + +fn read(bytes: &[u8]) -> Result { + docx::read(bytes, &Limits::default(), &Cancel::default()) +} + +fn imported(bytes: &[u8]) -> Imported { + Docx.import(bytes, &Limits::default(), &Cancel::default()) + .expect("import") +} + +fn paragraphs(document: &Document) -> Vec<&Paragraph> { + document + .content + .blocks + .iter() + .filter_map(|block| match block { + Block::Paragraph(paragraph) => Some(paragraph), + Block::Table(_) => None, + }) + .collect() +} + +fn paragraph<'a>(document: &'a Document, text: &str) -> &'a Paragraph { + paragraphs(document) + .into_iter() + .find(|paragraph| paragraph.text().contains(text)) + .unwrap_or_else(|| panic!("no paragraph contains {text:?}")) +} + +fn runs(paragraph: &Paragraph) -> Vec<&docx::Run> { + paragraph + .inlines + .iter() + .filter_map(|inline| match inline { + Inline::Run(run) => Some(run), + _ => None, + }) + .collect() +} + +fn run_with<'a>(paragraph: &'a Paragraph, text: &str) -> &'a docx::Run { + runs(paragraph) + .into_iter() + .find(|run| matches!(&run.content, RunContent::Text(value) if value == text)) + .unwrap_or_else(|| panic!("no run {text:?}")) +} + +fn canary() -> &'static str { + CANARIES + .lines() + .find(|line| !line.is_empty()) + .expect("synthetic canary") +} + +static NEXT_ROOT: AtomicUsize = AtomicUsize::new(0); + +fn scratch() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "bran-docx-{}-{}", + std::process::id(), + NEXT_ROOT.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(root.join("out")).expect("create export root"); + root +} + +#[test] +fn docx_fixtures_stay_within_budget() { + for (name, text) in [ + ("representative", REPRESENTATIVE), + ("unsupported-benign", UNSUPPORTED), + ] { + assert!( + text.len() <= MAX_FIXTURE_FILE_BYTES, + "{name} exceeds fixture budget" + ); + } +} + +#[test] +fn docx_import_preserves_structure() { + let document = read(&package(&representative())).expect("representative imports"); + let kinds: Vec = paragraphs(&document) + .iter() + .map(|paragraph| paragraph.kind.clone()) + .collect(); + let list = |num, level, format: &str| Kind::List { + num, + level, + format: format.to_owned(), + }; + assert_eq!( + kinds, + [ + Kind::Title, + Kind::Heading(1), + Kind::Body, + list(1, 0, "bullet"), + list(1, 1, "bullet"), + list(2, 0, "decimal"), + Kind::Heading(2), + Kind::Body, + Kind::Caption, + Kind::Body, + Kind::Body, + ] + ); + // Block order: the table sits between the review paragraph and its caption. + assert!(matches!(document.content.blocks[8], Block::Table(_))); + + let body = paragraph(&document, "Plain text"); + assert!(run_with(body, "bold").bold); + assert!(!run_with(body, "Plain text with ").bold); + assert_eq!( + run_with(body, "link").link, + Some(Link::External("https://example.invalid/spec".to_owned())) + ); + assert_eq!( + run_with(body, "jump").link, + Some(Link::Internal("overview".to_owned())) + ); + assert!(runs(body) + .iter() + .any(|run| run.content == RunContent::FootnoteRef(1))); + + let heading = paragraph(&document, "Overview"); + assert_eq!( + heading.inlines.first(), + Some(&Inline::BookmarkStart("overview".to_owned())) + ); + assert_eq!( + heading.inlines.last(), + Some(&Inline::BookmarkEnd("overview".to_owned())) + ); + + let Block::Table(rows) = &document.content.blocks[8] else { + panic!("table expected"); + }; + assert_eq!(rows.len(), 4); + assert_eq!(rows[1][0].merge, Merge::Restart); + assert_eq!(rows[2][0].merge, Merge::Continue); + assert_eq!(rows[3][0].span, 2); + assert!( + matches!(rows[1][1].blocks[0], Block::Table(_)), + "nested table kept" + ); + assert_eq!( + document.content.blocks[8].text(), + "Metric\tValue\nGroup\tInner\n\n\t2\nMerged total" + ); + + let image = paragraphs(&document)[9]; + assert!(image.section_break, "sectPr in pPr ends section 1"); + let RunContent::Image(picture) = &runs(image)[0].content else { + panic!("image run expected"); + }; + assert_eq!(picture.asset, sha256_hex(PIXEL_PNG)); + assert_eq!((picture.width, picture.height), (95250, 95250)); + assert_eq!(picture.description, "Synthetic one-pixel image"); + let asset = &document.content.assets[&picture.asset]; + assert_eq!(asset.media_type, "image/png"); + assert_eq!(asset.data, PIXEL_PNG); +} + +#[test] +fn docx_import_keeps_review_state_without_accepting_edits() { + let document = read(&package(&representative())).expect("representative imports"); + let review = paragraph(&document, "Kept text"); + let inserted = run_with(review, "inserted") + .change + .as_ref() + .expect("insert"); + assert_eq!(inserted.kind, ChangeKind::Insert); + assert_eq!(inserted.author, "Synthetic Author"); + assert_eq!(inserted.date, "2026-01-02T03:04:05Z"); + let removed = run_with(review, "removed").change.as_ref().expect("delete"); + assert_eq!(removed.kind, ChangeKind::Delete); + assert_eq!(review.text(), "Kept text inserted", "citable text"); + assert_eq!(review.full_text(), "Kept text insertedremoved", "all text"); + assert_eq!(review.inlines.first(), Some(&Inline::CommentStart(0))); + assert!(review.inlines.contains(&Inline::CommentEnd(0))); + assert!(runs(review) + .iter() + .any(|run| run.content == RunContent::CommentRef(0))); + assert!(runs(review) + .iter() + .any(|run| run.content == RunContent::EndnoteRef(1))); + + let comment = &document.content.comments[0]; + assert_eq!( + ( + comment.id, + comment.author.as_str(), + comment.initials.as_str() + ), + (0, "Synthetic Reviewer", "SR") + ); + assert_eq!(comment.blocks[0].text(), "Synthetic review comment."); + let footnote = &document.content.footnotes; + assert_eq!(footnote.len(), 1, "separator notes are not content"); + assert_eq!(footnote[0].id, 1); + assert_eq!(footnote[0].blocks[0].text(), " Synthetic footnote text."); + assert_eq!( + document.content.endnotes[0].blocks[0].text(), + " Synthetic endnote text." + ); + + for (feature, status) in [ + ("tracked-changes", Status::Exact), + ("comments", Status::Normalized), + ("footnotes", Status::Normalized), + ("endnotes", Status::Normalized), + ("hyperlinks", Status::Exact), + ("bookmarks", Status::Exact), + ("images", Status::Exact), + ("headings", Status::Normalized), + ("lists", Status::Normalized), + ("tables", Status::Normalized), + ("sections", Status::Normalized), + ("captions", Status::Normalized), + ] { + assert_eq!( + document.fidelity.get(feature), + Some(&status), + "fidelity of {feature}" + ); + } + let receipt = imported(&package(&representative())).receipt; + assert!(receipt.contains(&"hyperlink-not-fetched".to_owned())); + assert!(receipt.contains(&"tracked-changes:exact".to_owned())); +} + +#[test] +fn docx_citation_anchors_are_stable_locators() { + let document = read(&package(&representative())).expect("representative imports"); + let citations = document.citations(); + let found = |id: &str| { + citations + .iter() + .find(|citation| citation.id == id) + .unwrap_or_else(|| panic!("no citation {id}")) + }; + let title = found("docx:s1:b1"); + assert_eq!( + (title.role, title.section.as_str(), title.ordinal), + ("title", "s1", 1) + ); + assert_eq!(title.text, "Synthetic quarterly brief"); + assert_eq!(found("docx:s1:b2").role, "heading"); + assert_eq!(found("docx:s1:b4").role, "list"); + assert_eq!(found("docx:s1:b9").role, "table"); + assert_eq!(found("docx:s2:b1").text, "Second section text."); + assert_eq!(found("docx:footnote:1").text, " Synthetic footnote text."); + assert_eq!(found("docx:endnote:1").section, "endnotes"); + assert_eq!(found("docx:comment:0").text, "Synthetic review comment."); + assert!( + citations + .iter() + .all(|citation| citation.id != "docx:s1:b11"), + "the image-only paragraph has no text to cite" + ); + let anchors = imported(&package(&representative())).anchors; + assert_eq!(anchors.len(), citations.len()); + assert!(anchors + .iter() + .zip(&citations) + .all(|(anchor, citation)| anchor.id == citation.id + && anchor.text_digest == sha256_hex(citation.text.as_bytes()))); +} + +#[test] +fn docx_import_ignores_archive_order_timestamps_and_compression() { + let parts = representative(); + let first = imported(&package(&parts)); + for bytes in [ + zip_with(&parts, true, false, 0x4A21), + zip_with(&parts, false, false, 0x3C01), + zip_with(&parts, true, true, 0x0021), + ] { + assert_eq!(imported(&bytes), first); + } + assert_eq!(imported(&package(&parts)), first, "repeatable"); +} + +#[test] +fn docx_reports_unsupported_features_explicitly() { + let document = read(&package(&parse_parts(UNSUPPORTED))).expect("benign input imports"); + for (feature, status) in [ + ("charts", Status::Unsupported), + ("math", Status::Unsupported), + ("shapes", Status::Unsupported), + ("headers-footers", Status::Unsupported), + ("document-properties", Status::Unsupported), + ("symbols", Status::Unsupported), + ("formatting-changes", Status::Unsupported), + ("paragraph-mark-changes", Status::Unsupported), + ("fields", Status::Normalized), + ("content-controls", Status::Normalized), + ] { + assert_eq!( + document.fidelity.get(feature), + Some(&status), + "fidelity of {feature}" + ); + } + let text: Vec = paragraphs(&document).iter().map(|p| p.text()).collect(); + assert_eq!( + text, + [ + "Chart follows: ", + "Equation: ", + "After the box.", + "Controlled text", + "Date field: 2026-01-02", + "Symbol: ", + "Reformatted text", + ], + "field results kept, field codes and skipped content absent" + ); + assert!(run_with(paragraph(&document, "Reformatted"), "Reformatted text").italic); +} + +#[test] +fn docx_refuses_hostile_packages() { + let base = representative(); + let default = Limits::default(); + let small = Limits { + max_part_bytes: 64 * 1024, + max_xml_nodes: 2_000, + ..Limits::default() + }; + let nested_tables = format!( + "{}{}", + "".repeat(40), + "".repeat(40) + ); + let rows: Vec<(&str, Vec, &Limits, Refusal)> = vec![ + ( + "zip path escape", + package(&add(base.clone(), "../outside.xml", "")), + &default, + Refusal::UnsafePartPath, + ), + ( + "image relationship escape", + package(&edit( + base.clone(), + RELS, + "Target=\"media/pixel.png\"", + "Target=\"../../outside.png\"", + )), + &default, + Refusal::UnsafePartPath, + ), + ( + "oversized expansion", + package(&add( + base.clone(), + "word/media/bomb.png", + vec![0u8; 1024 * 1024], + )), + &small, + Refusal::DecompressionLimit, + ), + ( + "malformed document XML", + package(&edit(base.clone(), MAIN, "", "")), + &default, + Refusal::MalformedXml, + ), + ( + "undeclared entity in document XML", + package(&edit(base.clone(), MAIN, "Overview", "&undeclared;")), + &default, + Refusal::MalformedXml, + ), + ( + "DTD entity expansion in document XML", + package(&edit( + base.clone(), + MAIN, + "]>\n\n", + &format!("{}\n", "".repeat(3_000)), + )), + &small, + Refusal::XmlNodeLimit, + ), + ( + "table nesting beyond the block budget", + package(&edit( + base.clone(), + MAIN, + "\n", + &format!("{nested_tables}\n"), + )), + &default, + Refusal::XmlDepthLimit, + ), + ( + "external attached template", + package(&relationship( + base.clone(), + RELS, + &format!(""), + )), + &default, + Refusal::ExternalReference, + ), + ( + "externally linked image", + package(&relationship( + base.clone(), + RELS, + &format!(""), + )), + &default, + Refusal::ExternalReference, + ), + ( + "macro-enabled main part", + package(&edit( + base.clone(), + TYPES, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + "application/vnd.ms-word.document.macroEnabled.main+xml", + )), + &default, + Refusal::ActiveContent, + ), + ( + "VBA project part", + package(&override_type( + add(base.clone(), "word/vbaProject.bin", "synthetic inert bytes"), + "word/vbaProject.bin", + "application/vnd.ms-office.vbaProject", + )), + &default, + Refusal::ActiveContent, + ), + ( + "OLE object part", + package(&override_type( + add( + base.clone(), + "word/embeddings/oleObject1.bin", + "synthetic inert bytes", + ), + "word/embeddings/oleObject1.bin", + "application/vnd.openxmlformats-officedocument.oleObject", + )), + &default, + Refusal::ActiveContent, + ), + ( + "office document that is not WordprocessingML", + package(&edit( + base.clone(), + TYPES, + "wordprocessingml.document.main+xml", + "spreadsheetml.sheet.main+xml", + )), + &default, + Refusal::UnsupportedContainer, + ), + ( + "missing main document part", + package(&base.iter().filter(|(name, _)| name != MAIN).cloned().collect()), + &default, + Refusal::MalformedContainer, + ), + ]; + for (label, bytes, limits, expected) in rows { + let result = std::panic::catch_unwind(|| docx::read(&bytes, limits, &Cancel::default())); + let result = result.unwrap_or_else(|_| panic!("{label}: import panicked")); + assert_eq!(result.err(), Some(expected), "{label}"); + } +} + +#[test] +fn docx_export_round_trip_preserves_model_and_anchors() { + let first = imported(&package(&representative())); + let exported = docx::export(&first).expect("export"); + assert_eq!( + docx::export(&first).expect("export again").bytes, + exported.bytes, + "export is deterministic" + ); + let intake = PackageIntake(Format::Docx) + .import(&exported.bytes, &Limits::default(), &Cancel::default()) + .expect("export is a valid package"); + assert!( + !intake + .receipt + .iter() + .any(|code| code == "missing-relationship-target"), + "{:?}", + intake.receipt + ); + let second = imported(&exported.bytes); + assert_eq!(second.anchors, first.anchors, "citation anchors survive"); + let before = Document::from_canonical(&first.canonical).expect("canonical reads"); + let after = Document::from_canonical(&second.canonical).expect("canonical reads"); + assert_eq!(after.content, before.content, "structural model survives"); + assert_eq!( + docx::export(&second).expect("re-export").bytes, + exported.bytes, + "export of the re-import is byte-identical" + ); +} + +#[test] +fn docx_export_carries_a_fidelity_receipt() { + let first = imported(&package(&representative())); + let exported = docx::export(&first).expect("export"); + let receipt = String::from_utf8(exported.receipt).expect("UTF-8 receipt"); + for expected in [ + "\"schema_version\":\"1\"".to_owned(), + "\"format\":\"docx\"".to_owned(), + format!("\"output_sha256\":\"{}\"", sha256_hex(&exported.bytes)), + format!( + "\"source_canonical_sha256\":\"{}\"", + sha256_hex(&first.canonical) + ), + "\"tracked-changes\":\"exact\"".to_owned(), + "\"styles\":\"normalized\"".to_owned(), + "\"model\":\"exact\"".to_owned(), + ] { + assert!(receipt.contains(&expected), "{expected} in {receipt}"); + } +} + +#[test] +fn docx_canonical_json_reads_back_and_rejects_tampering() { + let document = read(&package(&representative())).expect("representative imports"); + let canonical = document.canonical(); + assert_eq!( + Document::from_canonical(&canonical).expect("reads back"), + document + ); + let text = String::from_utf8(canonical.clone()).expect("UTF-8"); + for bad in [ + canonical[..canonical.len() - 1].to_vec(), + text.replacen(&sha256_hex(PIXEL_PNG), &sha256_hex(b"other"), 1) + .into_bytes(), + b"{\"schema_version\":\"1\"}".to_vec(), + format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes(), + ] { + assert_eq!( + Document::from_canonical(&bad).err(), + Some(Refusal::MalformedContainer) + ); + } +} + +fn split_marker(marker: &str) -> Parts { + let (head, tail) = marker.split_at(marker.len() / 2); + edit( + representative(), + MAIN, + "Second section text.", + &format!("{head}{tail}"), + ) +} + +#[test] +fn docx_dlp_and_boundary_refuse_before_any_file_is_written() { + for (marker, code, refusal) in [ + (canary(), "dlp-findings", Refusal::DlpFindings), + ( + "important_boundary", + "public-boundary-violation", + Refusal::PublicBoundary, + ), + ] { + let bytes = package(&split_marker(marker)); + let package_receipt = PackageIntake(Format::Docx) + .import(&bytes, &Limits::default(), &Cancel::default()) + .expect("intake") + .receipt; + assert!( + !package_receipt.iter().any(|c| c == code), + "the split marker is invisible to the package byte scan" + ); + let first = imported(&bytes); + assert!( + first.receipt.iter().any(|c| c == code), + "adapter finds {code}" + ); + let mut forged = first.clone(); + forged.receipt.retain(|c| c != code); + for input in [&first, &forged] { + let root = scratch(); + assert_eq!( + docx::export_file(&root, "out/report.docx", input).err(), + Some(refusal) + ); + assert_eq!( + std::fs::read_dir(root.join("out")).unwrap().count(), + 0, + "no file or temporary file may be written" + ); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!(docx::export(input).err(), Some(refusal)); + } + } +} + +#[test] +fn docx_export_file_needs_explicit_docx_destination_and_never_overwrites() { + let first = imported(&package(&representative())); + let root = scratch(); + let (path, receipt) = docx::export_file(&root, "out/brief.docx", &first).expect("export"); + assert_eq!(path, root.canonicalize().unwrap().join("out/brief.docx")); + let written = std::fs::read(&path).unwrap(); + assert_eq!(written, docx::export(&first).unwrap().bytes); + assert!(!receipt.is_empty()); + assert_eq!( + docx::export_file(&root, "out/brief.docx", &first).err(), + Some(Refusal::ExportExists) + ); + assert_eq!(std::fs::read(&path).unwrap(), written, "unchanged"); + assert_eq!( + docx::export_file(&root, "out/brief.pdf", &first).err(), + Some(Refusal::ExportFormatMismatch) + ); + std::fs::remove_dir_all(root).unwrap(); +} + +// ---- Opt-in independent-reader check (never part of the normal gate). ---- + +/// Runs one reader; `None` means the reader is not installed. +fn reader(program: &str, args: &[&str]) -> Option> { + let output = std::process::Command::new(program) + .args(args) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + Some(if output.status.success() { + Ok(text) + } else { + Err(format!( + "{program} exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )) + }) +} + +/// Set BRAN_READER_DIR to a directory the readers may use (a LibreOffice snap +/// needs a non-hidden directory under the home directory) and BRAN_READER_PYTHON +/// to a Python with python-docx. A missing reader is reported as unavailable; +/// fewer than two available readers fails rather than passing. +#[test] +#[ignore = "opt-in: needs LibreOffice, poppler, and python-docx installed"] +fn docx_export_opens_in_independent_readers() { + let directory = std::env::var_os("BRAN_READER_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let python = std::env::var("BRAN_READER_PYTHON").unwrap_or_else(|_| "python3".to_owned()); + let document = directory.join("bran-docx-reader.docx"); + let pdf = directory.join("bran-docx-reader.pdf"); + let _ = std::fs::remove_file(&document); + let _ = std::fs::remove_file(&pdf); + let first = imported(&package(&representative())); + std::fs::write(&document, docx::export(&first).expect("export").bytes).unwrap(); + let path = |path: &Path| path.to_str().expect("UTF-8 path").to_owned(); + let expected = [ + "Synthetic quarterly brief", + "Overview", + "Merged total", + "Second section text.", + ]; + let mut passed = Vec::new(); + let mut unavailable = Vec::new(); + + let script = "import sys, docx\nd = docx.Document(sys.argv[1])\n\ +print('\\n'.join(p.text for p in d.paragraphs))\n\ +print('\\n'.join(c.text for t in d.tables for r in t.rows for c in r.cells))\n\ +print('inline_shapes', len(d.inline_shapes))"; + match reader(&python, &["-c", script, &path(&document)]) { + None => unavailable.push("python-docx"), + Some(Err(error)) if error.contains("No module named") => unavailable.push("python-docx"), + Some(Err(error)) => panic!("python-docx failed: {error}"), + Some(Ok(text)) => { + for needle in expected.iter().chain(&["inline_shapes 1"]) { + assert!(text.contains(needle), "python-docx misses {needle}: {text}"); + } + passed.push("python-docx"); + } + } + let convert = [ + "--headless", + "--convert-to", + "pdf", + &path(&document), + "--outdir", + &path(&directory), + ]; + match reader("libreoffice", &convert) { + None => unavailable.push("libreoffice"), + Some(Err(error)) => panic!("LibreOffice failed: {error}"), + Some(Ok(_)) if !pdf.exists() => panic!( + "LibreOffice wrote no PDF; a snap install needs BRAN_READER_DIR under the home directory" + ), + Some(Ok(_)) => match reader("pdftotext", &[&path(&pdf), "-"]) { + None => unavailable.push("libreoffice (pdftotext missing)"), + Some(Err(error)) => panic!("LibreOffice output unreadable: {error}"), + Some(Ok(text)) => { + for needle in expected.iter().chain(&[ + "Synthetic footnote text.", + "Synthetic endnote text.", + ]) { + assert!(text.contains(needle), "LibreOffice misses {needle}: {text}"); + } + passed.push("libreoffice"); + } + }, + } + let _ = std::fs::remove_file(&document); + let _ = std::fs::remove_file(&pdf); + println!("readers passed: {passed:?}; unavailable: {unavailable:?}"); + assert!( + passed.len() >= 2, + "unavailable: fewer than two independent readers ran ({unavailable:?})" + ); +} diff --git a/fixtures/enterprise-documents/docx/representative.parts b/fixtures/enterprise-documents/docx/representative.parts new file mode 100644 index 0000000..ec8b39f --- /dev/null +++ b/fixtures/enterprise-documents/docx/representative.parts @@ -0,0 +1,79 @@ +--- [Content_Types].xml + + + + + + + + + + + +--- _rels/.rels + + + +--- word/_rels/document.xml.rels + + + + + + + + + +--- word/document.xml + + +Synthetic quarterly brief +Overview +Plain text with bold and a link plus a jump. +First bullet +Nested bullet +Numbered step +Review state +Kept text insertedremoved + +MetricValue +GroupInner +2 +Merged total + +Table 1: Synthetic metrics + +Second section text. + + + +--- word/styles.xml + + + + + + + + +--- word/numbering.xml + + + + + + +--- word/footnotes.xml + + + + Synthetic footnote text. + +--- word/endnotes.xml + + Synthetic endnote text. + +--- word/comments.xml + +Synthetic review comment. + diff --git a/fixtures/enterprise-documents/docx/unsupported-benign.parts b/fixtures/enterprise-documents/docx/unsupported-benign.parts new file mode 100644 index 0000000..58f2cd2 --- /dev/null +++ b/fixtures/enterprise-documents/docx/unsupported-benign.parts @@ -0,0 +1,38 @@ +--- [Content_Types].xml + + + + + + + + +--- _rels/.rels + + + + +--- docProps/core.xml +Synthetic properties +--- word/_rels/document.xml.rels + + + + +--- word/header1.xml +Synthetic header +--- word/charts/chart1.xml + +--- word/document.xml + + +Chart follows: +Equation: x=1 +Boxed textBoxed textAfter the box. +Controlled text +Date field: DATE \@ "yyyy-MM-dd" 2026-01-02 +Symbol: +Reformatted text + + + diff --git a/tools/ci/test-budget.json b/tools/ci/test-budget.json index 32620b5..7f22c46 100644 --- a/tools/ci/test-budget.json +++ b/tools/ci/test-budget.json @@ -263,6 +263,20 @@ "source": "crates/bran-core/src/agent/coordinator.rs", "support_fixtures": [] }, + { + "stable_id": "P3-DOCX-ADAPTER", + "phase": 3, + "slice": "3.4", + "runner": "rust", + "name": "docx_export_round_trip_preserves_model_and_anchors", + "category": "integration", + "classification": "new", + "source": "crates/bran-document/tests/docx.rs", + "support_fixtures": [ + "fixtures/enterprise-documents/docx/representative.parts", + "fixtures/enterprise-documents/docx/unsupported-benign.parts" + ] + }, { "stable_id": "P3-ENTERPRISE-CONFORMANCE", "phase": 3, From c8aed2a4e308b7fe2ce3683d0b096a1c2d5cda42 Mon Sep 17 00:00:00 2001 From: Claude Code <1wgrumph+claude-code@gmail.com> Date: Wed, 30 Sep 2026 00:47:55 -0500 Subject: [PATCH 2/4] docs(enterprise): document the DOCX adapter and its compatibility row Describe the DOCX content model, citation anchors, fidelity vocabulary, export receipt, and the opt-in two-reader check with its recorded LibreOffice and python-docx results. Fill the DOCX column of the shared compatibility table and mark the DOCX adapter rows executable. Closes #21 --- docs/enterprise-document-conformance.md | 43 +++--- docs/enterprise-document-docx-adapter.md | 167 +++++++++++++++++++++++ 2 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 docs/enterprise-document-docx-adapter.md diff --git a/docs/enterprise-document-conformance.md b/docs/enterprise-document-conformance.md index a1c8849..e926589 100644 --- a/docs/enterprise-document-conformance.md +++ b/docs/enterprise-document-conformance.md @@ -22,6 +22,8 @@ invent a safety or fidelity policy. Parser selection is recorded in [`enterprise-document-dependency-review.md`](enterprise-document-dependency-review.md). The envelope the adapters emit is [`enterprise-document-evidence-envelope.md`](enterprise-document-evidence-envelope.md). +The DOCX adapter is described in +[`enterprise-document-docx-adapter.md`](enterprise-document-docx-adapter.md). ## Layout @@ -47,8 +49,9 @@ optionally `export`) and adds itself to `conformance::registered()`. From then on the corpus runs every package row of its format through the adapter as well as through the shared intake. The adapter must give the same outcome the row expects. An adapter that registers for a format also fails the corpus -test until it replaces that format's unavailable adapter rows with executable -ones, so a registered format cannot keep rows in the unavailable state. +test until each of that format's adapter rows has an executable arm in +`adapter_row` in `crates/bran-document/tests/conformance.rs`, so a registered +format cannot keep rows in the unavailable state. For each row, `conformance::check` verifies: @@ -103,11 +106,16 @@ private enterprise data. Adapter rows need a content model, so they stay unavailable until the owning adapter registers. The corpus test prints each one as `unavailable` with its -issue; it never counts them as passing. +issue; it never counts them as passing. The DOCX rows are executable: +`docx-ordinary-projection` checks the representative fixture and its recorded +canonical digest, `docx-unsupported-benign-fidelity` requires the +`feature:unsupported` and `feature:normalized` receipt codes, and +`docx-round-trip-anchors` requires an export whose re-import keeps every +anchor. | Row | Needs | |---|---| -| `docx-ordinary-projection`, `docx-unsupported-benign-fidelity`, `docx-round-trip-anchors` | #21 | +| `docx-ordinary-projection`, `docx-unsupported-benign-fidelity`, `docx-round-trip-anchors` | #21 (executable) | | `xlsx-ordinary-projection`, `xlsx-unsupported-benign-fidelity`, `xlsx-round-trip-anchors` | #26 | | `pptx-ordinary-projection`, `pptx-unsupported-benign-fidelity`, `pptx-round-trip-anchors` | #22 | | `pdf-ordinary-projection`, `pdf-malformed-object-graph`, `pdf-recursive-structure`, `pdf-active-action`, `pdf-embedded-file`, `pdf-encrypted`, `pdf-signed`, `pdf-dlp-canary`, `pdf-oversized-stream`, `pdf-round-trip-anchors` | #23 | @@ -169,10 +177,10 @@ cancelled result is never admitted as evidence. | Property | Evidence | Status | |---|---|---| -| Same logical input, same canonical bytes and digest | check step 2; `ordinary` recorded digests; `canonical_json_matches_envelope_rule` | done for packages; adapter envelopes when adapters register | -| Archive order, timestamps, producer differences do not change the result | check step 4 on every admitted row | done for packages | -| Every normalization, approximation, omission, or refusal in a versioned receipt | typed refusals and receipt codes (`RECEIPT_VERSION` 1); check step 3 | done for package outcomes; content fidelity needs #21, #22, #23, #26 | -| Citation anchors survive deterministic round trips | check step 5; `harness_rejects_round_trip_anchor_drift` | harness done; not exercised until an adapter exports | +| Same logical input, same canonical bytes and digest | check step 2; `ordinary` recorded digests; `canonical_json_matches_envelope_rule`; `docx-ordinary-projection` recorded digest | done for packages and DOCX; other adapters when they register | +| Archive order, timestamps, producer differences do not change the result | check step 4 on every admitted row | done for packages and DOCX | +| Every normalization, approximation, omission, or refusal in a versioned receipt | typed refusals and receipt codes (`RECEIPT_VERSION` 1); check step 3; DOCX `feature:status` codes (`docx::MODEL_VERSION` 1) | done for package outcomes and DOCX content; #22, #23, #26 pending | +| Citation anchors survive deterministic round trips | check step 5; `harness_rejects_round_trip_anchor_drift`; `docx-round-trip-anchors` | done for DOCX; other formats when their adapters export | | No network requests, no active content executed | `importers_have_no_network_or_process_access`; active-content and external rows | done | | Containment, DLP, classification, byte budgets on import and export | budget rows; `dlp-canary`; `export_gate_*` tests | done for the shared gates; classification is the envelope's `policy` block | | Typed, bounded failures; no panic; no partial output | check step 1; `parser_limit_property`; refusal rows; export gate tests | done | @@ -206,7 +214,7 @@ not use. | Property iterations | 2,000 | 50,000 | | Largest generated package | 2 MiB | 24 MiB | | Runtime ceiling (debug build, whole tier) | 10 s | 120 s | -| Measured on 2026-09-30 (debug build) | 0.8 s, 120 checks | 23.7 s, 120 checks | +| Measured on 2026-09-30 (debug build, with the DOCX adapter) | 0.9 s, 126 checks | 25.6 s, 126 checks | | Fixture file size | 8 KiB each | same files | The budgets are constants at the top of the test file. A tier that runs @@ -229,24 +237,27 @@ Status values: exact, normalized, approximated, unsupported, refused. | External relationships, remote media, external workbooks | refused | refused | refused | pending #23 | | External hyperlinks | recorded, never fetched | recorded, never fetched | recorded, never fetched | pending #23 | | DTDs and custom entities | refused | refused | refused | n/a | -| Text, structure, tables, lists | pending #21 | pending #26 | pending #22 | pending #23 | -| Comments, tracked changes, notes | pending #21 | pending #26 | pending #22 | pending #23 | +| Text, structure, tables, lists | exact text; normalized headings, lists, tables, sections | pending #26 | pending #22 | pending #23 | +| Comments, tracked changes, notes | exact tracked run changes; normalized comments and notes; unsupported formatting changes | pending #26 | pending #22 | pending #23 | +| Hyperlinks and bookmarks | exact (external targets recorded, never fetched) | pending #26 | pending #22 | pending #23 | +| Fields and content controls | normalized (cached result kept, code dropped, never evaluated) | pending #26 | pending #22 | pending #23 | +| Headers and footers | unsupported | pending #26 | pending #22 | pending #23 | | Formulas and cached values | n/a | pending #26 | n/a | n/a | -| Images and media | pending #21 | pending #26 | pending #22 | pending #23 | -| Charts, SmartArt, animations | pending #21 | pending #26 | pending #22 | n/a | +| Images and media | exact bytes, content-addressed; floating images normalized to inline | pending #26 | pending #22 | pending #23 | +| Charts, SmartArt, animations | unsupported | pending #26 | pending #22 | n/a | | OCR text | n/a | n/a | n/a | pending #23 | -| Export | pending #21 | pending #26 | pending #22 | pending #23 | +| Export | deterministic DOCX with fidelity receipt | pending #26 | pending #22 | pending #23 | ## Acceptance status for #25 | Item | Status | Evidence or owner | |---|---|---| | Fixture matrix, package classes | done | package rows above | -| Fixture matrix, PDF classes and unsupported-benign fidelity | not done | adapter rows; #21, #22, #23, #26 | +| Fixture matrix, PDF classes and unsupported-benign fidelity | DOCX done; others not done | adapter rows; #22, #23, #26 | | Required properties | see table above | | | CLI read-only inspection before export | not done | needs adapter output to inspect; #21, #22, #23, #26 | | Query and packet select document evidence | not done | needs admitted envelopes from adapters, then an ingest path | | Export needs explicit format and destination, never overwrites | done for the shared gate (`export::write_new`) | CLI wiring with the first adapter export | | Adapter tests and shared corpus in the fast/full split with budgets | done | budget table above | | Fuzz/property targets | done as seeded property targets | no coverage-guided fuzzing | -| Compatibility table | done for package features | content rows pending the adapters | +| Compatibility table | done for package features and DOCX | content rows pending #22, #23, #26 | diff --git a/docs/enterprise-document-docx-adapter.md b/docs/enterprise-document-docx-adapter.md new file mode 100644 index 0000000..62a6727 --- /dev/null +++ b/docs/enterprise-document-docx-adapter.md @@ -0,0 +1,167 @@ +--- +type: Concept +title: DOCX adapter +okf_status: active +tags: + - public + - developer +freshness: "2026-09-30" +resource: https://github.com/alphazede/bran +public_boundary: public +--- + +# DOCX adapter + +This is the native DOCX adapter for +[issue #21](https://github.com/alphazede/bran/issues/21). It lives in +`crates/bran-document/src/docx.rs`, is registered with the shared +conformance harness from +[`enterprise-document-conformance.md`](enterprise-document-conformance.md), +and uses only the parser selected in +[`enterprise-document-dependency-review.md`](enterprise-document-dependency-review.md) +(`quick-xml` through the shared `xml` reader). It adds no dependency and does +not need Microsoft Word, LibreOffice, or a network service. + +## Boundary + +- Input is an untrusted OPC package. `opc::open` owns package safety: path + escape, duplicate parts, bombs, budgets, macro and OLE content types, + external relationships other than hyperlinks, DTDs, and entities. The + adapter adds its own refusals only for the parts it reads. +- Nothing is fetched or executed. Fields keep their cached result and their + code is dropped. Hyperlink targets are recorded, never dereferenced. +- Content the model does not carry is skipped and recorded in the fidelity + map. It is never silently kept or silently dropped. + +| Input | Refusal | +|---|---| +| Main part is not WordprocessingML (for example a workbook) | `unsupported-container` | +| No `officeDocument` relationship, missing main part, or no `body` | `malformed-container` | +| Malformed XML or an undeclared entity in any part the adapter reads | `malformed-xml` | +| A DTD in any part the adapter reads | `xml-dtd-refused` | +| XML nodes or depth over the caller's `Limits` | `xml-node-limit`, `xml-depth-limit` | +| Tables or block content controls nested more than 32 deep | `xml-depth-limit` | + +## Content model + +`docx::read` returns a `Document`: the `Content` model, a `fidelity` map, and +the package `diagnostics`. The model holds body blocks, footnotes, endnotes, +comments, and image assets keyed by SHA-256. + +- Blocks are paragraphs and tables, in body order. A paragraph whose + properties hold a `sectPr` ends a section (`section_break`). +- A paragraph kind is `body`, `title`, `caption`, `heading` (level 1 to 9, + from the style name or outline level through `basedOn`), or `list` + (numbering id, level 0 to 8, and number format, from direct or style + numbering). +- Table cells keep their column span, vertical merge state, and blocks, + including nested tables. +- Inlines are runs, comment range markers, and bookmarks. A run holds text, + an image, or a footnote, endnote, or comment reference, plus bold, italic, + underline, an optional hyperlink (external target or internal bookmark), + and an optional tracked change (insert, delete, move from, move to, with id, + author, and date). + +Tracked changes stay as tracked changes. Import accepts and rejects nothing. + +The canonical bytes (`Document::canonical`) are the envelope's canonical JSON +of the model, the citation anchors, the fidelity map, and the diagnostics. +Image bytes are carried base64-encoded, so an export needs nothing but these +bytes. `Document::from_canonical` refuses input that does not re-serialize to +the same bytes or whose assets do not match their digests. + +## Citation anchors + +| Unit | Id | Locator (`section`, `ordinal`) | Role | +|---|---|---|---| +| Body block | `docx:s{section}:b{ordinal}` | `s1`, `s2`, ...; 1-based within the section | `title`, `heading`, `list`, `paragraph`, or `table` | +| Footnote, endnote | `docx:footnote:{id}`, `docx:endnote:{id}` | `footnotes` or `endnotes`; note order | `paragraph` | +| Comment | `docx:comment:{id}` | `comments`; comment order | `paragraph` | + +Anchor text is the citable view: inserted and moved-in text is included, +deleted and moved-away text is not. Table text joins cells with tabs and rows +with newlines. Blocks with no text have no anchor but keep their ordinal. The +roles and locators are the envelope's `flow` family. The adapter returns each +anchor's id and SHA-256 text digest to the harness. + +## Fidelity receipt + +The fidelity map records every observed feature with the worst status seen. +The adapter's receipt codes are the package diagnostics plus one +`feature:status` code per entry. `MODEL_VERSION` (`1`) versions the model, +this vocabulary, and the export receipt. + +| Feature | Status | Meaning | +|---|---|---| +| `tracked-changes` | exact | run insertions, deletions, and moves kept with id, author, date; `approximated` when changes nest | +| `hyperlinks`, `bookmarks`, `images` | exact | targets, names, image bytes and extents kept; `normalized` when a target, bookmark end, or extent was missing or duplicated | +| `headings`, `lists`, `captions`, `styles` | normalized | kind, level, numbering id, and format kept; style formatting, bullet glyphs, and start values dropped | +| `tables`, `sections` | normalized | cells, spans, merges, and section breaks kept; widths, borders, and page setup dropped | +| `footnotes`, `endnotes`, `comments` | normalized | ids, text, author, date, initials kept; note and comment formatting dropped | +| `fields`, `content-controls` | normalized | cached field result kept, field code dropped and not evaluated; content controls unwrapped | +| `run-formatting`, `paragraph-formatting`, `breaks`, `text` | normalized | only bold, italic, underline kept; page and column breaks become line breaks; XML-invalid characters dropped | +| `floating-images`, `svg-images`, `references` | normalized | anchored images inlined; SVG kept as its raster fallback; references to missing notes or comments dropped | +| `charts`, `smartart`, `shapes`, `vml`, `math`, `symbols`, `embedded-objects`, `embedded-documents` | unsupported | skipped | +| `headers-footers`, `document-properties`, `comment-threads`, `digital-signatures` | unsupported | parts not read | +| `formatting-changes`, `paragraph-mark-changes`, `table-row-changes`, `table-cell-changes` | unsupported | tracked formatting and structure changes not kept | +| `unrecognized-content`, `unrecognized-parts` | unsupported | anything else, skipped | + +The adapter re-runs the shared DLP and public-boundary check on the text it +extracts, joined per paragraph. A marker split across runs, which the package +byte scan cannot see, adds `dlp-findings` or `public-boundary-violation`. + +## Export + +`docx::export` writes the model as a DOCX and returns the bytes with a +fidelity receipt. `docx::export_file` writes through the shared gate +(`export::write_new`): an explicit contained `.docx` destination, never +overwritten. Both refuse with `dlp-findings` or `public-boundary-violation` +before any bytes are built when the import receipt, the canonical +diagnostics, or any emitted string carries a finding, so no file is written. + +The export is deterministic: parts are sorted, every entry has the same +timestamp, and styles and numbering are regenerated from the model. Importing +an export gives the same model, the same anchors, and, exported again, the +same bytes. + +The receipt is canonical JSON with `schema_version`, `format`, +`source_canonical_sha256`, `output_sha256`, `output_byte_length`, +`import_fidelity` (the fidelity map), `export_fidelity` (`model` exact; +`styles`, `numbering`, `sections`, `tables`, and `run-formatting` +normalized), and the exported anchor ids. + +## Independent readers + +`docx_export_opens_in_independent_readers` is an ignored test. It exports the +representative fixture and opens it with python-docx and with LibreOffice +(converted to PDF, read back with `pdftotext`). A missing reader is reported +as unavailable, and fewer than two readers fails the test. Set +`BRAN_READER_DIR` to a directory the readers may use and +`BRAN_READER_PYTHON` to a Python with python-docx, then run +`cargo test -p bran-document --test docx -- --ignored`. The normal gate never +runs it, and nothing shipped depends on these readers. + +Recorded on 2026-09-30: python-docx 1.2.0 read 11 paragraphs with their +styles, the table, one inline image, and two sections. LibreOffice 26.2.5.2 +converted it to a two-page PDF with the lists, table, tracked changes, +footnote, and endnote. Microsoft Word was not available and was not tested. + +## Tests + +- `crates/bran-document/tests/docx.rs`: structure, review state, anchors, + re-encoding invariance, unsupported features, hostile packages, round trip, + export receipt, canonical tampering, DLP before write, and no overwrite. +- `crates/bran-document/tests/conformance.rs`: every package row through the + adapter, plus the executable `docx-ordinary-projection` (recorded digest), + `docx-unsupported-benign-fidelity`, and `docx-round-trip-anchors` rows. +- Fixtures: `fixtures/enterprise-documents/docx/representative.parts` and + `unsupported-benign.parts`, synthetic, with the one-pixel PNG inline in the + tests. + +## Not done here + +- Microsoft Word itself was not run. +- Headers, footers, charts, SmartArt, shapes, text boxes, and math are + reported unsupported, not imported. +- Emitting the V1 evidence envelope and the read-only CLI inspection are + shared follow-up work from #25. From 523b7b969c6324f2d96cbe63943c090b69068728 Mon Sep 17 00:00:00 2001 From: Claude Code <1wgrumph+claude-code@gmail.com> Date: Wed, 30 Sep 2026 00:56:37 -0500 Subject: [PATCH 3/4] fix(document): read DOCX attributes by their written prefix The shared XML reader now keeps attribute prefixes, so the adapter reads w:val, r:id, and r:embed instead of stripped local names. A part that binds the WordprocessingML or relationships namespace to another prefix, or w or r to another namespace, is refused as unsupported-container rather than misread. Record the corpus runtime with the nine new rows. Refs #21 --- crates/bran-document/src/docx.rs | 109 ++++++++++++++++------- crates/bran-document/tests/docx.rs | 38 ++++++++ docs/enterprise-document-conformance.md | 2 +- docs/enterprise-document-docx-adapter.md | 4 + 4 files changed, 120 insertions(+), 33 deletions(-) diff --git a/crates/bran-document/src/docx.rs b/crates/bran-document/src/docx.rs index 270beda..a8e5fd5 100644 --- a/crates/bran-document/src/docx.rs +++ b/crates/bran-document/src/docx.rs @@ -489,6 +489,48 @@ pub fn read(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result Result<(), Refusal> { + for event in events { + let Event::Open { attributes, .. } = event else { + continue; + }; + for (key, uri) in attributes { + let Some(prefix) = key.strip_prefix("xmlns:") else { + continue; + }; + let misbound = PREFIXES.iter().any(|(known, namespace)| { + (namespace == uri && *known != prefix) || (*known == prefix && namespace != uri) + }) && !PREFIXES.contains(&(prefix, uri.as_str())); + if misbound { + return Err(Refusal::UnsupportedContainer); + } + } + } + Ok(()) +} + fn part<'p>(package: &'p Package, name: &str) -> Option<&'p Part> { package .parts @@ -676,7 +718,9 @@ impl<'a> Importer<'a> { fn parse(&self, part: &Part) -> Result, Refusal> { self.cancel.check()?; - xml::parse(&part.data, self.limits, self.cancel) + let events = xml::parse(&part.data, self.limits, self.cancel)?; + check_prefixes(&events)?; + Ok(events) } /// Records package and main-part relationships this model does not carry. @@ -727,14 +771,14 @@ impl<'a> Importer<'a> { for (depth, name, attributes) in cursor.elements()? { path.truncate(depth - 1); path.push(name); - let val = attr(attributes, "val"); + let val = attr(attributes, "w:val"); if depth == 1 { if let Some((id, style)) = current.take() { self.styles.entry(id).or_insert(style); } - if name == "style" && attr(attributes, "type") == Some("paragraph") { - if let Some(id) = attr(attributes, "styleId") { - if matches!(attr(attributes, "default"), Some("1" | "true" | "on")) { + if name == "style" && attr(attributes, "w:type") == Some("paragraph") { + if let Some(id) = attr(attributes, "w:styleId") { + if matches!(attr(attributes, "w:default"), Some("1" | "true" | "on")) { self.default_style.get_or_insert_with(|| id.to_owned()); } current = Some((id.to_owned(), Style::default())); @@ -772,20 +816,21 @@ impl<'a> Importer<'a> { path.truncate(depth - 1); path.push(name); match path.as_slice() { - ["abstractNum"] => abstract_id = number(attr(attributes, "abstractNumId")), - ["abstractNum", "lvl"] => level = number(attr(attributes, "ilvl")), + ["abstractNum"] => abstract_id = number(attr(attributes, "w:abstractNumId")), + ["abstractNum", "lvl"] => level = number(attr(attributes, "w:ilvl")), ["abstractNum", "lvl", "numFmt"] => { if let (Some(abstract_id), Some(level), Some(format)) = - (abstract_id, level, attr(attributes, "val")) + (abstract_id, level, attr(attributes, "w:val")) { self.formats .entry((abstract_id, level)) .or_insert_with(|| format.to_owned()); } } - ["num"] => num = number(attr(attributes, "numId")), + ["num"] => num = number(attr(attributes, "w:numId")), ["num", "abstractNumId"] => { - if let (Some(num), Some(abstract_id)) = (num, number(attr(attributes, "val"))) { + if let (Some(num), Some(abstract_id)) = (num, number(attr(attributes, "w:val"))) + { self.numbers.entry(num).or_insert(abstract_id); } } @@ -839,12 +884,12 @@ impl<'a> Importer<'a> { } }; // Separator notes are layout, not content. - if attr(attributes, "type").is_some_and(|kind| kind != "normal") { + if attr(attributes, "w:type").is_some_and(|kind| kind != "normal") { cursor.skip()?; continue; } self.note(feature, Status::Normalized); - match number::(attr(attributes, "id")) { + match number::(attr(attributes, "w:id")) { Some(id) if ids.insert(id) => { let blocks = self.story(&mut cursor, 0, feature)?; notes.push(Note { id, blocks }); @@ -873,11 +918,11 @@ impl<'a> Importer<'a> { } }; self.note("comments", Status::Normalized); - match number::(attr(attributes, "id")) { + match number::(attr(attributes, "w:id")) { Some(id) if ids.insert(id) => { - let author = self.clean(attr(attributes, "author").unwrap_or_default()); - let date = self.clean(attr(attributes, "date").unwrap_or_default()); - let initials = self.clean(attr(attributes, "initials").unwrap_or_default()); + let author = self.clean(attr(attributes, "w:author").unwrap_or_default()); + let date = self.clean(attr(attributes, "w:date").unwrap_or_default()); + let initials = self.clean(attr(attributes, "w:initials").unwrap_or_default()); let blocks = self.story(&mut cursor, 0, "comments")?; comments.push(Comment { id, @@ -1030,12 +1075,12 @@ impl<'a> Importer<'a> { for (level, name, attributes) in cursor.elements()? { match (level, name) { (1, "gridSpan") => { - span = number(attr(attributes, "val")) + span = number(attr(attributes, "w:val")) .filter(|span| (1..=63).contains(span)) .unwrap_or(1); } (1, "vMerge") => { - merge = if attr(attributes, "val") == Some("restart") { + merge = if attr(attributes, "w:val") == Some("restart") { Merge::Restart } else { Merge::Continue @@ -1096,7 +1141,7 @@ impl<'a> Importer<'a> { "sdtContent" | "smartTag" | "customXml" | "dir" | "bdo" | "AlternateContent" | "Choice" => frames.push(Frame::Plain), "bookmarkStart" => { - match (attr(attributes, "id"), attr(attributes, "name")) { + match (attr(attributes, "w:id"), attr(attributes, "w:name")) { (Some(id), Some(name)) if self.bookmark_names.insert(name.to_owned()) => { let name = self.clean(name); self.note("bookmarks", Status::Exact); @@ -1108,14 +1153,14 @@ impl<'a> Importer<'a> { cursor.skip()?; } "bookmarkEnd" => { - match attr(attributes, "id").and_then(|id| self.bookmarks.remove(id)) { + match attr(attributes, "w:id").and_then(|id| self.bookmarks.remove(id)) { Some(name) => inlines.push(Inline::BookmarkEnd(name)), None => self.note("bookmarks", Status::Normalized), } cursor.skip()?; } "commentRangeStart" | "commentRangeEnd" => { - if let Some(id) = number(attr(attributes, "id")) { + if let Some(id) = number(attr(attributes, "w:id")) { inlines.push(if name == "commentRangeStart" { Inline::CommentStart(id) } else { @@ -1142,7 +1187,7 @@ impl<'a> Importer<'a> { let mut props = ParagraphProps::default(); let mut parent = ""; for (depth, name, attributes) in cursor.elements()? { - let val = attr(attributes, "val"); + let val = attr(attributes, "w:val"); if depth == 1 { parent = name; } @@ -1229,8 +1274,8 @@ impl<'a> Importer<'a> { } fn link(&mut self, attributes: &[(String, String)]) -> Option { - let anchor = attr(attributes, "anchor"); - let link = match attr(attributes, "id") { + let anchor = attr(attributes, "w:anchor"); + let link = match attr(attributes, "r:id") { Some(id) => match self.relationship(id) { Some(rel) if rel.external && kind_of(rel) == "hyperlink" => { let target = match anchor { @@ -1260,9 +1305,9 @@ impl<'a> Importer<'a> { .into_iter() .find(|kind| kind.element() == name) .unwrap_or(ChangeKind::Insert), - id: number(attr(attributes, "id")).unwrap_or(0), - author: self.clean(attr(attributes, "author").unwrap_or_default()), - date: self.clean(attr(attributes, "date").unwrap_or_default()), + id: number(attr(attributes, "w:id")).unwrap_or(0), + author: self.clean(attr(attributes, "w:author").unwrap_or_default()), + date: self.clean(attr(attributes, "w:date").unwrap_or_default()), } } @@ -1313,7 +1358,7 @@ impl<'a> Importer<'a> { match name { "rPr" => { for (depth, element, attributes) in cursor.elements()? { - let val = attr(attributes, "val"); + let val = attr(attributes, "w:val"); match (depth, element) { (1, "b") => template.bold = on(val), (1, "i") => template.italic = on(val), @@ -1334,7 +1379,7 @@ impl<'a> Importer<'a> { } "tab" | "ptab" | "br" | "cr" | "noBreakHyphen" | "softHyphen" => { if name == "ptab" - || attr(attributes, "type").is_some_and(|t| t != "textWrapping") + || attr(attributes, "w:type").is_some_and(|t| t != "textWrapping") { self.note("breaks", Status::Normalized); } @@ -1350,7 +1395,7 @@ impl<'a> Importer<'a> { } "fldChar" => { self.note("fields", Status::Normalized); - match attr(attributes, "fldCharType") { + match attr(attributes, "w:fldCharType") { Some("begin") => self.fields.push(true), Some("separate") => { if let Some(code) = self.fields.last_mut() { @@ -1378,7 +1423,7 @@ impl<'a> Importer<'a> { } } "footnoteReference" | "endnoteReference" | "commentReference" => { - if let (Some(id), false) = (number(attr(attributes, "id")), hidden) { + if let (Some(id), false) = (number(attr(attributes, "w:id")), hidden) { if !text.is_empty() { emit( RunContent::Text(std::mem::take(&mut text)), @@ -1432,7 +1477,7 @@ impl<'a> Importer<'a> { name = attr(attributes, "name").unwrap_or_default(); description = attr(attributes, "descr").unwrap_or_default(); } - (_, "blip") => blip = blip.or(attr(attributes, "embed")), + (_, "blip") => blip = blip.or(attr(attributes, "r:embed")), (_, "svgBlip") => self.note("svg-images", Status::Normalized), (_, "chart") => other = Some("charts"), (_, "relIds") => other = Some("smartart"), diff --git a/crates/bran-document/tests/docx.rs b/crates/bran-document/tests/docx.rs index 3a1ae9a..10bf5d9 100644 --- a/crates/bran-document/tests/docx.rs +++ b/crates/bran-document/tests/docx.rs @@ -372,6 +372,22 @@ fn docx_citation_anchors_are_stable_locators() { && anchor.text_digest == sha256_hex(citation.text.as_bytes()))); } +#[test] +fn docx_reads_attributes_only_under_their_prefix() { + let parts = edit( + representative(), + MAIN, + "", + "", + ); + let document = read(&package(&parts)).expect("imports"); + assert_eq!( + paragraph(&document, "Synthetic quarterly brief").kind, + Kind::Body, + "an attribute in a foreign namespace is not w:val" + ); +} + #[test] fn docx_import_ignores_archive_order_timestamps_and_compression() { let parts = representative(); @@ -566,6 +582,28 @@ fn docx_refuses_hostile_packages() { &default, Refusal::ActiveContent, ), + ( + "WordprocessingML namespace bound to another prefix", + package(&edit( + base.clone(), + MAIN, + " Date: Wed, 30 Sep 2026 07:12:30 -0500 Subject: [PATCH 4/4] fix(docx): repair import safety and round-trip fidelity Refs #21 --- crates/bran-document/src/docx.rs | 309 ++++++++++++------ crates/bran-document/src/lib.rs | 1 + crates/bran-document/src/zip.rs | 18 +- crates/bran-document/tests/docx.rs | 234 ++++++++++++- docs/enterprise-document-docx-adapter.md | 29 +- .../docx/review/amplify-small.fixture | 20 ++ .../docx/review/amplify.fixture | 32 ++ .../docx/review/attribute-newline.fixture | 15 + .../docx/review/choice.fixture | 17 + .../docx/review/foreign.fixture | 14 + .../docx/review/high-compression.fixture | 19 ++ .../docx/review/ordinary.fixture | 14 + .../docx/review/prefix-control.fixture | 14 + .../docx/review/prefix.fixture | 14 + .../docx/unsupported-benign.parts | 2 +- tools/ci/test-budget.json | 9 + 16 files changed, 639 insertions(+), 122 deletions(-) create mode 100644 fixtures/enterprise-documents/docx/review/amplify-small.fixture create mode 100644 fixtures/enterprise-documents/docx/review/amplify.fixture create mode 100644 fixtures/enterprise-documents/docx/review/attribute-newline.fixture create mode 100644 fixtures/enterprise-documents/docx/review/choice.fixture create mode 100644 fixtures/enterprise-documents/docx/review/foreign.fixture create mode 100644 fixtures/enterprise-documents/docx/review/high-compression.fixture create mode 100644 fixtures/enterprise-documents/docx/review/ordinary.fixture create mode 100644 fixtures/enterprise-documents/docx/review/prefix-control.fixture create mode 100644 fixtures/enterprise-documents/docx/review/prefix.fixture diff --git a/crates/bran-document/src/docx.rs b/crates/bran-document/src/docx.rs index a8e5fd5..705e4fa 100644 --- a/crates/bran-document/src/docx.rs +++ b/crates/bran-document/src/docx.rs @@ -457,6 +457,7 @@ pub fn read(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result Result Result<(), Refusal> { - for event in events { - let Event::Open { attributes, .. } = event else { - continue; - }; - for (key, uri) in attributes { - let Some(prefix) = key.strip_prefix("xmlns:") else { - continue; - }; - let misbound = PREFIXES.iter().any(|(known, namespace)| { - (namespace == uri && *known != prefix) || (*known == prefix && namespace != uri) - }) && !PREFIXES.contains(&(prefix, uri.as_str())); - if misbound { - return Err(Refusal::UnsupportedContainer); +/// Canonical dispatch names from expanded XML names, independent of the written +/// prefix. Only WordprocessingML elements use bare local names; foreign names +/// can never enter that dispatch. Default namespaces do not apply to attributes +/// (Namespaces in XML 1.0, sections 6.1–6.3). +fn namespace_name(uri: Option<&str>, local: &str, attribute: bool) -> String { + let prefix = match uri { + Some( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + | "http://purl.oclc.org/ooxml/wordprocessingml/main", + ) => { + if attribute { + "w" + } else { + return local.to_owned(); } } + Some( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + | "http://purl.oclc.org/ooxml/officeDocument/relationships", + ) => "r", + Some( + "http://schemas.openxmlformats.org/drawingml/2006/main" + | "http://purl.oclc.org/ooxml/drawingml/main", + ) => "a", + Some( + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" + | "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing", + ) => "wp", + Some( + "http://schemas.openxmlformats.org/officeDocument/2006/math" + | "http://purl.oclc.org/ooxml/officeDocument/math", + ) => "m", + Some( + "http://schemas.openxmlformats.org/drawingml/2006/chart" + | "http://purl.oclc.org/ooxml/drawingml/chart", + ) => "c", + Some( + "http://schemas.openxmlformats.org/drawingml/2006/diagram" + | "http://purl.oclc.org/ooxml/drawingml/diagram", + ) => "dgm", + Some("http://schemas.microsoft.com/office/word/2010/wordprocessingShape") => "wps", + Some("http://schemas.microsoft.com/office/word/2010/wordprocessingGroup") => "wpg", + Some("http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas") => "wpc", + Some( + "http://schemas.openxmlformats.org/drawingml/2006/lockedCanvas" + | "http://purl.oclc.org/ooxml/drawingml/lockedCanvas", + ) => "lc", + Some("http://schemas.microsoft.com/office/drawing/2016/SVG/main") => "asvg", + Some("http://schemas.openxmlformats.org/markup-compatibility/2006") => "mc", + None if attribute => return local.to_owned(), + // No need to replicate a possibly huge foreign namespace URI into every + // event: these names are only dispatched to the unsupported handler. + _ => "?", + }; + format!("{prefix}:{local}") +} + +fn namespace_uri(result: quick_xml::name::ResolveResult<'_>) -> Result, Refusal> { + use quick_xml::name::ResolveResult; + match result { + ResolveResult::Bound(uri) => Ok(Some(uri.into_inner())), + ResolveResult::Unbound => Ok(None), + ResolveResult::Unknown(_) => Err(Refusal::MalformedXml), + } +} + +/// The shared reader already validated XML and its budgets. Revisit only the +/// opening tags with the installed parser's scoped namespace resolver; keep the +/// shared event representation and all other adapters' parsing unchanged. +fn resolve_names(bytes: &[u8], events: &mut [Event]) -> Result<(), Refusal> { + use quick_xml::events::Event as Q; + let mut reader = quick_xml::reader::NsReader::from_reader(bytes); + let mut opens = events.iter_mut().filter_map(|event| match event { + Event::Open { name, attributes } => Some((name, attributes)), + _ => None, + }); + loop { + match reader.read_event().map_err(|_| Refusal::MalformedXml)? { + Q::Start(element) | Q::Empty(element) => { + let (name, attributes) = opens.next().ok_or(Refusal::MalformedXml)?; + let (uri, local) = reader.resolver().resolve_element(element.name()); + *name = namespace_name(namespace_uri(uri)?, local.as_ref(), false); + let mut seen = BTreeSet::new(); + for (raw, (key, _)) in element.attributes().zip(attributes.iter_mut()) { + let raw = raw.map_err(|_| Refusal::MalformedXml)?; + if key == "xmlns" || key.starts_with("xmlns:") { + continue; + } + let (uri, local) = reader.resolver().resolve_attribute(raw.key); + let uri = namespace_uri(uri)?; + if !seen.insert((uri, local.into_inner())) { + return Err(Refusal::MalformedXml); + } + *key = namespace_name(uri, local.as_ref(), true); + } + } + Q::Eof => return Ok(()), + _ => {} + } } - Ok(()) } fn part<'p>(package: &'p Package, name: &str) -> Option<&'p Part> { @@ -658,9 +719,41 @@ struct Importer<'a> { /// Complex field stack of the current part; `true` while in the field code. fields: Vec, document: Document, + /// Cumulative run payload and metadata copies, independent of machine layout. + model_bytes: u64, } impl<'a> Importer<'a> { + fn reserve_model(&mut self, bytes: usize) -> Result<(), Refusal> { + self.model_bytes = self + .model_bytes + .checked_add(bytes as u64) + .filter(|bytes| *bytes <= self.limits.max_total_bytes) + .ok_or(Refusal::Oversized)?; + Ok(()) + } + + fn emit( + &mut self, + content: RunContent, + template: &Run, + inlines: &mut Vec, + ) -> Result<(), Refusal> { + let payload = match &content { + RunContent::Text(text) => text.len(), + RunContent::Image(image) => { + image.asset.len() + image.name.len() + image.description.len() + } + _ => 0, + }; + self.reserve_model(payload + metadata_bytes(template))?; + inlines.push(Inline::Run(Run { + content, + ..template.clone() + })); + Ok(()) + } + fn note(&mut self, feature: &str, status: Status) { let entry = self .document @@ -716,10 +809,18 @@ impl<'a> Importer<'a> { .find(|rel| rel.source.eq_ignore_ascii_case(&self.source) && rel.id == id) } - fn parse(&self, part: &Part) -> Result, Refusal> { + fn parse(&self, part: &Part, root: &str) -> Result, Refusal> { self.cancel.check()?; - let events = xml::parse(&part.data, self.limits, self.cancel)?; - check_prefixes(&events)?; + let mut events = xml::parse(&part.data, self.limits, self.cancel)?; + resolve_names(&part.data, &mut events)?; + if !matches!(events.first(), Some(Event::Open { name, .. }) if name == root) + || events.iter().any(|event| { + matches!(event, + Event::Open { name, .. } if name == "mc:AlternateContent") + }) + { + return Err(Refusal::UnsupportedContainer); + } Ok(events) } @@ -761,7 +862,7 @@ impl<'a> Importer<'a> { fn read_styles(&mut self, part: &Part) -> Result<(), Refusal> { self.note("styles", Status::Normalized); - let events = self.parse(part)?; + let events = self.parse(part, "styles")?; let mut cursor = Cursor { events: &events, at: 1, @@ -805,7 +906,7 @@ impl<'a> Importer<'a> { } fn read_numbering(&mut self, part: &Part) -> Result<(), Refusal> { - let events = self.parse(part)?; + let events = self.parse(part, "numbering")?; let mut cursor = Cursor { events: &events, at: 1, @@ -843,7 +944,7 @@ impl<'a> Importer<'a> { fn body(&mut self, part: &Part) -> Result, Refusal> { self.enter(&part.name); - let events = self.parse(part)?; + let events = self.parse(part, "document")?; let mut cursor = Cursor { events: &events, at: 1, @@ -867,7 +968,7 @@ impl<'a> Importer<'a> { "endnotes" }; self.enter(&part.name); - let events = self.parse(part)?; + let events = self.parse(part, &format!("{item}s"))?; let mut cursor = Cursor { events: &events, at: 1, @@ -901,7 +1002,7 @@ impl<'a> Importer<'a> { fn comments(&mut self, part: &Part) -> Result, Refusal> { self.enter(&part.name); - let events = self.parse(part)?; + let events = self.parse(part, "comments")?; let mut cursor = Cursor { events: &events, at: 1, @@ -985,7 +1086,7 @@ impl<'a> Importer<'a> { self.note("content-controls", Status::Normalized); blocks.extend(self.blocks(cursor, depth + 1, body)?); } - "sdtContent" | "customXml" | "AlternateContent" | "Choice" => { + "sdtContent" | "customXml" => { blocks.extend(self.blocks(cursor, depth + 1, body)?); } "sectPr" => { @@ -1004,10 +1105,11 @@ impl<'a> Importer<'a> { self.note("tables", Status::Normalized); cursor.skip()?; } - "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, + "m:oMath" | "m:oMathPara" => self.unsupported(cursor, "math")?, "altChunk" => self.unsupported(cursor, "embedded-documents")?, - "sdtPr" | "sdtEndPr" | "customXmlPr" | "Fallback" | "proofErr" | "permStart" - | "permEnd" => cursor.skip()?, + "sdtPr" | "sdtEndPr" | "customXmlPr" | "proofErr" | "permStart" | "permEnd" => { + cursor.skip()? + } _ => self.unsupported(cursor, "unrecognized-content")?, } } @@ -1138,8 +1240,9 @@ impl<'a> Importer<'a> { self.note("content-controls", Status::Normalized); frames.push(Frame::Plain); } - "sdtContent" | "smartTag" | "customXml" | "dir" | "bdo" | "AlternateContent" - | "Choice" => frames.push(Frame::Plain), + "sdtContent" | "smartTag" | "customXml" | "dir" | "bdo" => { + frames.push(Frame::Plain) + } "bookmarkStart" => { match (attr(attributes, "w:id"), attr(attributes, "w:name")) { (Some(id), Some(name)) if self.bookmark_names.insert(name.to_owned()) => { @@ -1169,10 +1272,10 @@ impl<'a> Importer<'a> { } cursor.skip()?; } - "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, - "sdtPr" | "sdtEndPr" | "smartTagPr" | "customXmlPr" | "Fallback" | "proofErr" - | "permStart" | "permEnd" | "moveFromRangeStart" | "moveFromRangeEnd" - | "moveToRangeStart" | "moveToRangeEnd" => cursor.skip()?, + "m:oMath" | "m:oMathPara" => self.unsupported(cursor, "math")?, + "sdtPr" | "sdtEndPr" | "smartTagPr" | "customXmlPr" | "proofErr" | "permStart" + | "permEnd" | "moveFromRangeStart" | "moveFromRangeEnd" | "moveToRangeStart" + | "moveToRangeEnd" => cursor.skip()?, _ => self.unsupported(cursor, "unrecognized-content")?, } } @@ -1317,38 +1420,30 @@ impl<'a> Importer<'a> { frames: &[Frame], inlines: &mut Vec, ) -> Result<(), Refusal> { + let link = frames + .iter() + .rev() + .find_map(|frame| match frame { + Frame::Link(link) => Some(link.as_ref()), + _ => None, + }) + .flatten(); + let change = frames.iter().rev().find_map(|frame| match frame { + Frame::Change(change) => Some(change), + _ => None, + }); + self.reserve_model(link_bytes(link) + change_bytes(change))?; let mut template = Run { content: RunContent::Text(String::new()), bold: false, italic: false, underline: false, - link: frames - .iter() - .rev() - .find_map(|frame| match frame { - Frame::Link(link) => Some(link.clone()), - _ => None, - }) - .flatten(), - change: frames.iter().rev().find_map(|frame| match frame { - Frame::Change(change) => Some(change.clone()), - _ => None, - }), + link: link.cloned(), + change: change.cloned(), }; let mut text = String::new(); - let mut open = 0; - let emit = |content: RunContent, template: &Run, inlines: &mut Vec| { - inlines.push(Inline::Run(Run { - content, - ..template.clone() - })); - }; loop { let (name, attributes) = match cursor.next()? { - Event::Close if open > 0 => { - open -= 1; - continue; - } Event::Close => break, Event::Text(_) => continue, Event::Open { name, attributes } => (name.as_str(), attributes.as_slice()), @@ -1413,38 +1508,37 @@ impl<'a> Importer<'a> { let image = self.drawing(cursor)?; if let (Some(image), false) = (image, hidden) { if !text.is_empty() { - emit( + self.emit( RunContent::Text(std::mem::take(&mut text)), &template, inlines, - ); + )?; } - emit(RunContent::Image(image), &template, inlines); + self.emit(RunContent::Image(image), &template, inlines)?; } } "footnoteReference" | "endnoteReference" | "commentReference" => { if let (Some(id), false) = (number(attr(attributes, "w:id")), hidden) { if !text.is_empty() { - emit( + self.emit( RunContent::Text(std::mem::take(&mut text)), &template, inlines, - ); + )?; } let content = match name { "footnoteReference" => RunContent::FootnoteRef(id), "endnoteReference" => RunContent::EndnoteRef(id), _ => RunContent::CommentRef(id), }; - emit(content, &template, inlines); + self.emit(content, &template, inlines)?; } cursor.skip()?; } - "AlternateContent" | "Choice" => open += 1, "pict" => self.unsupported(cursor, "vml")?, "object" => self.unsupported(cursor, "embedded-objects")?, "sym" => self.unsupported(cursor, "symbols")?, - "oMath" | "oMathPara" => self.unsupported(cursor, "math")?, + "m:oMath" | "m:oMathPara" => self.unsupported(cursor, "math")?, "instrText" | "delInstrText" | "footnoteRef" @@ -1452,13 +1546,12 @@ impl<'a> Importer<'a> { | "annotationRef" | "lastRenderedPageBreak" | "separator" - | "continuationSeparator" - | "Fallback" => cursor.skip()?, + | "continuationSeparator" => cursor.skip()?, _ => self.unsupported(cursor, "unrecognized-content")?, } } if !text.is_empty() { - emit(RunContent::Text(text), &template, inlines); + self.emit(RunContent::Text(text), &template, inlines)?; } Ok(()) } @@ -1468,20 +1561,22 @@ impl<'a> Importer<'a> { let (mut width, mut height, mut name, mut description) = (None, None, "", ""); for (depth, element, attributes) in cursor.elements()? { match (depth, element) { - (1, "anchor") => floating = true, - (2, "extent") => { + (1, "wp:anchor") => floating = true, + (2, "wp:extent") => { width = number::(attr(attributes, "cx")); height = number::(attr(attributes, "cy")); } - (2, "docPr") => { + (2, "wp:docPr") => { name = attr(attributes, "name").unwrap_or_default(); description = attr(attributes, "descr").unwrap_or_default(); } - (_, "blip") => blip = blip.or(attr(attributes, "r:embed")), - (_, "svgBlip") => self.note("svg-images", Status::Normalized), - (_, "chart") => other = Some("charts"), - (_, "relIds") => other = Some("smartart"), - (_, "wsp" | "wgp" | "wpc" | "txbx" | "lockedCanvas") => other = Some("shapes"), + (_, "a:blip") => blip = blip.or(attr(attributes, "r:embed")), + (_, "asvg:svgBlip") => self.note("svg-images", Status::Normalized), + (_, "c:chart") => other = Some("charts"), + (_, "dgm:relIds") => other = Some("smartart"), + (_, "wps:wsp" | "wpg:wgp" | "wpc:wpc" | "wps:txbx" | "lc:lockedCanvas") => { + other = Some("shapes") + } _ => {} } } @@ -1575,6 +1670,21 @@ impl<'a> Importer<'a> { } } +fn link_bytes(link: Option<&Link>) -> usize { + match link { + Some(Link::External(target) | Link::Internal(target)) => target.len(), + None => 0, + } +} + +fn change_bytes(change: Option<&Change>) -> usize { + change.map_or(0, |change| change.author.len() + change.date.len()) +} + +fn metadata_bytes(run: &Run) -> usize { + link_bytes(run.link.as_ref()) + change_bytes(run.change.as_ref()) +} + fn named_kind(name: &str) -> Option { match name { "title" => Some(Kind::Title), @@ -2417,6 +2527,9 @@ fn escape(value: &str) -> String { .replace('<', "<") .replace('>', ">") .replace('"', """) + .replace('\t', " ") + .replace('\n', " ") + .replace('\r', " ") } /// Relationships of one part, numbered in first-use order. @@ -2571,7 +2684,7 @@ fn write_docx(content: &Content) -> Vec { dos_date: 0x0021, }) .collect(); - zip::write(&entries) + zip::write_with_ratio(&entries, Limits::default().max_ratio) } /// Adds a notes or comments part, its content type, and its relationships. diff --git a/crates/bran-document/src/lib.rs b/crates/bran-document/src/lib.rs index 1c52f71..3e00c2c 100644 --- a/crates/bran-document/src/lib.rs +++ b/crates/bran-document/src/lib.rs @@ -93,6 +93,7 @@ pub struct Limits { pub max_package_bytes: u64, pub max_parts: usize, pub max_part_bytes: u64, + /// Total inflated package bytes; also bounds DOCX run payload and metadata copies. pub max_total_bytes: u64, /// Largest allowed uncompressed/compressed size ratio for one part. pub max_ratio: u64, diff --git a/crates/bran-document/src/zip.rs b/crates/bran-document/src/zip.rs index 36f2865..d7f0c05 100644 --- a/crates/bran-document/src/zip.rs +++ b/crates/bran-document/src/zip.rs @@ -259,19 +259,29 @@ fn inflate_counted(raw: &[u8], size: u64) -> (Result, Refusal>, u64) { /// Writes entries in the given order with exactly the given names and /// timestamps. Export callers pass sorted names and one fixed timestamp. pub fn write(entries: &[WriteEntry<'_>]) -> Vec { + write_with_ratio(entries, u64::MAX) +} + +/// Stores entries whose compressed representation would exceed intake's ratio. +/// The unrestricted writer remains available for adversarial intake fixtures. +pub(crate) fn write_with_ratio(entries: &[WriteEntry<'_>], max_ratio: u64) -> Vec { use std::io::Write; let mut out = Vec::new(); let mut central = Vec::new(); for entry in entries { - let data = if entry.deflate { + let (data, method) = if entry.deflate { let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); encoder.write_all(entry.data).expect("in-memory write"); - encoder.finish().expect("in-memory write") + let compressed = encoder.finish().expect("in-memory write"); + if entry.data.len() as u64 > max_ratio.saturating_mul(compressed.len().max(1) as u64) { + (entry.data.to_vec(), 0u16) + } else { + (compressed, 8u16) + } } else { - entry.data.to_vec() + (entry.data.to_vec(), 0u16) }; - let method: u16 = if entry.deflate { 8 } else { 0 }; let crc = crc32fast::hash(entry.data); let local = out.len() as u32; let name = entry.name.as_bytes(); diff --git a/crates/bran-document/tests/docx.rs b/crates/bran-document/tests/docx.rs index 10bf5d9..00cea36 100644 --- a/crates/bran-document/tests/docx.rs +++ b/crates/bran-document/tests/docx.rs @@ -1,6 +1,6 @@ //! DOCX adapter (issue #21): import, fidelity receipts, export, and round trips. //! -//! Every package is built in memory from the reviewable `.parts` fixtures in +//! Ordinary packages are built in memory from the reviewable `.parts` fixtures in //! `fixtures/enterprise-documents/docx/`. The only binary is a one-pixel PNG, //! kept inline below so readers can decode the image part. @@ -373,7 +373,7 @@ fn docx_citation_anchors_are_stable_locators() { } #[test] -fn docx_reads_attributes_only_under_their_prefix() { +fn docx_reads_attributes_only_in_their_namespace() { let parts = edit( representative(), MAIN, @@ -582,17 +582,6 @@ fn docx_refuses_hostile_packages() { &default, Refusal::ActiveContent, ), - ( - "WordprocessingML namespace bound to another prefix", - package(&edit( - base.clone(), - MAIN, - " Vec { + use std::io::Read; + let fixture = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/enterprise-documents/docx/review") + .join(format!("{name}.fixture")), + ) + .expect("review fixture"); + assert!(fixture.len() <= MAX_FIXTURE_FILE_BYTES); + let mut lines = fixture.lines(); + let digest = lines.next().unwrap(); + let size: u64 = lines.next().unwrap().parse().unwrap(); + let hex = lines.collect::(); + let compressed: Vec = hex + .as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect(); + let mut bytes = Vec::new(); + flate2::read::DeflateDecoder::new(compressed.as_slice()) + .take(size + 1) + .read_to_end(&mut bytes) + .unwrap(); + assert_eq!(bytes.len() as u64, size); + assert_eq!(sha256_hex(&bytes), digest, "original review input bytes"); + bytes +} + +#[test] +fn docx_review_metadata_expansion_is_bounded() { + // Run the real importer under the reviewer's 384 MiB address-space ceiling. + // A separate process lets an allocation abort fail the test without killing + // the suite. The gates already require Python; no extra dependency is needed. + if std::env::var_os("BRAN_DOCX_MEMORY_CHILD").is_none() { + let output = std::process::Command::new("python3") + .args([ + "-c", + "import os, resource, sys\nresource.setrlimit(resource.RLIMIT_AS, (384*1024*1024,)*2)\nresource.setrlimit(resource.RLIMIT_CORE, (0, 0))\nos.execv(sys.argv[1], sys.argv[1:])", + ]) + .arg(std::env::current_exe().unwrap()) + .args(["--exact", "docx_review_metadata_expansion_is_bounded", "--nocapture"]) + .env("BRAN_DOCX_MEMORY_CHILD", "1") + .output() + .expect("bounded test child"); + assert!( + output.status.success(), + "child {}: {}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + for control in ["ordinary", "amplify-small"] { + assert!(read(&review_input(control)).is_ok(), "{control} control"); + } + assert_eq!( + read(&review_input("amplify")).err(), + Some(Refusal::Oversized) + ); +} + +#[test] +fn docx_review_high_compression_round_trips() { + let first = imported(&review_input("high-compression")); + let exported = docx::export(&first).unwrap(); + let second = Docx + .import(&exported.bytes, &Limits::default(), &Cancel::default()) + .expect("export must pass its own intake limits"); + assert_eq!(second.anchors, first.anchors); + assert_eq!( + Document::from_canonical(&second.canonical).unwrap().content, + Document::from_canonical(&first.canonical).unwrap().content + ); + assert_eq!(docx::export(&second).unwrap().bytes, exported.bytes); +} + +#[test] +fn docx_review_namespace_alias_matches_control() { + let control = read(&review_input("prefix-control")).unwrap(); + assert_eq!(paragraph(&control, "Review text").kind, Kind::Heading(1)); + let alias = read(&review_input("prefix")).expect("valid namespace alias"); + assert_eq!(alias, control); + let redundant = edit( + representative(), + MAIN, + "(); + let xml = String::from_utf8( + base.iter() + .find(|(name, _)| name == MAIN) + .unwrap() + .1 + .clone(), + ) + .unwrap(); + for variant in [ + xml.replace("xmlns:x=", "xmlns=").replace("(); + let foreign_child = edit(base.clone(), MAIN, "", ""); + let document = read(&package(&foreign_child)).unwrap(); + assert!(document.content.blocks.is_empty()); + assert_eq!( + document.fidelity["sections"], + Status::Normalized, + "binding restored after the child" + ); + assert_eq!( + document.fidelity["unrecognized-content"], + Status::Unsupported + ); + // A foreign attribute under the familiar prefix must not set a heading; + // an unprefixed attribute is also outside WordprocessingML. + for attributes in ["xmlns:w=\"urn:foreign\" w:val=\"0\"", "val=\"0\""] { + let parts = edit(base.clone(), MAIN, "w:val=\"0\"", attributes); + assert_eq!( + paragraph(&read(&package(&parts)).unwrap(), "Review text").kind, + Kind::Body + ); + } +} + +#[test] +fn docx_review_compatibility_alternatives_are_explicitly_refused() { + assert_eq!( + read(&review_input("choice")).err(), + Some(Refusal::UnsupportedContainer) + ); +} + +#[test] +fn docx_review_attribute_whitespace_round_trips() { + let first = read(&review_input("attribute-newline")).unwrap(); + assert_eq!( + runs(paragraph(&first, "new"))[0] + .change + .as_ref() + .unwrap() + .author, + "First\nLast" + ); + assert_eq!(first.fidelity["tracked-changes"], Status::Exact); + let exported = docx::export(&first.imported()).unwrap(); + let second = read(&exported.bytes).unwrap(); + assert_eq!(second.content, first.content); + assert_eq!(second.citations(), first.citations()); + assert_eq!(second.fidelity["tracked-changes"], Status::Exact); +} + // ---- Opt-in independent-reader check (never part of the normal gate). ---- /// Runs one reader; `None` means the reader is not installed. diff --git a/docs/enterprise-document-docx-adapter.md b/docs/enterprise-document-docx-adapter.md index 1263a4d..a60ae26 100644 --- a/docs/enterprise-document-docx-adapter.md +++ b/docs/enterprise-document-docx-adapter.md @@ -28,9 +28,18 @@ not need Microsoft Word, LibreOffice, or a network service. escape, duplicate parts, bombs, budgets, macro and OLE content types, external relationships other than hyperlinks, DTDs, and entities. The adapter adds its own refusals only for the parts it reads. -- The shared XML reader keeps attribute prefixes as written, so the adapter - reads `w:val`, `r:id`, and `r:embed` by prefix. A part that binds those - namespaces under other prefixes is refused rather than misread. +- The shared XML reader validates the part before `quick-xml` resolves each + element and attribute through its in-scope namespace bindings. Transitional + and Strict WordprocessingML accept arbitrary prefixes and default element + namespaces; unprefixed attributes have no namespace. Foreign elements never + become Word content, and foreign roots are refused. +- Cumulative run payload and metadata copies (including transient templates) + are bounded by `Limits::max_total_bytes`. The importer returns `oversized` + before a copy would exceed that budget, including repeated tracked-change + authors and hyperlink targets. +- Markup compatibility alternatives (`mc:AlternateContent`) in a read part + are explicitly refused. The adapter does not claim to select an eligible + `Choice` or `Fallback`. - Nothing is fetched or executed. Fields keep their cached result and their code is dropped. Hyperlink targets are recorded, never dereferenced. - Content the model does not carry is skipped and recorded in the fidelity @@ -39,7 +48,9 @@ not need Microsoft Word, LibreOffice, or a network service. | Input | Refusal | |---|---| | Main part is not WordprocessingML (for example a workbook) | `unsupported-container` | -| A part binds the WordprocessingML or relationships namespace to a prefix other than `w` or `r`, or binds `w` or `r` to another namespace | `unsupported-container` | +| A read part has a foreign root namespace or `mc:AlternateContent` | `unsupported-container` | +| Cumulative run payload and metadata copies exceed `Limits::max_total_bytes` | `oversized` | +| Undeclared prefixes or duplicate attributes with the same expanded name | `malformed-xml` | | No `officeDocument` relationship, missing main part, or no `body` | `malformed-container` | | Malformed XML or an undeclared entity in any part the adapter reads | `malformed-xml` | | A DTD in any part the adapter reads | `xml-dtd-refused` | @@ -126,7 +137,10 @@ diagnostics, or any emitted string carries a finding, so no file is written. The export is deterministic: parts are sorted, every entry has the same timestamp, and styles and numbering are regenerated from the model. Importing an export gives the same model, the same anchors, and, exported again, the -same bytes. +same bytes. Entries whose deflate representation would exceed the default +intake compression ratio are stored instead. Attribute tabs, newlines, and +carriage returns are escaped as character references, preserving metadata +through XML attribute normalization. The receipt is canonical JSON with `schema_version`, `format`, `source_canonical_sha256`, `output_sha256`, `output_byte_length`, @@ -160,7 +174,10 @@ footnote, and endnote. Microsoft Word was not available and was not tested. `docx-unsupported-benign-fidelity`, and `docx-round-trip-anchors` rows. - Fixtures: `fixtures/enterprise-documents/docx/representative.parts` and `unsupported-benign.parts`, synthetic, with the one-pixel PNG inline in the - tests. + tests. The `docx/review/` text fixtures hold the original synthetic reviewer + packages and memory-ceiling controls as deflate bytes in hex, with original + lengths and SHA-256 digests checked after decoding. The shape fixture + uses a direct drawing; compatibility wrappers have a separate refusal test. ## Not done here diff --git a/fixtures/enterprise-documents/docx/review/amplify-small.fixture b/fixtures/enterprise-documents/docx/review/amplify-small.fixture new file mode 100644 index 0000000..32ed935 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/amplify-small.fixture @@ -0,0 +1,20 @@ +188000a8fd079b3d165647262826f263199e01b975c5769daa35490662d404aa +78444 +edd1bf6edb461c07702a6e0b049982780e044d018a8849870e862ca0703204065aa3c816180623d1 +b660892448cab291c56f9027e8dc252fd0b5cf93294bd6de49761a29ee1f74eaf0f90877a4bec7fb +1d7977b0bff5d576129ddd1d1e7edcbf9a9f779224b607217bb557166d5eb4472f2fabbc39ec5fcc +a683e56d37dc15cd6eefb46dab9d346d46a7f92c6bfa65951761e4b8ac67591bfed62769958dceb2 +933cfdeec993efd3d1aadce336d6e80d07cff2e36c3e6dbbcf2f42dc4cca62b757e7d3a6d7bd5e37 +aeb5dbcbaa6a3a19656d184fcf8bf1c62a8faf57e88799cb679ad349d57c1b1ee8a5b7ae1047fe7a +81eb793f9de7753d19e7dd83ac6e7fcc66e1a97451d6e3745c8ee6b330b3fff7656e79cff2f87832 +ca3fcd8fd5aaba1ce54d33294e66d3fea7915936296ede3f5d6ef7f060ed98b237e9de87701fdbbd +d08ee2a6a571039ac1cf9fefc27f3ca6b59d0cc7f479cdee8b7138a417e3a7bdeeeaabffb9f6eac3 +9f5d7fde2d4b6c3c112a67f549deeef6bedcf1b8256b9fb8b1358fde3f7cfbaedf4962bb1fb22f2a +0c163b377f57bbb3b3f837dfb0795aabaf8827d5eb0e43cdd7e5f8325eabd84d8aa61bfab0516197 +163bd9bc3d2debddde0f000000000000000000000000000000000000000000000000000000000000 +00000000000000000000000000000000000000000000000000000000000000000010f48683c54e1d +bb76783148e325f6b5582c168bc562b1582c168bc562b1582c168bc562b1582c168bc562b1582c16 +8bc562b1582c168bff7771e82745b3bc5671bcc947ed419d2e83d7e5f87279332e47f3595eb4c383 +fdce9dedaded243abb3b3cfcb87f353fef24496c0f923f5d7562ff6aaf2cda30ede8e565953787fd +8bd974b340f626ddfb10ee63bbb756e0d750e2a8cea74dda8ffde6c447ef1fbe7dd7ef24b1dd5f9b +f8fb9d245994f538bd79ebd5ba5f7f1387b7c2efb770fde5697cc33f00 diff --git a/fixtures/enterprise-documents/docx/review/amplify.fixture b/fixtures/enterprise-documents/docx/review/amplify.fixture new file mode 100644 index 0000000..25d8b25 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/amplify.fixture @@ -0,0 +1,32 @@ +c4f7bfbd2e2eeb7c9e2c2cf00f6d3f7b897286e66c8a11a57b7e73e44a487348 +255084 +edd1bf6edb4600c0613a6a0b041982207900816b1131edd0c19005144e87c0406b14d902c36024da +162a9104495936b278ca5b74ee92256333f64902f40db264ed9d64a7b1e2fe41e6ef67f0487f248f +e2717faff7c58324f6f2f6e8e0fddec5e2742b49e2763fd8b3ddaaec8ab23b7c7a5e17edc1e06c3e +1bae0efbe1a86c77d293aeabb7b3ac1d9f14f3bc1d5475518633475533cfbbf06f739cd5f9f897fc +b8c8be7df4e8bb6cbc9eee6117e74847c3c7c551be9875fd1fce02b7d3aadc499b62d6a6fdcbe7c6 +67eda4795dcfa6e3bc0be7b3d372b2f19487974f18843b57d7b427d3bafd3a5c9066373e219ef9e7 +075cdef7d369d134d349d1dfcf9beec77c1eaeca965533c926d578310f770efe7d9a1b7e67757434 +1d171fee8fb3d54d352eda765a1ecf67830f67e6f9b4bcfafdd96ab947fbd73e53fe22db7d178ee3 +76276c8771d1b2b800edf0e78f57e1333fd3b5950c9fe9e339fb4f26e1233d997c93f6d76ffddf73 +af5ffcf1e5ebddf0888d2bc2cc79735c743be9a72b1e97e4da2b6e2c4dfbfacddd576f7b49dcee05 +fb6486e172fbeadff5ea6c2fffcf3b6c7eadf55bc42f95f64761cee7d5e43ceeeb384ccbb61fc6b0 +50619596dbf9a23ba99a9df47b499224499224499224499224499224499224499224499224499224 +49922449922449922449922449922449922449922449922449922449922449922449922449922449 +9224499224499224499224499224499224499224499224499224499242e968b8dc6ee2d08dce8659 +dcc5b1c118638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618638c31c618 +638c31c618638c31c618638c31c618638c31c6f833398cd3b25dedeb78be2dc6dd7e93ade0793539 +5f1d4caaf1625e94dd687f6febd683de8324f6f2f6e8e0fddec5e2742b49e2763ff9bb8bad383edb +adca2edc76f8f4bc2eda83c1d97cb63941fe22db7d178ee376e7da04bf85290e9b62d66683386ede +d8be7e73f7d5db5e12b77bd76efce356922cab66925dfdeaf573bffc2a9eee85bfdfc3fed73f7b61 +fc0b diff --git a/fixtures/enterprise-documents/docx/review/attribute-newline.fixture b/fixtures/enterprise-documents/docx/review/attribute-newline.fixture new file mode 100644 index 0000000..c3d4dda --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/attribute-newline.fixture @@ -0,0 +1,15 @@ +2ded7dc081d50c13cac29854f76e9d0164daa66218fb3376a5fa1f455907c97c +1171 +9d53c16ed340104d1a40aa3821f201d62271a9ea4d397008b62f6d91a2a0d642880baaaac5de242b +ecddd5ee266e04879efa2bfd06ae553fa052bf811fe0c295193b691ad70184ad999ddd9979e379b3 +8e879d47dd163e17dbd1c9afe1f974d66eb5509ec3d9a77d251d97eef4c35c737be29fe559509a1e +58d28664e29cee536a9309cf99f595e6123c236572e6606bc654b3e40b1b73faaad77b4d930a6ed7 +21068982033e62d3cc798767706c859221313cb3c45bd4c55a21615a6722610efc7426d35a95dd45 +051f32cb183b11daee4000a18d15d0b3b9c022ef78c68d1129f76266dc11cb218a16caa43455c934 +874cffcf300ddfa9462391f0bb7c44d34625dc5a21c779e6df797226e4f2fb69497714af8d897da5 +fb3fc146790a728aa45124c006efefb3f09f635a6312c6741fd31ba430a441ba47bcaaebbf63578d +1f2cda6b28518b006466c6dc85e421e348c95a8b356a3e7efb71730b36ca3390070841d15f6e2b76 +fac5bff4509f56d5054e8a7811607e56e91c578d4a48eb8106a280a5a2cfa66ea24c48de0a63ddcb +177bbd37ef987504230d2a17495e04140dd4a6d48051ae25a0e5898b0d2d0faa4a74d50630d0deea +7636ffc5cbe7bcdd6afea7eb00f5fbb502b86cafddb67a629dfd55e2d556c32ce2e1e327e8eec0fb +1dd6eb0eee7e03 diff --git a/fixtures/enterprise-documents/docx/review/choice.fixture b/fixtures/enterprise-documents/docx/review/choice.fixture new file mode 100644 index 0000000..4b57c62 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/choice.fixture @@ -0,0 +1,17 @@ +4f00703bbbaa6a79d662e7d417c0997aea053f2139ddddcd119343227aba1fdf +1556 +9d94cf6fd33014c7d39521214e88fd0151aea8715b310e551a0975439a2a4134ed86a6e13a4e6b2d +8e8dedb6abb84c1cf8573873e4ca9fc3910b579ef3a35dd3940d12d9f1aff7797e5f3f271ab71f1d +39f6f9f224bcfc3dbe9d2f5a8e63cb73187b3f1299a199b9ba5849aa2ffd1b9e0679d38556a687de +cc183940489319e558fb42d20c6612a13836d055532431b9c6538afaddee2b440a5cc75886170627 +34c1f3d4b8a73730ac99c8869ea2a9f6dcd2aff535f4b0942923d8c03c5a6471cd4ba7f4e08365be +46cf98d42f6081871a3dd899fd0e4abb770baa148ba91b6165de620eabd052a818c582cc3958fa7f +c734ec53240923746d6f69520942b566d994a7fe7a86639655fb47b9dc61b4754cf8131afd82b62d +4fa15c59d190154007e77755f8cf63da52128ee92ed33d8be190cee29ee71651dfcf2e023f29c36b +70515b0164aca6d40cbd5dc5ad245b21d6a4f9f6f9e7878b03c7b1e5198ced1082e5a0ea16ea0c96 +0f89a17e5a4514f6a4bc12c3c943381cabebb9ec10c125c430612933ab9c556196bd973b1cce8812 +5a24c607bb52ac221bfbdd5e376fd964ac00c7ff06e8578010b499887815069c0c5ea786aa0c1b5a +a6783e389a09b074cfe9c7395314120b769b9b495b295b9930523481cb43e300d9aead555ec322b4 +86ece31deff0d63bb99ff706a7e90432b98ea8c6f7103666a831f2e5405362228572a34222b4c923 +48c1d6c1517bff6fb47a6e5b4ef34fb50ea85ff00de06b6bebbad70debe9bf31fc71d07019a2f1e1 +633bdd86f73b7c4f0f6def0f diff --git a/fixtures/enterprise-documents/docx/review/foreign.fixture b/fixtures/enterprise-documents/docx/review/foreign.fixture new file mode 100644 index 0000000..17d6aee --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/foreign.fixture @@ -0,0 +1,14 @@ +66ed3e094efe6c286845266862555aa405de3587733ab5b9d7b21639cd11f12c +1069 +9d53bd4ec330106e29202126040f107945c4c0c010255df8915025a8101203026412378d486ccb76 +4b100b134fc1ce33b0f2083c0a0b2b77494b691a4022d15d6c9feffb7cdfc5dd4e6b7ead81cfe352 +fbe2a3f33018361b0db455583bdf95c27261af4eef1437176e9ea57e317460244c40fad62a8f5213 +f679c68c2b151710e9499d310b531d53c5c21b1673babdb9b943c3126ec3220669fb7bbcc706a975 +f6735836891401d13c35c419f122574098526912320b713a1451856563c4e04266b1c7f41365d661 +03a1b50c18f9996094773ce45a271177ba4cdb2396c12e7a2b754423190e32c8747f87a939a7ecf5 +92907fe5239ad232e4c62422ce52f72b92b1448ccf4f0bb9dbdda936b17bbafb0e63b465b02b148d +a200c63ff9aec23fdb34a524b4e93ba6731841930ea32de29455ff8d5d16be372aaf86a2b2039099 +8eb90dc8ace228c9548915693e9ecede3218a3ad80cd20f8b9379e96ea787940065a7870629ec402 +cacdbd6b19dde157a1d3e86cfba08c3b96e7d6a7b8825e175e15becca21302385b736eadf5f3fd1a +3f0fcd46fd6dab02543b3f01786e4efd0733cc155d2689af73352a753b0b8b186ec1fb02dfcb16ce +3e01 diff --git a/fixtures/enterprise-documents/docx/review/high-compression.fixture b/fixtures/enterprise-documents/docx/review/high-compression.fixture new file mode 100644 index 0000000..ac4efe9 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/high-compression.fixture @@ -0,0 +1,19 @@ +673d88bac379c795421e8dc20f4869855c1538d0daf3ccd5ab1697120950eb85 +101118 +edd13d6fdb461800602a6e0b049982f807085a8b8869870e8244a0703a04065aa3c816380623d1b6 +50492448cab291c54b12a07fc4bfa16b7f5296acbd93ec3456dc0f74eaf03cc21d4ff7f1dedd7b07 +fb3b5fec26d1dbfbd9e187fdcbe5592749627914fa5eec958bb658b447cf2faaa239ec9fcf67c375 +b31b5a8b66d43b6ddb6a90a6cdf8b498e74dbfac8a4518392eeb79de86bff5495ae5e35ff29322fd +f6c993efd2f126dce336c6e865c3a7c571be9cb5dd1fce4377332d17a35e5dcc9a5ef77adfb8d7a8 +9757d56c3acedb309e9e2d265bbb3cbedea11f56aee734a7d3aaf93a4ce8a577ee1047fe7a83eb75 +3f9d15753d9d14dd83bc6e7fcce76156ba2aeb493a29c7cb7958d9fffb30779cb33c3e9e8e8b8feb +63b4aa2ec745d34c1727f359ffe3c83c9f2e6ece9faed39d1ddc7aa6fc75baf73eb4637910ca514c +5a1a13d00c7ffe340bfff1996e65323cd3a731bbcf26e1919e4dbee97537b7fee7d89b8b3fbdbede +1d5b6ccd0891f3faa46847bdcf331e5372eb8a5ba979397a7395bdeb24b13c0c7d9f4518ae06377f +37d919acfecd1db65f6b738bf852bd6e1662be2a2717f15bc5aa8e559b7d0f000000000000000000 +00000000000000000000000000000000000000000000000000000000000000000000000000000000 +00000000000000000000000000000000000000000000000000000000000000000000000000000000 +00000000000000fc6f0dd3d5a0cd625dafeb2a1bae064d316e0fea74ddf1aa9c5cac1b9372bc9c17 +8b363bd8efdcdbddd94da2b7f7b3c30ffb97cbb34e92c4f228f9d36527d62ff6ca451b961d3dbfa8 +8ae6b07f3e9f6d07c85fa77bef433b9607b7025c85104775316bd27eacb717be1cbdb9cade759258 +1ede5af8fbbd245995f524bd39f566df2fbf8ac33be1f75bf8a6bfc613fe01 diff --git a/fixtures/enterprise-documents/docx/review/ordinary.fixture b/fixtures/enterprise-documents/docx/review/ordinary.fixture new file mode 100644 index 0000000..26462cc --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/ordinary.fixture @@ -0,0 +1,14 @@ +28164888b5490bd0fa5dec4a3083590b83922f27b72a358326de7bca5a6f4a55 +1129 +9d533b4fc330106e2920211610fc80c82b222e0c0c559a0590409520426c08552671db8824b66cf7 +815898f811881df113d8103f898595bba4cf340544a2f3e31edff9beb3bd466579bb84dfe39a7bfd +d578e8f6caa512ca16e8ae0e456278629a977792eb6b7b10474ebab46095e83ae918236b946abfc3 +63a66d2179029696503133b0556d2a997fcbda9cee57ab07d4cfe0760d6210d739e22dd68d8c753c +00b50e4552278a479a58c3bc98ab4e989451e8330376da4b825c96dd61061b22531fdd09a5de0107 +420b33a065718261dc798f2b1506dcf29832672c062fda172aa081f0bb3144da3fc3149c53b45aa1 +cfc7f1882695f0b9d661d28e237b6c8959988cce4f53ba5d6fa64dec9e1e7ec21a651da489a45124 +403b17d32cfcb34d334c429ba631add3009a741aec112babfa77ecacf0a361790529721e80cc549b +9b3a99671c2999293147cdfbc9c6f32bac513641e6109c7e6db4cdd8a9f5ff5243be5b5915d82962 +b9807923823b9c250e0a07e35ef05ec8fb96e103e35054e0a8d23175d3dc379ea2a9228ba793c341 +5de5a5edcae2b739fa1ecaa5e2979a07c8df9a09c04b79e60ee503f39c4e023f960a18f61a2bab68 +aec0ff06f3530577df diff --git a/fixtures/enterprise-documents/docx/review/prefix-control.fixture b/fixtures/enterprise-documents/docx/review/prefix-control.fixture new file mode 100644 index 0000000..78045e3 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/prefix-control.fixture @@ -0,0 +1,14 @@ +5c28d56f6b45b3749cb1fa1f9cbfad34cdea74c5b5364c9ce10acc290915a650 +1168 +9d53cd6ed43010deed0252c509d107887cad1a2f1c38449bbdb41caa451055bd55556592d95d8bc4 +b66c6fb2552f7d1b9e81030778821e78122e5c9949f6a79ba6804834f6d833f38de71b3b990c9e1c +f4e8fbb63fbefc35b95d94fd5e8fe425ee5d1c6be541f9abf36b03ee325c16f9a85603d4948bd9dc +7b1371eed23914c285da8042cb54db42785cda193722fd2466c05f0f876f78dac01d79c260e3d109 +4cc522f7c1db256e3ba955cc2ce48e05abbc942b66c2985ca6c2a39d972a6b65395a650831b2f671 +7369dc213a30de99812c8f2758c57d28c15a99419008ebdf8b02bd78a56dc6339d2e0a8c0cff0cd3 +714e3d9dca1436f18466ac4ec139a966451e6e2c85906a7d7e5ed33d4e76da246ef8f14fd4499ea3 +5c11699c0870a3b3fb2cfc679b7698c436ddc70c4e336cd269f68a054dd57fc76e0a3f5995d791a2 +e581c8c2cec0c7ec21e344c94e892d6abe1ef21f77a893bc40798030aaa2f5b26127aafea58676b7 +9a2aa853c84f157dd4d935cda61e124b935ef85c2a7857e6411595228fd9b03efec6a11efcf80c4a +0955e061e9c9e86b17db38928783d427b6896cf2f06d0d587e7fef60f0f8135e7fb7fd5ef7836e03 +b42fd716e0737fe7aab503dbd46f03bfef753422993c7d46e601fe5f086240abdf diff --git a/fixtures/enterprise-documents/docx/review/prefix.fixture b/fixtures/enterprise-documents/docx/review/prefix.fixture new file mode 100644 index 0000000..3747b78 --- /dev/null +++ b/fixtures/enterprise-documents/docx/review/prefix.fixture @@ -0,0 +1,14 @@ +66e0eff22455aba1a4b22b3f0b6da7a3b72df8b6cd4d3830a0269db7e17de6a5 +1168 +9d53cd6ed43010deed0212424242f401225f51e32d481ca2cd5e5a0ed5561055dcaaaa32c9ecae45 +625bb6374dc5a5275e8567e8119e8037e01db8f4da99647fba690a8844638f3d33df78beb193c9e0 +d16e8fbeaf4fc7673793ab45d9eff5485ee2dee981561e943fff7869c09d8555918f6a35404db998 +cdbd3711e72e9d43215ca80d28b44cb52d84c7a59d7123d2cf6206fcf570f896a70ddc9e270c361e +1dc2542c721fbcab70db49ad626621772c58e6a55c3113c6e432151eedbc54592bcbde32438891b5 +8f9b4be35ea103e39d19c8f2708265dc8712ac95190489b0febd28d08b5f689bf14ca78b0223c33f +c3749c534fa73285753ca119ab53704eaa5991876b4b21a45a9d9fd7748f93ad36892ffce037ea24 +cf50ce89344e04b8d1c95d16feb34d5b4c629bee6206471936e928db674153f5dfb19bc20f97e575 +a4687920b2b033f031bbcf3851b255628b9a5fe59be73f51277981720f615445ab65c34e54fd4b0d +ed6e355550a7909f2afaa4b34b9a4d3d249626bdf0b954705ce6411595228fd9b03efedaa11efcf8 +044a09178187ca93d1d72eb671240f07a94f6c13d9e4e19b1ab0fcfeceeee0e127bcfaaefabdee07 +dd06685fae0dc0b7fed6556b07b6a9df04fed8e968443279fc84cc03fcaf71fe3ea0d52d diff --git a/fixtures/enterprise-documents/docx/unsupported-benign.parts b/fixtures/enterprise-documents/docx/unsupported-benign.parts index 58f2cd2..a1fd151 100644 --- a/fixtures/enterprise-documents/docx/unsupported-benign.parts +++ b/fixtures/enterprise-documents/docx/unsupported-benign.parts @@ -28,7 +28,7 @@ Chart follows: Equation: x=1 -Boxed textBoxed textAfter the box. +Boxed textAfter the box. Controlled text Date field: DATE \@ "yyyy-MM-dd" 2026-01-02 Symbol: diff --git a/tools/ci/test-budget.json b/tools/ci/test-budget.json index 7f22c46..a76be1f 100644 --- a/tools/ci/test-budget.json +++ b/tools/ci/test-budget.json @@ -274,6 +274,15 @@ "source": "crates/bran-document/tests/docx.rs", "support_fixtures": [ "fixtures/enterprise-documents/docx/representative.parts", + "fixtures/enterprise-documents/docx/review/amplify-small.fixture", + "fixtures/enterprise-documents/docx/review/amplify.fixture", + "fixtures/enterprise-documents/docx/review/attribute-newline.fixture", + "fixtures/enterprise-documents/docx/review/choice.fixture", + "fixtures/enterprise-documents/docx/review/foreign.fixture", + "fixtures/enterprise-documents/docx/review/high-compression.fixture", + "fixtures/enterprise-documents/docx/review/ordinary.fixture", + "fixtures/enterprise-documents/docx/review/prefix-control.fixture", + "fixtures/enterprise-documents/docx/review/prefix.fixture", "fixtures/enterprise-documents/docx/unsupported-benign.parts" ] },