diff --git a/crates/bran-document/src/conformance.rs b/crates/bran-document/src/conformance.rs index 81f52bd..30aa345 100644 --- a/crates/bran-document/src/conformance.rs +++ b/crates/bran-document/src/conformance.rs @@ -29,7 +29,11 @@ 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![&crate::xlsx::Xlsx, &crate::pdf::PdfAdapter] + vec![ + &crate::docx::Docx, + &crate::xlsx::Xlsx, + &crate::pdf::PdfAdapter, + ] } #[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..705e4fa --- /dev/null +++ b/crates/bran-document/src/docx.rs @@ -0,0 +1,3109 @@ +//! 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(), + model_bytes: 0, + }; + 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) +} + +/// 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(()), + _ => {} + } + } +} + +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, + /// 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 + .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, root: &str) -> Result, Refusal> { + self.cancel.check()?; + 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) + } + + /// 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, "styles")?; + 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, "w:val"); + if depth == 1 { + if let Some((id, style)) = current.take() { + self.styles.entry(id).or_insert(style); + } + 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())); + } + } + 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, "numbering")?; + 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, "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, "w:val")) + { + self.formats + .entry((abstract_id, level)) + .or_insert_with(|| format.to_owned()); + } + } + ["num"] => num = number(attr(attributes, "w:numId")), + ["num", "abstractNumId"] => { + if let (Some(num), Some(abstract_id)) = (num, number(attr(attributes, "w: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, "document")?; + 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, &format!("{item}s"))?; + 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, "w:type").is_some_and(|kind| kind != "normal") { + cursor.skip()?; + continue; + } + self.note(feature, Status::Normalized); + 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 }); + } + _ => cursor.skip()?, + } + } + } + + fn comments(&mut self, part: &Part) -> Result, Refusal> { + self.enter(&part.name); + let events = self.parse(part, "comments")?; + 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, "w:id")) { + Some(id) if ids.insert(id) => { + 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, + 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" => { + 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()?; + } + "m:oMath" | "m:oMathPara" => self.unsupported(cursor, "math")?, + "altChunk" => self.unsupported(cursor, "embedded-documents")?, + "sdtPr" | "sdtEndPr" | "customXmlPr" | "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, "w:val")) + .filter(|span| (1..=63).contains(span)) + .unwrap_or(1); + } + (1, "vMerge") => { + merge = if attr(attributes, "w: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" => { + 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()) => { + 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, "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, "w:id")) { + inlines.push(if name == "commentRangeStart" { + Inline::CommentStart(id) + } else { + Inline::CommentEnd(id) + }); + } + 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")?, + } + } + 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, "w: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, "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 { + 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, "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()), + } + } + + fn run( + &mut self, + cursor: &mut Cursor<'_>, + 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: link.cloned(), + change: change.cloned(), + }; + let mut text = String::new(); + loop { + let (name, attributes) = match cursor.next()? { + 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, "w: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, "w: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, "w: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() { + self.emit( + RunContent::Text(std::mem::take(&mut text)), + &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() { + 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), + }; + self.emit(content, &template, inlines)?; + } + cursor.skip()?; + } + "pict" => self.unsupported(cursor, "vml")?, + "object" => self.unsupported(cursor, "embedded-objects")?, + "sym" => self.unsupported(cursor, "symbols")?, + "m:oMath" | "m:oMathPara" => self.unsupported(cursor, "math")?, + "instrText" + | "delInstrText" + | "footnoteRef" + | "endnoteRef" + | "annotationRef" + | "lastRenderedPageBreak" + | "separator" + | "continuationSeparator" => cursor.skip()?, + _ => self.unsupported(cursor, "unrecognized-content")?, + } + } + if !text.is_empty() { + self.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, "wp:anchor") => floating = true, + (2, "wp:extent") => { + width = number::(attr(attributes, "cx")); + height = number::(attr(attributes, "cy")); + } + (2, "wp:docPr") => { + name = attr(attributes, "name").unwrap_or_default(); + description = attr(attributes, "descr").unwrap_or_default(); + } + (_, "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") + } + _ => {} + } + } + 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 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), + "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('"', """) + .replace('\t', " ") + .replace('\n', " ") + .replace('\r', " ") +} + +/// 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_with_ratio(&entries, Limits::default().max_ratio) +} + +/// 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 588d584..1c62c3d 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 pdf; @@ -104,6 +105,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/conformance.rs b/crates/bran-document/tests/conformance.rs index 1988e30..9e88e7b 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 XLSX_FEATURES: &str = include_str!("../../../fixtures/enterprise-documents/conformance/xlsx-features.parts"); const CONFORMANCE_DOC: &str = include_str!("../../../docs/enterprise-document-conformance.md"); @@ -616,15 +622,40 @@ fn run_corpus(tier: Tier) -> Report { 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"; + /// Canonical XLSX projection of the ordinary package, recorded from the /// adapter. A change here is a change to the grid projection. const XLSX_ORDINARY_PROJECTION: &str = "29cf261bf0933533ea37b196375340f0ec723183d9e09c30fcfbc9291fd9ca03"; +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 format must implement every one of /// its rows here; an unknown row fails, so no row silently stays unavailable. fn adapter_row(adapter: &dyn Adapter, row: &str, limits: &Limits) -> Result<(), String> { let cancel = Cancel::default(); + let run = |parts: &Parts, expect: &Expect| { + conformance::check( + adapter, + &parts.zip(), + &reencodings(parts), + expect, + limits, + &cancel, + ) + }; let features = Parts::parse(XLSX_FEATURES); let features_variants = || { vec![ @@ -633,6 +664,59 @@ fn adapter_row(adapter: &dyn Adapter, row: &str, limits: &Limits) -> Result<(), ] }; 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()) + } + } "xlsx-ordinary-projection" => { let imported = adapter .import(&Parts::parse(XLSX_BASE).zip(), limits, &cancel) diff --git a/crates/bran-document/tests/docx.rs b/crates/bran-document/tests/docx.rs new file mode 100644 index 0000000..00cea36 --- /dev/null +++ b/crates/bran-document/tests/docx.rs @@ -0,0 +1,1087 @@ +//! DOCX adapter (issue #21): import, fidelity receipts, export, and round trips. +//! +//! 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. + +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_reads_attributes_only_in_their_namespace() { + 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(); + 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, + ), + ( + "w prefix bound to a foreign namespace", + package(&edit( + base.clone(), + "word/styles.xml", + "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"", + "xmlns:w=\"urn:example:other\"", + )), + &default, + Refusal::UnsupportedContainer, + ), + ( + "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(); +} + +// Original, synthetic inputs from the independent review of 523b7b9. +fn review_input(name: &str) -> 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. +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/docs/enterprise-document-conformance.md b/docs/enterprise-document-conformance.md index 660dd21..511210f 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 @@ -49,8 +51,10 @@ 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 `adapter_row` in `crates/bran-document/tests/conformance.rs` implements every row of that format, so a registered format cannot keep rows -in the unavailable state. The XLSX adapter (#26, -[`enterprise-document-xlsx.md`](enterprise-document-xlsx.md)) is registered. +in the unavailable state. The DOCX adapter (#21, +[`enterprise-document-docx-adapter.md`](enterprise-document-docx-adapter.md)) +and the XLSX adapter (#26, +[`enterprise-document-xlsx.md`](enterprise-document-xlsx.md)) are registered. For each row, `conformance::check` verifies: @@ -105,11 +109,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) | | `pptx-ordinary-projection`, `pptx-unsupported-benign-fidelity`, `pptx-round-trip-anchors` | #22 | XLSX adapter rows run now against the registered adapter: @@ -183,10 +192,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`; `xlsx-ordinary-projection` | done for packages and XLSX; 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 | -| 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, XLSX and PDF content; DOCX and PPTX content fidelity needs #21, #22 | -| Citation anchors survive deterministic round trips | check step 5; `harness_rejects_round_trip_anchor_drift`; `xlsx-round-trip-anchors`; `pdf-round-trip-anchors` | done for XLSX and PDF; DOCX, PPTX when their adapters export | +| Same logical input, same canonical bytes and digest | check step 2; `ordinary` recorded digests; `canonical_json_matches_envelope_rule`; `docx-ordinary-projection` recorded digest; `xlsx-ordinary-projection` | done for packages, DOCX, and XLSX; 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, XLSX, PDF content; PPTX content fidelity needs #22 | +| Citation anchors survive deterministic round trips | check step 5; `harness_rejects_round_trip_anchor_drift`; `docx-round-trip-anchors`; `xlsx-round-trip-anchors`; `pdf-round-trip-anchors` | done for DOCX, XLSX, and PDF; PPTX when its adapter exports | | 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 | @@ -221,6 +230,7 @@ not use. | Largest generated package | 2 MiB | 24 MiB | | Runtime ceiling (debug build, whole tier) | 10 s | 120 s | | Measured on 2026-09-30 (debug build, shared intake only) | 0.8 s, 120 checks | 23.7 s, 120 checks | +| Measured on 2026-09-30 (debug build, with the DOCX adapter) | 0.9 s, 162 checks | 29.8 s, 162 checks | | Measured on 2026-09-30 (debug build, XLSX adapter registered) | 0.8 s, 162 checks | 28.6 s, 162 checks | | Fixture file size | 8 KiB each | same files | @@ -244,14 +254,17 @@ Status values: exact, normalized, approximated, unsupported, refused. | External relationships, remote media, external workbooks | refused | refused | refused | refused (remote go-to, URL file specifications, external streams) | | External hyperlinks | recorded, never fetched | recorded, never fetched | recorded, never fetched | recorded, never fetched | | DTDs and custom entities | refused | refused | refused | n/a | -| Text, structure, tables, lists | pending #21 | exact values and tables; rich text and styles normalized | pending #22 | text normalized; tables unsupported | -| Comments, tracked changes, notes | pending #21 | legacy comments exact; threaded comments unsupported | pending #22 | annotations normalized | +| Text, structure, tables, lists | exact text; normalized headings, lists, tables, sections | exact values and tables; rich text and styles normalized | pending #22 | text normalized; tables unsupported | +| Comments, tracked changes, notes | exact tracked run changes; normalized comments and notes; unsupported formatting changes | legacy comments exact; threaded comments unsupported | pending #22 | annotations normalized | +| Hyperlinks and bookmarks | exact (external targets recorded, never fetched) | exact (external targets recorded, never fetched; unsafe targets quarantined) | pending #22 | outline exact; hyperlinks recorded, never fetched | +| Fields and content controls | normalized (cached result kept, code dropped, never evaluated) | n/a | pending #22 | n/a | +| Headers and footers | unsupported | unsupported (`layout-not-imported`) | pending #22 | n/a | | Formulas and cached values | n/a | text exact, kept apart, never recalculated; active formulas quarantined | n/a | n/a | -| Images and media | pending #21 | unsupported, typed relationship with digest | pending #22 | image locators approximated; pixels not decoded | -| Charts, SmartArt, animations | pending #21 | unsupported, typed relationship with digest | pending #22 | n/a | +| Images and media | exact bytes, content-addressed; floating images normalized to inline | unsupported, typed relationship with digest | pending #22 | image locators approximated; pixels not decoded | +| Charts, SmartArt, animations | unsupported | unsupported, typed relationship with digest | pending #22 | n/a | | Power Query, external workbook links, query tables | n/a | refused | n/a | n/a | | OCR text | n/a | n/a | n/a | unsupported (no engine; requests reported unavailable) | -| Export | pending #21 | deterministic, with fidelity receipt; DLP first | pending #22 | tagged PDF; active content removed; no PDF/A or PDF/UA claim | +| Export | deterministic DOCX with fidelity receipt | deterministic, with fidelity receipt; DLP first | pending #22 | tagged PDF; active content removed; no PDF/A or PDF/UA claim | ## Acceptance status for #25 @@ -259,11 +272,11 @@ Status values: exact, normalized, approximated, unsupported, refused. |---|---|---| | Fixture matrix, package classes | done | package rows above | | Fixture matrix, PDF classes | done | PDF rows in `tests/pdf.rs` (#23) | -| Fixture matrix, unsupported-benign fidelity | not done | adapter rows; #21, #22, #26 | +| Fixture matrix, unsupported-benign fidelity | DOCX and XLSX done; PPTX not done | adapter rows; #22 | | 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, DOCX, XLSX, and PDF | content rows pending #22 | diff --git a/docs/enterprise-document-docx-adapter.md b/docs/enterprise-document-docx-adapter.md new file mode 100644 index 0000000..a60ae26 --- /dev/null +++ b/docs/enterprise-document-docx-adapter.md @@ -0,0 +1,188 @@ +--- +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. +- 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 + map. It is never silently kept or silently dropped. + +| Input | Refusal | +|---|---| +| Main part is not WordprocessingML (for example a workbook) | `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` | +| 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. 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`, +`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. 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 + +- 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. 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/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 new file mode 100644 index 0000000..a1fd151 --- /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 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 c3ea7f0..b3303fa 100644 --- a/tools/ci/test-budget.json +++ b/tools/ci/test-budget.json @@ -263,6 +263,29 @@ "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/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" + ] + }, { "stable_id": "P3-ENTERPRISE-CONFORMANCE", "phase": 3,