diff --git a/crates/bran-document/src/conformance.rs b/crates/bran-document/src/conformance.rs index 30aa345..103fa9b 100644 --- a/crates/bran-document/src/conformance.rs +++ b/crates/bran-document/src/conformance.rs @@ -32,6 +32,7 @@ pub fn registered() -> Vec<&'static dyn Adapter> { vec![ &crate::docx::Docx, &crate::xlsx::Xlsx, + &crate::pptx::Pptx, &crate::pdf::PdfAdapter, ] } diff --git a/crates/bran-document/src/lib.rs b/crates/bran-document/src/lib.rs index 1c62c3d..3242fa2 100644 --- a/crates/bran-document/src/lib.rs +++ b/crates/bran-document/src/lib.rs @@ -14,6 +14,7 @@ pub mod opc; pub mod pdf; mod pdf_syntax; mod pdf_text; +pub mod pptx; pub mod xlsx; pub mod xml; pub mod zip; diff --git a/crates/bran-document/src/opc.rs b/crates/bran-document/src/opc.rs index 0f09499..a38a5d4 100644 --- a/crates/bran-document/src/opc.rs +++ b/crates/bran-document/src/opc.rs @@ -177,7 +177,7 @@ pub fn open(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result) { +pub(crate) fn dlp_scan(bytes: &[u8], findings: &mut BTreeSet<&'static str>) { match validate_emitted_string(&String::from_utf8_lossy(bytes)) { Err(ExportError::DlpViolation(_)) => findings.insert("dlp-findings"), Err(_) => findings.insert("public-boundary-violation"), diff --git a/crates/bran-document/src/pptx.rs b/crates/bran-document/src/pptx.rs new file mode 100644 index 0000000..d014095 --- /dev/null +++ b/crates/bran-document/src/pptx.rs @@ -0,0 +1,2032 @@ +//! PPTX adapter (issue #22). +//! +//! Import runs the shared OPC intake first, so package safety, budgets, active +//! content parts, and external references are decided once for every format. +//! This module then maps PresentationML into canonical JSON: slide order and +//! identifiers, sections, layout names, shapes in reading order, text, tables, +//! notes, comments, alt text, links, images as content-addressed assets, and +//! envelope-shaped citation anchors. Export projects that content into a new +//! deck on a generic master, layouts, and theme, with a fidelity receipt part. +//! Nothing is fetched, run, or played. + +use crate::canonical::{sha256_hex, Json}; +use crate::conformance::{Adapter, Anchor, Imported}; +use crate::opc::{self, Part, Relationship}; +use crate::xml::{self, Event}; +use crate::zip::{self, WriteEntry}; +use crate::{Cancel, Format, Limits, Refusal, RECEIPT_VERSION}; +use bran_core::export::{validate_emitted_string, ExportError}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +/// Version tag of the canonical content this adapter emits and exports from. +pub const SCHEMA: &str = "bran.pptx.content/1"; +/// Package part and package relationship type of an export's fidelity receipt. +pub const RECEIPT_PART: &str = "bran/fidelity-receipt.json"; +pub const RECEIPT_RELATIONSHIP: &str = + "https://schemas.alphazede.dev/bran/relationships/fidelity-receipt"; +const EXPORT_RECEIPT_SCHEMA: &str = "bran.pptx.export-receipt/1"; + +/// Group nesting and canonical JSON depth are bounded so no walk can exhaust +/// the stack, whatever XML depth limit the caller chose. +const MAX_NESTING: usize = 64; +const MAX_JSON_DEPTH: usize = 256; +/// Canonical content that does not match this module's model. +const BAD: Refusal = Refusal::MalformedContainer; + +const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const PML: &str = "application/vnd.openxmlformats-officedocument.presentationml"; +const MAIN_TYPES: [&str; 3] = [ + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml", + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml", +]; +const TABLE_URI: &str = "http://schemas.openxmlformats.org/drawingml/2006/table"; +/// Transitional and Strict relationship namespaces. +const REL_NAMESPACES: [&str; 2] = [ + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://purl.oclc.org/ooxml/officeDocument/relationships", +]; +/// Relationship types whose targets are embedded or executable objects. +const EMBEDDED: [&str; 3] = ["/package", "/oleObject", "/control"]; +/// Click actions that start a program, macro, or OLE verb. +const RUNNING_ACTIONS: [&str; 3] = ["ppaction://program", "ppaction://macro", "ppaction://ole"]; +/// Presentation fidelity per envelope feature (#20). It states what this +/// adapter can carry, not what one deck contained. +const FIDELITY: [(&str, &str); 7] = [ + ("animations", "unsupported"), + ("macros", "unsupported"), + ("shapes", "normalized"), + ("slides", "exact"), + ("speaker_notes", "normalized"), + ("text", "normalized"), + ("z_order", "exact"), +]; + +/// The PPTX adapter registered with the conformance harness. +pub struct Pptx; + +impl Adapter for Pptx { + fn format(&self) -> Format { + Format::Pptx + } + + fn import(&self, bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result { + import(bytes, limits, cancel) + } + + fn export(&self, imported: &Imported) -> Result, Refusal> { + export(imported) + } +} + +// ---- Import ---- + +/// Imports an untrusted deck. Every refusal is typed; nothing partial returns. +pub fn import(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result { + let package = opc::open(bytes, limits, cancel)?; + if package + .relationships + .iter() + .any(|r| EMBEDDED.iter().any(|kind| r.kind.ends_with(kind))) + { + return Err(Refusal::ActiveContent); + } + let mut deck = Deck { + parts: package + .parts + .iter() + .map(|part| (part.name.to_ascii_lowercase(), part)) + .collect(), + relationships: package + .relationships + .iter() + .map(|r| ((r.source.to_ascii_lowercase(), r.id.clone()), r)) + .collect(), + limits, + cancel, + codes: BTreeSet::new(), + used: BTreeSet::new(), + assets: BTreeMap::new(), + slide_ids: BTreeMap::new(), + layouts: BTreeMap::new(), + legacy_authors: BTreeMap::new(), + modern_authors: BTreeMap::new(), + anchors: Vec::new(), + remaining_bytes: limits.max_total_bytes, + remaining_nodes: limits.max_xml_nodes, + slide: (0, 0), + z: 0, + shape_ids: BTreeSet::new(), + }; + let content = deck.content()?; + if deck + .parts + .keys() + .any(|name| !name.ends_with(".rels") && !deck.used.contains(name)) + { + deck.codes.insert("unmapped-parts-omitted"); + } + let canonical = content.to_bytes(); + // The intake's byte scan cannot see a canary split across runs; the + // canonical content carries each paragraph joined. + match validate_emitted_string(&String::from_utf8_lossy(&canonical)) { + Err(ExportError::DlpViolation(_)) => deck.codes.insert("dlp-findings"), + Err(_) => deck.codes.insert("public-boundary-violation"), + Ok(()) => false, + }; + let receipt: BTreeSet<&str> = package.diagnostics.union(&deck.codes).copied().collect(); + Ok(Imported { + canonical, + receipt: receipt.into_iter().map(str::to_owned).collect(), + anchors: deck + .anchors + .iter() + .map(|(id, text, _)| Anchor { + id: id.clone(), + text_digest: sha256_hex(text.as_bytes()), + }) + .collect(), + }) +} + +/// Parses canonical JSON written by this module (content or export receipt). +pub fn parse_canonical(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| BAD)?; + let mut parser = JsonParser { text, at: 0 }; + let value = parser.value(0)?; + if parser.at != text.len() { + return Err(BAD); + } + Ok(value) +} + +struct Node { + name: String, + attributes: Vec<(String, String)>, + children: Vec, + text: String, + end: usize, +} + +/// Arena tree over the shared reader's events. Nodes are in document order +/// and `end` closes each subtree, so nothing here recurses to build or drop. +/// Attributes in a relationship namespace are keyed `r:` whatever +/// prefix the part bound to it, so a producer's prefix choice cannot hide a +/// link or image; `r` bound to any other namespace becomes `r-foreign:`. +/// Every other key stays as the shared reader wrote it. +struct Dom(Vec); + +impl Dom { + fn parse(data: &[u8], limits: &Limits, cancel: &Cancel) -> Result { + let mut nodes: Vec = Vec::new(); + let mut open: Vec = Vec::new(); + // Namespace declarations of each open element, innermost last. + let mut scopes: Vec> = Vec::new(); + for event in xml::parse(data, limits, cancel)? { + match event { + Event::Open { + name, + mut attributes, + } => { + let declared: Vec<(String, String)> = attributes + .iter() + .filter_map(|(key, uri)| { + key.strip_prefix("xmlns:") + .map(|prefix| (prefix.to_owned(), uri.clone())) + }) + .collect(); + for (key, _) in &mut attributes { + let renamed = match key.split_once(':') { + Some((prefix, local)) if prefix != "xmlns" => { + let uri = declared + .iter() + .chain(scopes.iter().rev().flatten()) + .find(|(bound, _)| bound == prefix) + .map(|(_, uri)| uri.as_str()); + match uri { + Some(uri) if REL_NAMESPACES.contains(&uri) => { + Some(format!("r:{local}")) + } + Some(_) if prefix == "r" => Some(format!("r-foreign:{local}")), + Some(_) => None, + None if prefix == "xml" => None, + None => return Err(Refusal::MalformedXml), + } + } + _ => None, + }; + if let Some(renamed) = renamed { + *key = renamed; + } + } + scopes.push(declared); + let index = nodes.len(); + if let Some(&parent) = open.last() { + nodes[parent].children.push(index); + } + nodes.push(Node { + name, + attributes, + children: Vec::new(), + text: String::new(), + end: index + 1, + }); + open.push(index); + } + Event::Close => { + scopes.pop(); + if let Some(index) = open.pop() { + nodes[index].end = nodes.len(); + } + } + Event::Text(text) => { + if let Some(&index) = open.last() { + nodes[index].text.push_str(&text); + } + } + } + } + Ok(Self(nodes)) + } + + fn name(&self, node: usize) -> &str { + &self.0[node].name + } + + fn text(&self, node: usize) -> &str { + &self.0[node].text + } + + fn attr(&self, node: usize, key: &str) -> Option<&str> { + self.0[node] + .attributes + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.as_str()) + } + + fn flag(&self, node: usize, key: &str) -> bool { + matches!(self.attr(node, key), Some("1" | "true")) + } + + fn kids(&self, node: usize) -> impl Iterator + '_ { + self.0[node].children.iter().copied() + } + + fn named<'a>(&'a self, node: usize, name: &'a str) -> impl Iterator + 'a { + self.kids(node).filter(move |&kid| self.name(kid) == name) + } + + fn child(&self, node: usize, name: &str) -> Option { + self.named(node, name).next() + } + + fn path(&self, node: usize, names: &[&str]) -> Option { + names.iter().try_fold(node, |at, name| self.child(at, name)) + } + + fn num(&self, node: usize, key: &str) -> Result { + self.attr(node, key) + .and_then(|value| value.parse().ok()) + .ok_or(Refusal::MalformedXml) + } +} + +type Authors = BTreeMap)>; + +/// Where a shape sits: its anchor key, locator shape id, and z-order index. +struct Placed { + key: String, + shape: i64, + z: i64, +} + +struct Deck<'a> { + parts: BTreeMap, + relationships: BTreeMap<(String, String), &'a Relationship>, + limits: &'a Limits, + cancel: &'a Cancel, + codes: BTreeSet<&'static str>, + used: BTreeSet, + assets: BTreeMap, + slide_ids: BTreeMap, + layouts: BTreeMap>, + legacy_authors: Authors, + modern_authors: Authors, + /// (anchor id, text, envelope-shaped anchor) + anchors: Vec<(String, String, Json)>, + // One cumulative budget for relationship expansion and projection copies. + remaining_bytes: u64, + remaining_nodes: usize, + /// (slide number, slide id) of the slide being read. + slide: (i64, i64), + z: i64, + shape_ids: BTreeSet, +} + +impl<'a> Deck<'a> { + fn part(&mut self, name: &str) -> Option<&'a Part> { + let key = name.to_ascii_lowercase(); + let part = self.parts.get(&key).copied(); + if part.is_some() { + self.used.insert(key); + } + part + } + + fn reserve(&mut self, bytes: u64, nodes: usize) -> Result<(), Refusal> { + self.remaining_bytes = self + .remaining_bytes + .checked_sub(bytes) + .ok_or(Refusal::Oversized)?; + self.remaining_nodes = self + .remaining_nodes + .checked_sub(nodes) + .ok_or(Refusal::XmlNodeLimit)?; + Ok(()) + } + + fn dom(&mut self, part: &Part) -> Result { + self.cancel.check()?; + // Count every traversal, including a shared notes/comments target. + self.reserve(part.data.len() as u64, 0)?; + let mut limits = self.limits.clone(); + limits.max_xml_nodes = self.remaining_nodes; + let dom = Dom::parse(&part.data, &limits, self.cancel)?; + self.reserve(0, dom.0.len())?; + Ok(dom) + } + + fn relationship(&self, source: &str, id: &str) -> Option<&'a Relationship> { + self.relationships + .get(&(source.to_ascii_lowercase(), id.to_owned())) + .copied() + } + + fn related(&self, source: &str, suffix: &str) -> Vec<&'a Relationship> { + let source = source.to_ascii_lowercase(); + self.relationships + .range((source.clone(), String::new())..) + .take_while(|((from, _), _)| *from == source) + .filter(|(_, r)| r.kind.ends_with(suffix)) + .map(|(_, r)| *r) + .collect() + } + + fn content(&mut self) -> Result { + let main = self + .related("", "/officeDocument") + .first() + .map(|r| r.target.clone()) + .ok_or(Refusal::MalformedContainer)?; + let part = self.part(&main).ok_or(Refusal::MalformedContainer)?; + if !MAIN_TYPES.contains(&part.content_type.as_str()) { + return Err(Refusal::UnsupportedContainer); + } + let dom = self.dom(part)?; + if dom.name(0) != "presentation" { + return Err(Refusal::MalformedXml); + } + let slides = self.slide_list(&dom, &main)?; + let size = match dom.child(0, "sldSz") { + Some(size) => obj([ + ("cx", int(dom.num(size, "cx")?)), + ("cy", int(dom.num(size, "cy")?)), + ]), + None => Json::Null, + }; + let mut sections = Vec::new(); + for ext in dom + .child(0, "extLst") + .into_iter() + .flat_map(|l| dom.named(l, "ext")) + { + for section in dom + .child(ext, "sectionLst") + .into_iter() + .flat_map(|list| dom.named(list, "section")) + { + let mut ids = Vec::new(); + for entry in dom + .child(section, "sldIdLst") + .into_iter() + .flat_map(|list| dom.named(list, "sldId")) + { + ids.push(int(dom.num(entry, "id")?)); + } + sections.push(obj([ + ("id", opt(dom.attr(section, "id"))), + ("name", s(dom.attr(section, "name").unwrap_or_default())), + ("slides", Json::Arr(ids)), + ])); + } + } + self.legacy_authors = self.authors(&main, "/commentAuthors", "cmAuthor")?; + self.modern_authors = self.authors(&main, "/authors", "author")?; + let mut out = Vec::with_capacity(slides.len()); + for (index, (id, name)) in slides.iter().enumerate() { + out.push(self.slide(index as i64 + 1, *id, name)?); + } + let asset_bytes = self.assets.values().fold(0u64, |total, part| { + total.saturating_add((part.data.len() as u64).saturating_mul(4)) + }); + self.reserve(asset_bytes, self.assets.len())?; + let assets = self + .assets + .iter() + .map(|(digest, part)| { + obj([ + ("byte_length", int(part.data.len() as i64)), + ("data_hex", s(&hex(&part.data))), + ("id", s(&asset_id(digest))), + ("media_type", s(&part.content_type)), + ("sha256", s(digest)), + ]) + }) + .collect(); + self.anchors.sort_by(|a, b| a.0.cmp(&b.0)); + let anchor_bytes = self.anchors.iter().fold(0u64, |total, (_, text, _)| { + total.saturating_add(text.len() as u64) + }); + self.reserve(anchor_bytes, self.anchors.len())?; + Ok(obj([ + ( + "anchors", + Json::Arr(self.anchors.iter().map(|(_, _, a)| a.clone()).collect()), + ), + ("assets", Json::Arr(assets)), + ("family", s("presentation")), + ("fidelity", fidelity()), + ("schema", s(SCHEMA)), + ("sections", Json::Arr(sections)), + ("slide_size", size), + ("slides", Json::Arr(out)), + ])) + } + + /// Slides in `sldIdLst` order: (slide id, part name). + fn slide_list(&mut self, dom: &Dom, main: &str) -> Result, Refusal> { + let mut slides = Vec::new(); + let (mut parts, mut ids) = (BTreeSet::new(), BTreeSet::new()); + for entry in dom + .child(0, "sldIdLst") + .into_iter() + .flat_map(|list| dom.named(list, "sldId")) + { + let (id, rid) = (dom.attr(entry, "id"), dom.attr(entry, "r:id")); + let id = id + .and_then(|value| value.parse::().ok()) + .filter(|value| (256..=2_147_483_647).contains(value)) + .ok_or(Refusal::MalformedXml)?; + let target = rid + .and_then(|rid| self.relationship(main, rid)) + .filter(|r| !r.external && r.kind.ends_with("/slide")) + .map(|r| r.target.clone()) + .ok_or(Refusal::MalformedContainer)?; + let key = target.to_ascii_lowercase(); + let is_slide = self + .parts + .get(&key) + .is_some_and(|part| part.content_type == format!("{PML}.slide+xml")); + if !is_slide || !parts.insert(key.clone()) || !ids.insert(id) { + return Err(Refusal::MalformedContainer); + } + self.slide_ids.insert(key, i64::from(id)); + slides.push((i64::from(id), target)); + } + Ok(slides) + } + + fn authors(&mut self, main: &str, suffix: &str, element: &str) -> Result { + let mut authors = BTreeMap::new(); + for relationship in self.related(main, suffix) { + let Some(part) = self.part(&relationship.target) else { + continue; + }; + let dom = self.dom(part)?; + for author in dom.named(0, element) { + if let Some(id) = dom.attr(author, "id") { + let name = dom.attr(author, "name").unwrap_or_default().to_owned(); + let initials = dom.attr(author, "initials").map(str::to_owned); + authors.insert(id.to_owned(), (name, initials)); + } + } + } + Ok(authors) + } + + fn slide(&mut self, number: i64, id: i64, name: &str) -> Result { + let part = self.part(name).ok_or(Refusal::MalformedContainer)?; + let dom = self.dom(part)?; + if dom.name(0) != "sld" { + return Err(Refusal::MalformedXml); + } + self.scan(&dom)?; + self.slide = (number, id); + self.z = 0; + self.shape_ids.clear(); + let layout = self.layout(name)?; + let mut shapes = Vec::new(); + if let Some(tree) = dom.path(0, &["cSld", "spTree"]) { + self.shapes(&dom, name, tree, 0, &mut shapes)?; + } + let notes = self.notes(name)?; + let comments = self.comments(name)?; + Ok(obj([ + ("comments", Json::Arr(comments)), + ( + "hidden", + Json::Bool(matches!(dom.attr(0, "show"), Some("0" | "false"))), + ), + ("id", int(id)), + ("layout", opt(layout.as_deref())), + ( + "name", + opt(dom.child(0, "cSld").and_then(|c| dom.attr(c, "name"))), + ), + ("notes", notes), + ("shapes", Json::Arr(shapes)), + ])) + } + + /// Refuses running actions and embedded objects anywhere in a part and + /// receipts content this adapter does not carry. + fn scan(&mut self, dom: &Dom) -> Result<(), Refusal> { + for node in 0..dom.0.len() { + let code = match dom.name(node) { + "oleObj" | "control" => return Err(Refusal::ActiveContent), + "hlinkClick" => { + check_action(dom.attr(node, "action"))?; + continue; + } + "hlinkHover" | "hlinkMouseOver" => { + check_action(dom.attr(node, "action"))?; + "hover-action-omitted" + } + "transition" => "unsupported-transition", + "timing" => "unsupported-animation", + "videoFile" | "audioFile" | "quickTimeFile" | "wavAudioFile" | "media" | "snd" => { + "unsupported-media" + } + "contentPart" => "unsupported-content-part", + _ => continue, + }; + self.codes.insert(code); + } + Ok(()) + } + + fn layout(&mut self, slide: &str) -> Result, Refusal> { + let Some(relationship) = self.related(slide, "/slideLayout").first().copied() else { + return Ok(None); + }; + self.codes.insert("layout-design-normalized"); + let key = relationship.target.to_ascii_lowercase(); + if let Some(name) = self.layouts.get(&key) { + return Ok(name.clone()); + } + let name = match self.part(&relationship.target) { + Some(part) => { + let dom = self.dom(part)?; + self.scan(&dom)?; + dom.child(0, "cSld") + .and_then(|c| dom.attr(c, "name")) + .map(str::to_owned) + } + None => None, + }; + self.layouts.insert(key, name.clone()); + Ok(name) + } + + fn shapes( + &mut self, + dom: &Dom, + source: &str, + parent: usize, + depth: usize, + out: &mut Vec, + ) -> Result<(), Refusal> { + if depth > MAX_NESTING { + return Err(Refusal::XmlDepthLimit); + } + for node in dom.kids(parent) { + let shape = match dom.name(node) { + "sp" => { + let (mut shape, placed) = self.common(dom, source, node, "shape")?; + let text_box = dom + .path(node, &["nvSpPr", "cNvSpPr"]) + .is_some_and(|c| dom.flag(c, "txBox")); + let (paragraphs, text) = match dom.child(node, "txBody") { + Some(body) => self.paragraphs(dom, source, body)?, + None => (Json::Null, String::new()), + }; + let role = match dom + .path(node, &["nvSpPr", "nvPr", "ph"]) + .and_then(|ph| dom.attr(ph, "type")) + { + Some("title" | "ctrTitle") => "title", + Some("subTitle") => "heading", + _ => "paragraph", + }; + self.anchor(&placed, "", role, text)?; + shape.insert("text_box".to_owned(), Json::Bool(text_box)); + shape.insert("paragraphs".to_owned(), paragraphs); + shape + } + "cxnSp" => self.common(dom, source, node, "connector")?.0, + "pic" => { + let (mut shape, _) = self.common(dom, source, node, "picture")?; + let image = match dom + .path(node, &["blipFill", "blip"]) + .and_then(|blip| dom.attr(blip, "r:embed")) + { + Some(rid) => self.asset(source, rid)?, + None => Json::Null, + }; + shape.insert("image".to_owned(), image); + shape + } + "grpSp" => { + let (mut shape, _) = self.common(dom, source, node, "group")?; + let mut children = Vec::new(); + self.shapes(dom, source, node, depth + 1, &mut children)?; + shape.insert("children".to_owned(), Json::Arr(children)); + shape + } + "graphicFrame" => match self.frame(dom, source, node)? { + Some(shape) => shape, + None => continue, + }, + "AlternateContent" => { + match dom.child(node, "Fallback") { + Some(fallback) => { + self.codes.insert("alternate-content-fallback"); + self.shapes(dom, source, fallback, depth + 1, out)?; + } + None => { + self.codes.insert("alternate-content-omitted"); + } + } + continue; + } + _ => continue, + }; + out.push(Json::Obj(shape)); + } + Ok(()) + } + + /// Identity, name, accessibility metadata, placeholder, link, and geometry + /// shared by every shape kind. Also places the shape in z-order. + fn common( + &mut self, + dom: &Dom, + source: &str, + node: usize, + kind: &str, + ) -> Result<(BTreeMap, Placed), Refusal> { + self.codes.insert("shape-styling-normalized"); + let z = self.z; + self.z += 1; + let nv = dom.kids(node).find(|&kid| dom.name(kid).starts_with("nv")); + let c = nv.and_then(|nv| dom.child(nv, "cNvPr")); + let id = match c.and_then(|c| dom.attr(c, "id")) { + Some(value) => Some(i64::from( + value.parse::().map_err(|_| Refusal::MalformedXml)?, + )), + None => { + self.codes.insert("shape-identity-missing"); + None + } + }; + let key = match id { + Some(id) if self.shape_ids.insert(id) => format!("shape-{id}"), + Some(id) => { + self.codes.insert("duplicate-shape-id"); + format!("shape-{id}-z{z}") + } + None => format!("shape-z{z}"), + }; + let placed = Placed { + key, + shape: id.unwrap_or(0), + z, + }; + let attr = |key: &str| c.and_then(|c| dom.attr(c, key)); + let link = self.link(dom, source, c.and_then(|c| dom.child(c, "hlinkClick")))?; + let placeholder = match nv.and_then(|nv| dom.path(nv, &["nvPr", "ph"])) { + Some(ph) => obj([ + ("idx", opt(dom.attr(ph, "idx"))), + ("type", opt(dom.attr(ph, "type"))), + ]), + None => Json::Null, + }; + let props = dom + .child(node, "spPr") + .or_else(|| dom.child(node, "grpSpPr")); + let frame = dom + .child(node, "xfrm") + .or_else(|| props.and_then(|p| dom.child(p, "xfrm"))); + let preset = props + .and_then(|p| dom.child(p, "prstGeom")) + .and_then(|g| dom.attr(g, "prst")); + if let Some(alt) = attr("descr").filter(|alt| !alt.trim().is_empty()) { + self.anchor(&placed, ":alt", "shape", alt.to_owned())?; + } + let shape = BTreeMap::from([ + ("alt_text".to_owned(), opt(attr("descr"))), + ("alt_title".to_owned(), opt(attr("title"))), + ( + "hidden".to_owned(), + Json::Bool(c.is_some_and(|c| dom.flag(c, "hidden"))), + ), + ("id".to_owned(), id.map_or(Json::Null, int)), + ("kind".to_owned(), s(kind)), + ("link".to_owned(), link), + ("name".to_owned(), s(attr("name").unwrap_or_default())), + ("placeholder".to_owned(), placeholder), + ("preset".to_owned(), opt(preset)), + ("xfrm".to_owned(), xfrm(dom, frame)?), + ]); + Ok((shape, placed)) + } + + fn anchor( + &mut self, + placed: &Placed, + suffix: &str, + role: &str, + text: String, + ) -> Result<(), Refusal> { + if text.trim().is_empty() { + return Ok(()); + } + self.reserve((text.len() as u64).saturating_mul(2), 1)?; + let (number, slide) = self.slide; + let id = format!("anc:pptx:slide-{slide}:{}{suffix}", placed.key); + let locator = obj([ + ("family", s("presentation")), + ("shape", int(placed.shape)), + ("slide", int(number)), + ("z_index", int(placed.z)), + ]); + let anchor = obj([ + ("family", s("presentation")), + ("id", s(&id)), + ("locator", locator), + ("role", s(role)), + ("text", s(&text)), + ("text_digest", s(&sha256_hex(text.as_bytes()))), + ]); + self.anchors.push((id, text, anchor)); + Ok(()) + } + + /// Paragraphs of a text body, and their text joined for the anchor. + fn paragraphs( + &mut self, + dom: &Dom, + source: &str, + body: usize, + ) -> Result<(Json, String), Refusal> { + let mut paragraphs = Vec::new(); + let mut lines = Vec::new(); + for paragraph in dom.named(body, "p") { + let level = match dom.child(paragraph, "pPr").and_then(|p| dom.attr(p, "lvl")) { + Some(value) => value.parse::().map_err(|_| Refusal::MalformedXml)?, + None => 0, + }; + // Text-level alternate content (equations) is read from its fallback. + let mut items = Vec::new(); + for item in dom.kids(paragraph) { + if dom.name(item) != "AlternateContent" { + items.push(item); + } else if let Some(fallback) = dom.child(item, "Fallback") { + self.codes.insert("alternate-content-fallback"); + items.extend(dom.kids(fallback)); + } else { + self.codes.insert("alternate-content-omitted"); + } + } + let (mut runs, mut line) = (Vec::new(), String::new()); + for item in items { + let text = match dom.name(item) { + "r" | "fld" => { + if dom.name(item) == "fld" { + self.codes.insert("field-as-text"); + } + self.codes.insert("text-formatting-normalized"); + dom.child(item, "t").map_or("", |t| dom.text(t)) + } + "br" => "\n", + _ => continue, + }; + let link = dom + .child(item, "rPr") + .and_then(|properties| dom.child(properties, "hlinkClick")); + let link = self.link(dom, source, link)?; + // Run text and the joined paragraph both allocate a copy. + self.reserve((text.len() as u64).saturating_mul(2), 1)?; + line.push_str(text); + runs.push(obj([("link", link), ("text", s(text))])); + } + lines.push(line); + paragraphs.push(obj([("level", int(level)), ("runs", Json::Arr(runs))])); + } + let joined_bytes = lines.iter().fold(0u64, |total, line| { + total.saturating_add(line.len() as u64 + 1) + }); + self.reserve(joined_bytes, paragraphs.len())?; + Ok((Json::Arr(paragraphs), lines.join("\n"))) + } + + fn link(&mut self, dom: &Dom, source: &str, node: Option) -> Result { + let Some(node) = node else { + return Ok(Json::Null); + }; + let action = dom.attr(node, "action").filter(|a| !a.is_empty()); + check_action(action)?; + let (mut url, mut slide) = (None, None); + if let Some(rid) = dom.attr(node, "r:id").filter(|id| !id.is_empty()) { + let relationship = self + .relationship(source, rid) + .ok_or(Refusal::MalformedContainer)?; + if relationship.external { + url = Some(relationship.target.as_str()); + } else if let Some(id) = self + .slide_ids + .get(&relationship.target.to_ascii_lowercase()) + { + slide = Some(*id); + } else { + self.codes.insert("link-target-omitted"); + } + } + self.reserve(url.map_or(0, |url| url.len() as u64), 1)?; + Ok(obj([ + ("action", opt(action)), + ("slide", slide.map_or(Json::Null, int)), + ("url", opt(url)), + ])) + } + + fn asset(&mut self, source: &str, rid: &str) -> Result { + let relationship = self + .relationship(source, rid) + .ok_or(Refusal::MalformedContainer)?; + if relationship.external { + return Err(Refusal::ExternalReference); + } + // A missing target is already receipted by the intake. + let Some(part) = self.part(&relationship.target) else { + return Ok(Json::Null); + }; + if !part.content_type.starts_with("image/") { + self.codes.insert("unsupported-media"); + return Ok(Json::Null); + } + let digest = sha256_hex(&part.data); + let id = asset_id(&digest); + self.assets.insert(digest, part); + Ok(s(&id)) + } + + /// Tables become table shapes; charts, SmartArt, and other frames are + /// receipted and left out of the projection. + fn frame( + &mut self, + dom: &Dom, + source: &str, + node: usize, + ) -> Result>, Refusal> { + let data = dom.path(node, &["graphic", "graphicData"]); + let Some(table) = data.and_then(|d| dom.child(d, "tbl")) else { + let uri = data.and_then(|d| dom.attr(d, "uri")).unwrap_or_default(); + self.codes.insert(if uri.contains("chart") { + "unsupported-chart" + } else if uri.ends_with("/diagram") { + "unsupported-smartart" + } else { + "unsupported-graphic-frame" + }); + return Ok(None); + }; + self.codes.insert("table-formatting-normalized"); + let (mut shape, placed) = self.common(dom, source, node, "table")?; + let mut columns = Vec::new(); + for column in dom + .child(table, "tblGrid") + .into_iter() + .flat_map(|grid| dom.named(grid, "gridCol")) + { + columns.push(int(dom.num(column, "w")?)); + } + let (mut rows, mut lines) = (Vec::new(), Vec::new()); + for row in dom.named(table, "tr") { + let (mut cells, mut texts) = (Vec::new(), Vec::new()); + for cell in dom.named(row, "tc") { + let (paragraphs, text) = match dom.child(cell, "txBody") { + Some(body) => self.paragraphs(dom, source, body)?, + None => (Json::Arr(Vec::new()), String::new()), + }; + cells.push(paragraphs); + texts.push(text); + } + lines.push(texts.join("\t")); + rows.push(obj([ + ("cells", Json::Arr(cells)), + ("height", int(dom.num(row, "h")?)), + ])); + } + self.anchor(&placed, "", "table", lines.join("\n"))?; + shape.insert("columns".to_owned(), Json::Arr(columns)); + shape.insert("rows".to_owned(), Json::Arr(rows)); + Ok(Some(shape)) + } + + fn notes(&mut self, slide: &str) -> Result { + let Some(relationship) = self.related(slide, "/notesSlide").first().copied() else { + return Ok(Json::Null); + }; + let Some(part) = self.part(&relationship.target) else { + return Ok(Json::Null); + }; + let dom = self.dom(part)?; + self.scan(&dom)?; + let body = dom.path(0, &["cSld", "spTree"]).and_then(|tree| { + dom.named(tree, "sp").find(|&sp| { + dom.path(sp, &["nvSpPr", "nvPr", "ph"]) + .is_some_and(|ph| dom.attr(ph, "type") == Some("body")) + }) + }); + let Some(body) = body else { + return Ok(Json::Null); + }; + let id = match dom + .path(body, &["nvSpPr", "cNvPr"]) + .and_then(|c| dom.attr(c, "id")) + { + Some(value) => Some(i64::from( + value.parse::().map_err(|_| Refusal::MalformedXml)?, + )), + None => None, + }; + let (paragraphs, text) = match dom.child(body, "txBody") { + Some(text_body) => self.paragraphs(&dom, &relationship.target, text_body)?, + None => (Json::Arr(Vec::new()), String::new()), + }; + let placed = Placed { + key: "notes".to_owned(), + shape: id.unwrap_or(0), + z: 0, + }; + self.anchor(&placed, "", "notes", text)?; + Ok(obj([ + ("paragraphs", paragraphs), + ("shape", id.map_or(Json::Null, int)), + ])) + } + + /// Legacy and modern comments, flattened in document order. + fn comments(&mut self, slide: &str) -> Result, Refusal> { + let mut comments = Vec::new(); + for relationship in self.related(slide, "/comments") { + let modern = relationship.kind.contains("microsoft.com"); + let Some(part) = self.part(&relationship.target) else { + continue; + }; + let dom = self.dom(part)?; + self.scan(&dom)?; + for node in 0..dom.0.len() { + let name = dom.name(node); + if !(name == "cm" || modern && name == "reply") { + continue; + } + if name == "reply" { + self.codes.insert("comment-threads-flattened"); + } + let authors = if modern { + &self.modern_authors + } else { + &self.legacy_authors + }; + let (author, initials) = dom + .attr(node, "authorId") + .and_then(|id| authors.get(id)) + .cloned() + .unwrap_or_default(); + let text = match (modern, dom.child(node, "txBody"), dom.child(node, "text")) { + (true, Some(body), _) => self.paragraphs(&dom, &relationship.target, body)?.1, + (false, _, Some(text)) => dom.text(text).to_owned(), + _ => String::new(), + }; + self.codes.insert("comment-metadata-normalized"); + let placed = Placed { + key: format!("comment-{}", comments.len() + 1), + shape: comments.len() as i64 + 1, + z: 0, + }; + self.reserve( + (text.len() as u64) + .saturating_add(author.len() as u64) + .saturating_add(initials.as_ref().map_or(0, |s| s.len() as u64)), + 1, + )?; + self.anchor(&placed, "", "notes", text.clone())?; + comments.push(obj([ + ("author", s(&author)), + ("initials", opt(initials.as_deref())), + ("text", s(&text)), + ])); + } + } + Ok(comments) + } +} + +fn check_action(action: Option<&str>) -> Result<(), Refusal> { + let action = action.unwrap_or_default().to_ascii_lowercase(); + if RUNNING_ACTIONS + .iter() + .any(|prefix| action.starts_with(prefix)) + { + return Err(Refusal::ActiveContent); + } + Ok(()) +} + +fn xfrm(dom: &Dom, frame: Option) -> Result { + let Some(frame) = frame else { + return Ok(Json::Null); + }; + let pair = |element: &str, a: &str, b: &str| -> Result { + Ok(match dom.child(frame, element) { + Some(node) => Json::Arr(vec![int(dom.num(node, a)?), int(dom.num(node, b)?)]), + None => Json::Null, + }) + }; + Ok(obj([ + ("ch_ext", pair("chExt", "cx", "cy")?), + ("ch_off", pair("chOff", "x", "y")?), + ("ext", pair("ext", "cx", "cy")?), + ("off", pair("off", "x", "y")?), + ])) +} + +fn fidelity() -> Json { + Json::Obj( + FIDELITY + .iter() + .map(|(feature, status)| ((*feature).to_owned(), s(status))) + .collect(), + ) +} + +fn asset_id(digest: &str) -> String { + format!("asset:pptx:sha256-{digest}") +} + +fn s(value: &str) -> Json { + Json::Str(value.to_owned()) +} + +fn opt(value: Option<&str>) -> Json { + value.map_or(Json::Null, s) +} + +fn int(value: i64) -> Json { + Json::Int(value) +} + +fn obj(pairs: [(&str, Json); N]) -> Json { + Json::Obj( + pairs + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect(), + ) +} + +// ponytail: hex doubles asset bytes inside canonical content; move to base64 +// or a separate asset store if canonical size starts to matter. +fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn unhex(text: &str) -> Result, Refusal> { + if !text.len().is_multiple_of(2) || !text.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(BAD); + } + let nibble = |b: u8| (b as char).to_digit(16).unwrap_or_default() as u8; + Ok(text + .as_bytes() + .chunks(2) + .map(|pair| nibble(pair[0]) << 4 | nibble(pair[1])) + .collect()) +} + +/// Bounded reader for the canonical JSON subset (`Json` has no floats). +struct JsonParser<'a> { + text: &'a str, + at: usize, +} + +impl JsonParser<'_> { + fn peek(&self) -> Option { + self.text.as_bytes().get(self.at).copied() + } + + fn eat(&mut self, byte: u8) -> bool { + let matched = self.peek() == Some(byte); + self.at += usize::from(matched); + matched + } + + fn expect(&mut self, byte: u8) -> Result<(), Refusal> { + if self.eat(byte) { + Ok(()) + } else { + Err(BAD) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_JSON_DEPTH { + return Err(BAD); + } + let literal = |parser: &mut Self, word: &str, value: Json| { + if parser.text[parser.at..].starts_with(word) { + parser.at += word.len(); + Ok(value) + } else { + Err(BAD) + } + }; + match self.peek().ok_or(BAD)? { + b'n' => literal(self, "null", Json::Null), + b't' => literal(self, "true", Json::Bool(true)), + b'f' => literal(self, "false", Json::Bool(false)), + b'"' => self.string().map(Json::Str), + b'[' => { + self.at += 1; + let mut items = Vec::new(); + if !self.eat(b']') { + loop { + items.push(self.value(depth + 1)?); + if self.eat(b']') { + break; + } + self.expect(b',')?; + } + } + Ok(Json::Arr(items)) + } + b'{' => { + self.at += 1; + let mut map = BTreeMap::new(); + if !self.eat(b'}') { + loop { + let key = self.string()?; + self.expect(b':')?; + if map.insert(key, self.value(depth + 1)?).is_some() { + return Err(BAD); + } + if self.eat(b'}') { + break; + } + self.expect(b',')?; + } + } + Ok(Json::Obj(map)) + } + _ => { + let start = self.at; + self.eat(b'-'); + while self.peek().is_some_and(|b| b.is_ascii_digit()) { + self.at += 1; + } + self.text[start..self.at] + .parse() + .map(Json::Int) + .map_err(|_| BAD) + } + } + } + + fn string(&mut self) -> Result { + self.expect(b'"')?; + let mut out = String::new(); + loop { + let rest = &self.text[self.at..]; + let stop = rest + .find(|c: char| c == '"' || c == '\\' || c < ' ') + .ok_or(BAD)?; + out.push_str(&rest[..stop]); + self.at += stop; + match self.peek() { + Some(b'"') => { + self.at += 1; + return Ok(out); + } + Some(b'\\') => { + let escape = self.text.as_bytes().get(self.at + 1).copied().ok_or(BAD)?; + self.at += 2; + out.push(match escape { + b'"' => '"', + b'\\' => '\\', + b'/' => '/', + b'b' => '\u{8}', + b'f' => '\u{c}', + b'n' => '\n', + b'r' => '\r', + b't' => '\t', + b'u' => { + let digits = self.text.get(self.at..self.at + 4).ok_or(BAD)?; + if !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(BAD); + } + self.at += 4; + let code = u32::from_str_radix(digits, 16).map_err(|_| BAD)?; + char::from_u32(code).ok_or(BAD)? + } + _ => return Err(BAD), + }); + } + _ => return Err(BAD), + } + } + } +} + +// ---- Export ---- + +/// Projects imported content into a new deck. DLP and public-boundary checks +/// run on everything the deck would contain before any bytes are returned. +pub fn export(imported: &Imported) -> Result, Refusal> { + for (code, refusal) in [ + ("dlp-findings", Refusal::DlpFindings), + ("public-boundary-violation", Refusal::PublicBoundary), + ] { + if imported.receipt.iter().any(|item| item == code) { + return Err(refusal); + } + } + let deck = parse_canonical(&imported.canonical)?; + if deck.field("schema")?.str()? != SCHEMA { + return Err(Refusal::ExportUnsupported); + } + let mut writer = Writer::default(); + let comments = writer.deck(&deck)?; + let mut codes = vec!["generic-master-layout-theme", "run-properties-defaulted"]; + if comments { + codes.push("comments-written-legacy"); + } + let receipt = obj([ + ("content_sha256", s(&sha256_hex(&imported.canonical))), + ( + "export_receipt", + Json::Arr(codes.into_iter().map(s).collect()), + ), + ("fidelity", fidelity()), + ( + "import_receipt", + Json::Arr(imported.receipt.iter().map(|code| s(code)).collect()), + ), + ("receipt_version", s(RECEIPT_VERSION)), + ("schema", s(EXPORT_RECEIPT_SCHEMA)), + ]); + writer.part(RECEIPT_PART.to_owned(), None, receipt.to_bytes()); + let mut root = Rels::default(); + root.add( + &format!("{R}/officeDocument"), + "ppt/presentation.xml", + false, + ); + root.add(RECEIPT_RELATIONSHIP, RECEIPT_PART, false); + writer.rels("", &root); + writer.finish() +} + +/// Reads canonical content; any mismatch with the model is `BAD`. +trait Field { + fn field(&self, key: &str) -> Result<&Json, Refusal>; + fn str(&self) -> Result<&str, Refusal>; + fn opt_str(&self) -> Result, Refusal>; + fn int(&self) -> Result; + fn opt_int(&self) -> Result, Refusal>; + fn arr(&self) -> Result<&[Json], Refusal>; + fn bool(&self) -> Result; +} + +impl Field for Json { + fn field(&self, key: &str) -> Result<&Json, Refusal> { + match self { + Json::Obj(map) => map.get(key).ok_or(BAD), + _ => Err(BAD), + } + } + + fn str(&self) -> Result<&str, Refusal> { + match self { + Json::Str(text) => Ok(text), + _ => Err(BAD), + } + } + + fn opt_str(&self) -> Result, Refusal> { + match self { + Json::Null => Ok(None), + other => other.str().map(Some), + } + } + + fn int(&self) -> Result { + match self { + Json::Int(value) => Ok(*value), + _ => Err(BAD), + } + } + + fn opt_int(&self) -> Result, Refusal> { + match self { + Json::Null => Ok(None), + other => other.int().map(Some), + } + } + + fn arr(&self) -> Result<&[Json], Refusal> { + match self { + Json::Arr(items) => Ok(items), + _ => Err(BAD), + } + } + + fn bool(&self) -> Result { + match self { + Json::Bool(value) => Ok(*value), + _ => Err(BAD), + } + } +} + +/// Relationships of one part, deduplicated, numbered `rId1..` in order added. +#[derive(Default)] +struct Rels(Vec<(String, String, bool)>); + +impl Rels { + fn add(&mut self, kind: &str, target: &str, external: bool) -> String { + let key = (kind.to_owned(), target.to_owned(), external); + let index = match self.0.iter().position(|item| *item == key) { + Some(index) => index, + None => { + self.0.push(key); + self.0.len() - 1 + } + }; + format!("rId{}", index + 1) + } +} + +/// Parts of the deck being written, with the text they emit for DLP. +#[derive(Default)] +struct Writer { + parts: BTreeMap>, + types: BTreeMap, + texts: Vec, +} + +const DECL: &str = "\n"; +const NS: &str = "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\""; +const TREE_ROOT: &str = + ""; +const PLACEHOLDERS: &str = ""; +const CLR_MAP: &str = ""; +const MASTER_CLR: &str = ""; +const THEME: &str = ""; + +impl Writer { + fn part(&mut self, name: String, content_type: Option<&str>, data: impl Into>) { + if let Some(content_type) = content_type { + self.types.insert(name.clone(), content_type.to_owned()); + } + self.parts.insert(name, data.into()); + } + + fn rels(&mut self, source: &str, rels: &Rels) { + let name = match source.rsplit_once('/') { + Some((directory, file)) => format!("{directory}/_rels/{file}.rels"), + None => format!("_rels/{source}.rels"), + }; + let mut xml = format!( + "{DECL}" + ); + for (index, (kind, target, external)) in rels.0.iter().enumerate() { + let mode = if *external { + " TargetMode=\"External\"" + } else { + "" + }; + let _ = write!( + xml, + "", + index + 1, + esc(kind), + esc(target) + ); + } + xml.push_str(""); + self.parts.insert(name, xml.into_bytes()); + } + + /// Writes every deck part. Returns whether any comment was written. + fn deck(&mut self, deck: &Json) -> Result { + let slides = deck.field("slides")?.arr()?; + let mut media = BTreeMap::new(); + for (index, asset) in deck.field("assets")?.arr()?.iter().enumerate() { + let data = unhex(asset.field("data_hex")?.str()?)?; + if sha256_hex(&data) != asset.field("sha256")?.str()? { + return Err(BAD); + } + let media_type = asset.field("media_type")?.str()?; + let file = format!("image{}.{}", index + 1, extension(media_type)); + self.part(format!("ppt/media/{file}"), Some(media_type), data); + media.insert(asset.field("id")?.str()?, file); + } + let mut ids = BTreeMap::new(); + let mut layouts: Vec> = Vec::new(); + for (index, slide) in slides.iter().enumerate() { + if ids.insert(slide.field("id")?.int()?, index + 1).is_some() { + return Err(BAD); + } + let layout = slide.field("layout")?.opt_str()?; + if !layouts.contains(&layout) { + layouts.push(layout); + } + } + if layouts.is_empty() { + layouts.push(None); + } + + let mut master = Rels::default(); + let mut layout_ids = String::new(); + for (index, name) in layouts.iter().enumerate() { + let file = format!("slideLayout{}.xml", index + 1); + let rid = master.add( + &format!("{R}/slideLayout"), + &format!("../slideLayouts/{file}"), + false, + ); + let _ = write!( + layout_ids, + "", + 2_147_483_649_u64 + index as u64 + ); + let name = match name { + Some(name) => { + self.texts.push((*name).to_owned()); + format!(" name=\"{}\"", esc(name)) + } + None => String::new(), + }; + let part = format!("ppt/slideLayouts/{file}"); + self.part( + part.clone(), + Some(&format!("{PML}.slideLayout+xml")), + format!("{DECL}{TREE_ROOT}{PLACEHOLDERS}{MASTER_CLR}"), + ); + let mut rels = Rels::default(); + rels.add( + &format!("{R}/slideMaster"), + "../slideMasters/slideMaster1.xml", + false, + ); + self.rels(&part, &rels); + } + master.add(&format!("{R}/theme"), "../theme/theme1.xml", false); + self.part( + "ppt/slideMasters/slideMaster1.xml".to_owned(), + Some(&format!("{PML}.slideMaster+xml")), + format!("{DECL}{TREE_ROOT}{PLACEHOLDERS}{CLR_MAP}{layout_ids}"), + ); + self.rels("ppt/slideMasters/slideMaster1.xml", &master); + self.part( + "ppt/theme/theme1.xml".to_owned(), + Some("application/vnd.openxmlformats-officedocument.theme+xml"), + format!("{DECL}{THEME}"), + ); + + let mut presentation = Rels::default(); + presentation.add( + &format!("{R}/slideMaster"), + "slideMasters/slideMaster1.xml", + false, + ); + let mut slide_list = String::new(); + let mut any_notes = false; + // (author, initials) in first-use order, with each author's last index. + let mut authors: Vec<((&str, Option<&str>), usize)> = Vec::new(); + for (index, slide) in slides.iter().enumerate() { + let number = index + 1; + let part = format!("ppt/slides/slide{number}.xml"); + let rid = presentation.add( + &format!("{R}/slide"), + &format!("slides/slide{number}.xml"), + false, + ); + let _ = write!( + slide_list, + "", + slide.field("id")?.int()? + ); + let mut rels = Rels::default(); + let layout = slide.field("layout")?.opt_str()?; + let layout = layouts + .iter() + .position(|item| *item == layout) + .unwrap_or_default(); + rels.add( + &format!("{R}/slideLayout"), + &format!("../slideLayouts/slideLayout{}.xml", layout + 1), + false, + ); + let mut shapes = String::new(); + for shape in slide.field("shapes")?.arr()? { + self.shape(&mut shapes, shape, &mut rels, &media, &ids, 0)?; + } + let notes = slide.field("notes")?; + if !matches!(notes, Json::Null) { + any_notes = true; + self.notes(notes, number, &ids)?; + rels.add( + &format!("{R}/notesSlide"), + &format!("../notesSlides/notesSlide{number}.xml"), + false, + ); + } + let comments = slide.field("comments")?.arr()?; + if !comments.is_empty() { + let mut xml = format!("{DECL}"); + for comment in comments { + let author = comment.field("author")?.str()?; + let initials = comment.field("initials")?.opt_str()?; + let text = comment.field("text")?.str()?; + self.texts.extend([author.to_owned(), text.to_owned()]); + let key = (author, initials); + let id = match authors.iter().position(|(item, _)| *item == key) { + Some(id) => id, + None => { + authors.push((key, 0)); + authors.len() - 1 + } + }; + authors[id].1 += 1; + let _ = write!( + xml, + "{}", + authors[id].1, + esc(text) + ); + } + xml.push_str(""); + self.part( + format!("ppt/comments/comment{number}.xml"), + Some(&format!("{PML}.comments+xml")), + xml, + ); + rels.add( + &format!("{R}/comments"), + &format!("../comments/comment{number}.xml"), + false, + ); + } + let hidden = if slide.field("hidden")?.bool()? { + " show=\"0\"" + } else { + "" + }; + let name = match slide.field("name")?.opt_str()? { + Some(name) => { + self.texts.push(name.to_owned()); + format!(" name=\"{}\"", esc(name)) + } + None => String::new(), + }; + self.part( + part.clone(), + Some(&format!("{PML}.slide+xml")), + format!("{DECL}{TREE_ROOT}{shapes}{MASTER_CLR}"), + ); + self.rels(&part, &rels); + } + + let notes_master = if any_notes { + let rid = presentation.add( + &format!("{R}/notesMaster"), + "notesMasters/notesMaster1.xml", + false, + ); + self.part( + "ppt/notesMasters/notesMaster1.xml".to_owned(), + Some(&format!("{PML}.notesMaster+xml")), + format!("{DECL}{TREE_ROOT}{CLR_MAP}"), + ); + let mut rels = Rels::default(); + rels.add(&format!("{R}/theme"), "../theme/theme2.xml", false); + self.rels("ppt/notesMasters/notesMaster1.xml", &rels); + self.part( + "ppt/theme/theme2.xml".to_owned(), + Some("application/vnd.openxmlformats-officedocument.theme+xml"), + format!("{DECL}{THEME}"), + ); + format!("") + } else { + String::new() + }; + if !authors.is_empty() { + presentation.add(&format!("{R}/commentAuthors"), "commentAuthors.xml", false); + let mut xml = format!("{DECL}"); + for (id, ((name, initials), last)) in authors.iter().enumerate() { + let initials = initials + .map(|value| format!(" initials=\"{}\"", esc(value))) + .unwrap_or_default(); + let _ = write!( + xml, + "", + esc(name), + id % 8 + ); + } + xml.push_str(""); + self.part( + "ppt/commentAuthors.xml".to_owned(), + Some(&format!("{PML}.commentAuthors+xml")), + xml, + ); + } + presentation.add(&format!("{R}/theme"), "theme/theme1.xml", false); + let size = match deck.field("slide_size")? { + Json::Null => String::new(), + size => format!( + "", + size.field("cx")?.int()?, + size.field("cy")?.int()? + ), + }; + let mut sections = String::new(); + for section in deck.field("sections")?.arr()? { + let name = section.field("name")?.str()?; + self.texts.push(name.to_owned()); + let id = section + .field("id")? + .opt_str()? + .map(|id| format!(" id=\"{}\"", esc(id))) + .unwrap_or_default(); + let _ = write!( + sections, + "", + esc(name) + ); + for slide in section.field("slides")?.arr()? { + let _ = write!(sections, "", slide.int()?); + } + sections.push_str(""); + } + if !sections.is_empty() { + sections = format!("{sections}"); + } + self.part( + "ppt/presentation.xml".to_owned(), + Some(MAIN_TYPES[0]), + format!("{DECL}{notes_master}{slide_list}{size}{sections}"), + ); + self.rels("ppt/presentation.xml", &presentation); + Ok(!authors.is_empty()) + } + + fn notes( + &mut self, + notes: &Json, + number: usize, + ids: &BTreeMap, + ) -> Result<(), Refusal> { + let id = notes + .field("shape")? + .opt_int()? + .ok_or(Refusal::ExportUnsupported)?; + let mut rels = Rels::default(); + rels.add( + &format!("{R}/notesMaster"), + "../notesMasters/notesMaster1.xml", + false, + ); + rels.add( + &format!("{R}/slide"), + &format!("../slides/slide{number}.xml"), + false, + ); + let body = self.paragraphs(notes.field("paragraphs")?, &mut rels, ids)?; + let part = format!("ppt/notesSlides/notesSlide{number}.xml"); + self.part( + part.clone(), + Some(&format!("{PML}.notesSlide+xml")), + format!("{DECL}{TREE_ROOT}{body}{MASTER_CLR}"), + ); + self.rels(&part, &rels); + Ok(()) + } + + fn shape( + &mut self, + out: &mut String, + shape: &Json, + rels: &mut Rels, + media: &BTreeMap<&str, String>, + ids: &BTreeMap, + depth: usize, + ) -> Result<(), Refusal> { + if depth > MAX_NESTING { + return Err(BAD); + } + // A shape without an identity cannot be projected faithfully. + let id = shape + .field("id")? + .opt_int()? + .ok_or(Refusal::ExportUnsupported)?; + let name = shape.field("name")?.str()?; + self.texts.push(name.to_owned()); + let mut attributes = format!(" id=\"{id}\" name=\"{}\"", esc(name)); + for (key, attribute) in [("alt_text", "descr"), ("alt_title", "title")] { + if let Some(value) = shape.field(key)?.opt_str()? { + self.texts.push(value.to_owned()); + let _ = write!(attributes, " {attribute}=\"{}\"", esc(value)); + } + } + if shape.field("hidden")?.bool()? { + attributes.push_str(" hidden=\"1\""); + } + let link = self.link(shape.field("link")?, rels, ids)?; + let c_nv_pr = format!("{link}"); + let nv_pr = match shape.field("placeholder")? { + Json::Null => "".to_owned(), + placeholder => { + let mut ph = String::new(); + for key in ["type", "idx"] { + if let Some(value) = placeholder.field(key)?.opt_str()? { + let _ = write!(ph, " {key}=\"{}\"", esc(value)); + } + } + format!("") + } + }; + let geometry = match shape.field("preset")?.opt_str()? { + Some(preset) => format!( + "", + esc(preset) + ), + None => String::new(), + }; + let frame = shape.field("xfrm")?; + let _ = match shape.field("kind")?.str()? { + "shape" => { + let text_box = if shape.field("text_box")?.bool()? { " txBox=\"1\"" } else { "" }; + let body = match shape.field("paragraphs")? { + Json::Null => String::new(), + paragraphs => format!( + "{}", + self.paragraphs(paragraphs, rels, ids)? + ), + }; + write!(out, "{c_nv_pr}{nv_pr}{}{geometry}{body}", xfrm_xml(frame, "a:xfrm")?) + } + "connector" => write!(out, "{c_nv_pr}{nv_pr}{}{geometry}", xfrm_xml(frame, "a:xfrm")?), + "picture" => { + let blip = match shape.field("image")?.opt_str()? { + Some(asset) => { + let file = media.get(asset).ok_or(BAD)?; + let rid = rels.add(&format!("{R}/image"), &format!("../media/{file}"), false); + format!("") + } + None => String::new(), + }; + write!(out, "{c_nv_pr}{nv_pr}{blip}{}{geometry}", xfrm_xml(frame, "a:xfrm")?) + } + "group" => { + let mut children = String::new(); + for child in shape.field("children")?.arr()? { + self.shape(&mut children, child, rels, media, ids, depth + 1)?; + } + write!(out, "{c_nv_pr}{nv_pr}{}{children}", xfrm_xml(frame, "a:xfrm")?) + } + "table" => { + let mut table = String::from(""); + for column in shape.field("columns")?.arr()? { + let _ = write!(table, "", column.int()?); + } + table.push_str(""); + for row in shape.field("rows")?.arr()? { + let _ = write!(table, "", row.field("height")?.int()?); + for cell in row.field("cells")?.arr()? { + let _ = write!(table, "{}", self.paragraphs(cell, rels, ids)?); + } + table.push_str(""); + } + table.push_str(""); + write!(out, "{c_nv_pr}{nv_pr}{}{table}", xfrm_xml(frame, "p:xfrm")?) + } + _ => return Err(BAD), + }; + Ok(()) + } + + fn paragraphs( + &mut self, + paragraphs: &Json, + rels: &mut Rels, + ids: &BTreeMap, + ) -> Result { + let mut out = String::new(); + for paragraph in paragraphs.arr()? { + out.push_str(""); + let level = paragraph.field("level")?.int()?; + if level != 0 { + let _ = write!(out, ""); + } + let mut line = String::new(); + for run in paragraph.field("runs")?.arr()? { + let text = run.field("text")?.str()?; + let link = self.link(run.field("link")?, rels, ids)?; + line.push_str(text); + let _ = match (text, link.is_empty()) { + ("\n", true) => write!(out, ""), + (_, true) => write!( + out, + "{}", + esc(text) + ), + (_, false) => write!( + out, + "{link}{}", + esc(text) + ), + }; + } + self.texts.push(line); + out.push_str(""); + } + Ok(out) + } + + fn link( + &mut self, + link: &Json, + rels: &mut Rels, + ids: &BTreeMap, + ) -> Result { + if matches!(link, Json::Null) { + return Ok(String::new()); + } + let action = link.field("action")?.opt_str()?; + check_action(action)?; + let rid = match ( + link.field("url")?.opt_str()?, + link.field("slide")?.opt_int()?, + ) { + (Some(url), _) => { + self.texts.push(url.to_owned()); + rels.add(&format!("{R}/hyperlink"), url, true) + } + (None, Some(slide)) => { + let number = ids.get(&slide).ok_or(BAD)?; + rels.add( + &format!("{R}/slide"), + &format!("../slides/slide{number}.xml"), + false, + ) + } + (None, None) => String::new(), + }; + let action = action + .map(|action| format!(" action=\"{}\"", esc(action))) + .unwrap_or_default(); + Ok(format!("")) + } + + /// Checks everything the deck contains, then builds the package with + /// sorted names, one fixed timestamp, and deflate. + fn finish(mut self) -> Result, Refusal> { + let mut types = String::from(""); + for (name, content_type) in &self.types { + let _ = write!( + types, + "", + esc(name), + esc(content_type) + ); + } + types.push_str(""); + self.parts.insert( + "[Content_Types].xml".to_owned(), + format!("{DECL}{types}").into_bytes(), + ); + let mut findings = BTreeSet::new(); + for text in &self.texts { + opc::dlp_scan(text.as_bytes(), &mut findings); + } + for (name, data) in &self.parts { + opc::dlp_scan(name.as_bytes(), &mut findings); + opc::dlp_scan(data, &mut findings); + } + if findings.contains("dlp-findings") { + return Err(Refusal::DlpFindings); + } + if findings.contains("public-boundary-violation") { + return Err(Refusal::PublicBoundary); + } + // `[Content_Types].xml` sorts first; readers that expect it first get it. + let entries: Vec> = self + .parts + .iter() + .map(|(name, data)| WriteEntry { + name, + data, + deflate: true, + dos_time: 0, + dos_date: 0x0021, + }) + .collect(); + let limits = Limits::default(); + let bytes = zip::write_bounded(&entries, &limits)?; + // Generated XML must satisfy the same node/depth budgets as intake. + opc::open(&bytes, &limits, &Cancel::default())?; + Ok(bytes) + } +} + +fn xfrm_xml(frame: &Json, tag: &str) -> Result { + if matches!(frame, Json::Null) { + return Ok(String::new()); + } + let mut inner = String::new(); + for (key, element, a, b) in [ + ("off", "a:off", "x", "y"), + ("ext", "a:ext", "cx", "cy"), + ("ch_off", "a:chOff", "x", "y"), + ("ch_ext", "a:chExt", "cx", "cy"), + ] { + match frame.field(key)? { + Json::Null => {} + Json::Arr(pair) if pair.len() == 2 => { + let _ = write!( + inner, + "<{element} {a}=\"{}\" {b}=\"{}\"/>", + pair[0].int()?, + pair[1].int()? + ); + } + _ => return Err(BAD), + } + } + Ok(format!("<{tag}>{inner}")) +} + +/// XML text and attribute escaping. Tabs and line breaks become character +/// references so attribute normalization cannot change them on re-import; +/// characters XML 1.0 cannot carry become U+FFFD. +fn esc(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => out.push('\u{FFFD}'), + c => out.push(c), + } + } + out +} + +fn extension(media_type: &str) -> &'static str { + match media_type { + "image/png" => "png", + "image/jpeg" => "jpeg", + "image/gif" => "gif", + "image/bmp" => "bmp", + "image/tiff" => "tiff", + "image/x-emf" => "emf", + "image/x-wmf" => "wmf", + "image/svg+xml" => "svg", + _ => "bin", + } +} diff --git a/crates/bran-document/src/xml.rs b/crates/bran-document/src/xml.rs index b1614f0..f12b439 100644 --- a/crates/bran-document/src/xml.rs +++ b/crates/bran-document/src/xml.rs @@ -29,7 +29,7 @@ pub fn parse(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result Result { + if matches!( + reader.resolver().resolve_element(element.name()).0, + quick_xml::name::ResolveResult::Unknown(_) + ) { + return Err(Refusal::MalformedXml); + } if depth == 0 { roots += 1; } @@ -54,6 +60,12 @@ pub fn parse(bytes: &[u8], limits: &Limits, cancel: &Cancel) -> Result (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) + write_inner(entries, None, u64::MAX).expect("unbounded in-memory ZIP write") +} + +/// Export policy: enforce intake budgets and store entries whose deflate +/// ratio would make them inadmissible on re-import. +pub(crate) fn write_bounded( + entries: &[WriteEntry<'_>], + limits: &Limits, +) -> Result, Refusal> { + if entries.len() > limits.max_parts || entries.len() >= u16::MAX as usize { + return Err(Refusal::TooManyParts); + } + let mut remaining = limits.max_total_bytes; + for entry in entries { + if entry.name.len() > u16::MAX as usize + || entry.data.len() as u64 > limits.max_part_bytes + || entry.data.len() >= u32::MAX as usize + { + return Err(Refusal::Oversized); + } + remaining = remaining + .checked_sub(entry.data.len() as u64) + .ok_or(Refusal::Oversized)?; + } + write_inner(entries, Some(limits), limits.max_ratio) } /// 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 { + write_inner(entries, None, max_ratio).expect("unbounded in-memory ZIP write") +} + +fn write_inner( + entries: &[WriteEntry<'_>], + limits: Option<&Limits>, + max_ratio: u64, +) -> Result, Refusal> { use std::io::Write; let mut out = Vec::new(); let mut central = Vec::new(); @@ -282,6 +314,18 @@ pub(crate) fn write_with_ratio(entries: &[WriteEntry<'_>], max_ratio: u64) -> Ve } else { (entry.data.to_vec(), 0u16) }; + // Include both headers, both names, and the final end record before + // growing either output buffer. + let package_size = (out.len() as u64) + .saturating_add(central.len() as u64) + .saturating_add(76 + 2 * entry.name.len() as u64) + .saturating_add(data.len() as u64) + .saturating_add(22); + if limits.is_some_and(|limits| { + package_size > limits.max_package_bytes || package_size >= u32::MAX as u64 + }) { + return Err(Refusal::Oversized); + } let crc = crc32fast::hash(entry.data); let local = out.len() as u32; let name = entry.name.as_bytes(); @@ -319,7 +363,7 @@ pub(crate) fn write_with_ratio(entries: &[WriteEntry<'_>], max_ratio: u64) -> Ve out.extend_from_slice(&(central.len() as u32).to_le_bytes()); out.extend_from_slice(&cd_offset.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // comment length - out + Ok(out) } #[cfg(test)] diff --git a/crates/bran-document/tests/conformance.rs b/crates/bran-document/tests/conformance.rs index 9e88e7b..2ba11cc 100644 --- a/crates/bran-document/tests/conformance.rs +++ b/crates/bran-document/tests/conformance.rs @@ -15,6 +15,9 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; +#[path = "pptx/decks.rs"] +mod pptx_decks; + // Recorded budgets. Runtime budgets are debug-build wall-clock ceilings for // the whole tier; they gate the test suite, never an import outcome. const FAST_RUNTIME_BUDGET: Duration = Duration::from_secs(10); @@ -534,9 +537,12 @@ fn variants(row: &str, format: Format) -> Vec> { return Vec::new(); } let tier_limits = limits(Tier::Fast); - let original = build(row, format, Tier::Fast, &tier_limits); + reencode(&build(row, format, Tier::Fast, &tier_limits)) +} + +fn reencode(original: &[u8]) -> Vec> { let entries = - zip::read(&original, &tier_limits, &Cancel::default()).expect("admitted row reads"); + zip::read(original, &limits(Tier::Fast), &Cancel::default()).expect("admitted row reads"); let parts = Parts( entries .into_iter() @@ -761,6 +767,19 @@ fn adapter_row(adapter: &dyn Adapter, row: &str, limits: &Limits) -> Result<(), } Ok(()) } + "pptx-ordinary-projection" + | "pptx-unsupported-benign-fidelity" + | "pptx-round-trip-anchors" => { + let (input, expect) = pptx_decks::row(row).ok_or_else(|| { + format!("adapter registered: replace {row} with an executable row") + })?; + let outcome = + conformance::check(adapter, &input, &reencode(&input), &expect, limits, &cancel)?; + if row.ends_with("round-trip-anchors") && !outcome.round_trip { + return Err("export refused, so no round trip was exercised".to_owned()); + } + Ok(()) + } other => Err(format!( "adapter registered: replace {other} with an executable row" )), diff --git a/crates/bran-document/tests/pptx.rs b/crates/bran-document/tests/pptx.rs new file mode 100644 index 0000000..941caf1 --- /dev/null +++ b/crates/bran-document/tests/pptx.rs @@ -0,0 +1,1045 @@ +//! PPTX adapter (issue #22): import, export, round trip, DLP, and hostile decks. +//! +//! Every deck is synthetic and built in memory from the `.parts` fixtures. +//! No test here needs PowerPoint, a cloud service, or the network. The one +//! reader test is opt-in and reports each missing reader as unavailable. + +use bran_document::canonical::{sha256_hex, Json}; +use bran_document::conformance::{self, Adapter, Expect, Imported, PackageIntake}; +use bran_document::pptx::{self, Pptx}; +use bran_document::{export, opc, Cancel, Format, Limits, Refusal}; +use std::io::Read; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::PathBuf; +use std::process::Command; + +#[path = "pptx/decks.rs"] +mod decks; + +use decks::{Deck, REL}; + +const CANARIES: &str = + include_str!("../../../fixtures/public-boundary/rejected/synthetic-canaries.txt"); +/// Canonical projection digest of the ordinary deck. A change here is a change +/// to the PPTX content model and must be reviewed as one. +const ORDINARY_DIGEST: &str = "30541582214bd6e1a249c2de0443c48a330d44d8e9fd7d23665351409420e070"; +const SLIDE3: &str = "ppt/slides/slide3.xml"; +const RELS3: &str = "ppt/slides/_rels/slide3.xml.rels"; + +fn import_bytes(bytes: &[u8]) -> Result { + Pptx.import(bytes, &Limits::default(), &Cancel::default()) +} + +fn import(deck: &Deck) -> Imported { + import_bytes(&deck.zip()).expect("deck imports") +} + +fn content(bytes: &[u8]) -> Json { + pptx::parse_canonical(bytes).expect("canonical JSON parses") +} + +fn get<'a>(value: &'a Json, key: &str) -> &'a Json { + match value { + Json::Obj(map) => map + .get(key) + .unwrap_or_else(|| panic!("missing {key} in {value:?}")), + other => panic!("not an object: {other:?}"), + } +} + +fn arr(value: &Json) -> &[Json] { + match value { + Json::Arr(items) => items, + other => panic!("not an array: {other:?}"), + } +} + +fn text(value: &Json) -> &str { + match value { + Json::Str(text) => text, + other => panic!("not a string: {other:?}"), + } +} + +fn int(value: &Json) -> i64 { + match value { + Json::Int(value) => *value, + other => panic!("not an integer: {other:?}"), + } +} + +fn runs(paragraph: &Json) -> Vec<&str> { + arr(get(paragraph, "runs")) + .iter() + .map(|run| text(get(run, "text"))) + .collect() +} + +fn cell_texts(table: &Json) -> Vec> { + arr(get(table, "rows")) + .iter() + .map(|row| { + arr(get(row, "cells")) + .iter() + .map(|cell| arr(cell).iter().map(|p| runs(p).concat()).collect()) + .collect() + }) + .collect() +} + +fn anchor<'a>(deck: &'a Json, id: &str) -> &'a Json { + arr(get(deck, "anchors")) + .iter() + .find(|anchor| text(get(anchor, "id")) == id) + .unwrap_or_else(|| panic!("anchor {id} missing")) +} + +fn has(imported: &Imported, code: &str) -> bool { + imported.receipt.iter().any(|item| item == code) +} + +fn canary() -> &'static str { + CANARIES + .lines() + .find(|line| !line.is_empty()) + .expect("synthetic canary") +} + +// Exact reviewer ZIPs, deflated and hex-encoded to keep the corpus text-only. +fn review_deck(encoded: &str, digest: &str) -> Vec { + let hex: String = encoded.split_whitespace().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()) + .read_to_end(&mut bytes) + .unwrap(); + assert_eq!(sha256_hex(&bytes), digest, "original reviewer input"); + bytes +} + +#[test] +fn pptx_review_binary_dlp_rescan() { + let bytes = review_deck( + include_str!( + "../../../fixtures/enterprise-documents/pptx-review/png-metadata-canary.deflate.hex" + ), + "072d0358bba5850397e1867fbfb46c077e5d576a09f6c8ed7bdb0e730aeaf19f", + ); + let mut imported = import_bytes(&bytes).unwrap(); + assert!( + has(&imported, "dlp-findings"), + "intake detects PNG metadata" + ); + assert_eq!(Pptx.export(&imported).err(), Some(Refusal::DlpFindings)); + imported.receipt.clear(); + assert_eq!( + Pptx.export(&imported).err(), + Some(Refusal::DlpFindings), + "export independently scans binary metadata" + ); +} + +#[test] +fn pptx_review_shared_notes_budget() { + if std::env::var_os("BRAN_PPTX_BUDGET_CHILD").is_none() { + let exe = std::env::current_exe().unwrap(); + let mut command = if cfg!(target_os = "linux") { + let mut command = Command::new("sh"); + command.args([ + "-c", + "ulimit -v 524288; ulimit -c 0; exec \"$@\"", + "pptx-budget", + ]); + command.arg(&exe); + command + } else { + Command::new(&exe) + }; + let output = command + .args([ + "--exact", + "pptx_review_shared_notes_budget", + "--nocapture", + "--test-threads=1", + ]) + .env("BRAN_PPTX_BUDGET_CHILD", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "bounded child {}: {} {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + // Positive control under the identical address-space limit. + import(&Deck::ordinary()); + let bytes = review_deck( + include_str!("../../../fixtures/enterprise-documents/pptx-review/shared-notes-amplification.deflate.hex"), + "d757b0217a09e3ddb6b327ef23ce4c19e5d6240dd143e025adf6868ff0d91075", + ); + assert_eq!(import_bytes(&bytes).err(), Some(Refusal::Oversized)); +} + +#[test] +fn pptx_review_compressible_text_round_trip() { + let bytes = review_deck( + include_str!( + "../../../fixtures/enterprise-documents/pptx-review/compressible-text.deflate.hex" + ), + "a9aab4eb18bd3274ad86dc5a821938c709753495c3705169f0b80aec3285b94e", + ); + let first = import_bytes(&bytes).unwrap(); + let exported = Pptx.export(&first).unwrap(); + assert_eq!(Pptx.export(&first).unwrap(), exported); + let again = import_bytes(&exported).expect("export obeys the same intake limits"); + assert_eq!(again.canonical, first.canonical); + assert_eq!(again.anchors, first.anchors); +} + +#[test] +fn pptx_review_unbound_relationship_prefix() { + import(&Deck::ordinary()); + let bytes = review_deck( + include_str!("../../../fixtures/enterprise-documents/pptx-review/undeclared-r.deflate.hex"), + "27a310d94d2e43aaa5416c8972ad3047874c25bc1a214ee9609aba7b75e2a6b3", + ); + assert_eq!(import_bytes(&bytes).err(), Some(Refusal::MalformedXml)); + // Empty bindings and undeclared element prefixes are also malformed. + for (from, to) in [ + (format!("xmlns:r=\"{REL}\""), "xmlns:r=\"\""), + ( + "xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\"".to_owned(), + "", + ), + ] { + let deck = Deck::ordinary().edit("ppt/presentation.xml", &from, to); + assert_eq!(import_bytes(&deck.zip()).err(), Some(Refusal::MalformedXml)); + } +} + +#[test] +fn pptx_import_maps_presentation_structure() { + let imported = import(&Deck::ordinary()); + let deck = content(&imported.canonical); + assert_eq!(text(get(&deck, "schema")), pptx::SCHEMA); + assert_eq!(text(get(&deck, "family")), "presentation"); + assert_eq!(int(get(get(&deck, "slide_size"), "cx")), 12_192_000); + + // Deck order follows sldIdLst, not part names or slide ids. + let slides = arr(get(&deck, "slides")); + let ids: Vec = slides.iter().map(|slide| int(get(slide, "id"))).collect(); + assert_eq!(ids, [256, 300, 258]); + let layouts: Vec<&str> = slides.iter().map(|s| text(get(s, "layout"))).collect(); + assert_eq!( + layouts, + ["Title Slide", "Title and Content", "Title and Content"] + ); + let hidden: Vec<&Json> = slides.iter().map(|s| get(s, "hidden")).collect(); + assert_eq!( + hidden, + [&Json::Bool(false), &Json::Bool(false), &Json::Bool(true)] + ); + assert_eq!(text(get(&slides[1], "name")), "Findings"); + let sections: Vec<(&str, Vec)> = arr(get(&deck, "sections")) + .iter() + .map(|s| { + let ids = arr(get(s, "slides")).iter().map(int).collect(); + (text(get(s, "name")), ids) + }) + .collect(); + assert_eq!( + sections, + [("Opening", vec![256]), ("Details", vec![300, 258])] + ); + + // Reading order is shape-tree order, groups included, not shape-id order. + let shapes = arr(get(&slides[1], "shapes")); + let order: Vec<(&str, i64)> = shapes + .iter() + .map(|s| (text(get(s, "kind")), int(get(s, "id")))) + .collect(); + assert_eq!( + order, + [ + ("shape", 7), + ("shape", 4), + ("group", 10), + ("picture", 5), + ("shape", 6), + ("shape", 8) + ] + ); + let children: Vec<(&str, i64)> = arr(get(&shapes[2], "children")) + .iter() + .map(|s| (text(get(s, "kind")), int(get(s, "id")))) + .collect(); + assert_eq!(children, [("shape", 11), ("connector", 12)]); + assert_eq!(get(&shapes[1], "text_box"), &Json::Bool(true)); + assert_eq!( + text(get(get(&shapes[0], "placeholder"), "type")), + "title", + "placeholder kind is kept" + ); + + // Text boxes keep paragraphs, levels, line breaks, fields, and run links. + let paragraphs = arr(get(&shapes[1], "paragraphs")); + assert_eq!( + runs(¶graphs[0]), + ["Revenue grew", "\n", "in every region"] + ); + assert_eq!(int(get(¶graphs[1], "level")), 1); + assert_eq!( + runs(¶graphs[1]), + ["See ", "the report", " on slide ", "2"] + ); + let link = get(&arr(get(¶graphs[1], "runs"))[1], "link"); + assert_eq!( + text(get(link, "url")), + "https://example.invalid/synthetic-report" + ); + + // Shape-level links: a slide jump resolves to the target slide id; an + // action-only link keeps its action and nothing else. + let jump = get(&shapes[4], "link"); + assert_eq!(int(get(jump, "slide")), 258); + assert_eq!(text(get(jump, "action")), "ppaction://hlinksldjump"); + let next = get(&shapes[5], "link"); + assert_eq!(get(next, "url"), &Json::Null); + assert_eq!(get(next, "slide"), &Json::Null); + assert_eq!( + text(get(next, "action")), + "ppaction://hlinkshowjump?jump=nextslide" + ); + + // Alt text and title, and images as content-addressed assets used twice. + let picture = &shapes[3]; + assert_eq!( + text(get(picture, "alt_text")), + "Synthetic bar chart of regional growth" + ); + assert_eq!(text(get(picture, "alt_title")), "Growth chart"); + let assets = arr(get(&deck, "assets")); + assert_eq!(assets.len(), 1, "one image referenced twice is one asset"); + let png = decks::png([37, 99, 235]); + assert_eq!(text(get(&assets[0], "sha256")), sha256_hex(&png)); + assert_eq!(text(get(&assets[0], "media_type")), "image/png"); + assert_eq!(int(get(&assets[0], "byte_length")), png.len() as i64); + let asset_id = text(get(&assets[0], "id")); + assert_eq!(text(get(picture, "image")), asset_id); + let logo = &arr(get(&slides[2], "shapes"))[2]; + assert_eq!(text(get(logo, "image")), asset_id); + + // Tables, notes, and comments. + let table = &arr(get(&slides[2], "shapes"))[1]; + assert_eq!(text(get(table, "kind")), "table"); + assert_eq!(cell_texts(table), [["Region", "Growth"], ["North", "12%"]]); + let columns: Vec = arr(get(table, "columns")).iter().map(int).collect(); + assert_eq!(columns, [3_000_000, 3_000_000]); + let notes = get(&slides[0], "notes"); + assert_eq!(int(get(notes, "shape")), 3); + let note_lines: Vec = arr(get(notes, "paragraphs")) + .iter() + .map(|p| runs(p).concat()) + .collect(); + assert_eq!( + note_lines, + [ + "Open with the regional summary.", + "Keep it under two minutes." + ] + ); + let comments = arr(get(&slides[0], "comments")); + assert_eq!(comments.len(), 1); + assert_eq!(text(get(&comments[0], "author")), "Synthetic Reviewer"); + assert_eq!(text(get(&comments[0], "initials")), "SR"); + assert_eq!( + text(get(&comments[0], "text")), + "Check the subtitle wording." + ); + assert_eq!(get(&slides[1], "notes"), &Json::Null); + + // Anchors: envelope-shaped, sorted, stable ids, and digests of their text. + let anchors = arr(get(&deck, "anchors")); + let anchor_ids: Vec<&str> = anchors.iter().map(|a| text(get(a, "id"))).collect(); + let mut sorted = anchor_ids.clone(); + sorted.sort_unstable(); + assert_eq!(anchor_ids, sorted, "anchors are sorted by id"); + for item in anchors { + assert_eq!(text(get(item, "family")), "presentation"); + assert_eq!( + text(get(item, "text_digest")), + sha256_hex(text(get(item, "text")).as_bytes()) + ); + } + let expected = [ + ( + "anc:pptx:slide-256:shape-2", + "title", + "Synthetic quarterly review", + 1, + 2, + 0, + ), + ( + "anc:pptx:slide-256:shape-3", + "heading", + "Prepared for the fixture team", + 1, + 3, + 1, + ), + ( + "anc:pptx:slide-256:notes", + "notes", + "Open with the regional summary.\nKeep it under two minutes.", + 1, + 3, + 0, + ), + ( + "anc:pptx:slide-256:comment-1", + "notes", + "Check the subtitle wording.", + 1, + 1, + 0, + ), + ( + "anc:pptx:slide-300:shape-11", + "paragraph", + "Grouped label", + 2, + 11, + 3, + ), + ( + "anc:pptx:slide-300:shape-5:alt", + "shape", + "Synthetic bar chart of regional growth", + 2, + 5, + 5, + ), + ( + "anc:pptx:slide-258:shape-3", + "table", + "Region\tGrowth\nNorth\t12%", + 3, + 3, + 1, + ), + ( + "anc:pptx:slide-258:shape-3:alt", + "shape", + "Growth by region", + 3, + 3, + 1, + ), + ]; + for (id, role, body, slide, shape, z) in expected { + let item = anchor(&deck, id); + assert_eq!(text(get(item, "role")), role, "{id}"); + assert_eq!(text(get(item, "text")), body, "{id}"); + let locator = get(item, "locator"); + assert_eq!( + ( + int(get(locator, "slide")), + int(get(locator, "shape")), + int(get(locator, "z_index")) + ), + (slide, shape, z), + "{id}" + ); + } + let harness: Vec<(&str, &str)> = imported + .anchors + .iter() + .map(|a| (a.id.as_str(), a.text_digest.as_str())) + .collect(); + let from_content: Vec<(&str, &str)> = anchors + .iter() + .map(|a| (text(get(a, "id")), text(get(a, "text_digest")))) + .collect(); + assert_eq!(harness, from_content); + + // Normalizations and omissions are receipted, never silent. + for code in [ + "hyperlink-not-fetched", + "field-as-text", + "text-formatting-normalized", + "shape-styling-normalized", + "layout-design-normalized", + "table-formatting-normalized", + "comment-metadata-normalized", + "unmapped-parts-omitted", + ] { + assert!(has(&imported, code), "receipt is missing {code}"); + } + let fidelity = get(&deck, "fidelity"); + for (feature, status) in [ + ("animations", "unsupported"), + ("macros", "unsupported"), + ("slides", "exact"), + ("z_order", "exact"), + ] { + assert_eq!(text(get(fidelity, feature)), status, "{feature}"); + } +} + +#[test] +fn pptx_import_ignores_archive_order_timestamps_and_compression() { + let deck = Deck::ordinary(); + let first = import_bytes(&deck.zip()).unwrap(); + for bytes in [ + deck.zip_with(true, false, 0x4A21), + deck.zip_with(false, false, 0x3C01), + Deck(deck.0.iter().rev().cloned().collect()).zip(), + ] { + assert_eq!(import_bytes(&bytes).unwrap(), first); + } +} + +#[test] +fn pptx_ordinary_projection_is_recorded() { + let imported = import(&Deck::ordinary()); + assert_eq!(sha256_hex(&imported.canonical), ORDINARY_DIGEST); +} + +#[test] +fn pptx_unsupported_features_are_receipted() { + let imported = import(&decks::unsupported_benign()); + for code in decks::BENIGN_CODES { + assert!(has(&imported, code), "receipt is missing {code}"); + } + let deck = content(&imported.canonical); + let shapes = arr(get(&arr(get(&deck, "slides"))[1], "shapes")); + let ids: Vec = shapes.iter().map(|s| int(get(s, "id"))).collect(); + assert!( + !ids.contains(&20) && !ids.contains(&21), + "charts and SmartArt are not projected" + ); + let fallback = shapes.iter().find(|s| int(get(s, "id")) == 22).unwrap(); + assert_eq!(text(get(fallback, "name")), "Fallback text"); + let video = shapes.iter().find(|s| int(get(s, "id")) == 23).unwrap(); + assert_eq!( + text(get(video, "kind")), + "picture", + "the poster frame stays" + ); + assert!(!String::from_utf8_lossy(&imported.canonical).contains("Choice text")); +} + +#[test] +fn pptx_adversarial_decks_are_refused() { + use Refusal::*; + let o = Deck::ordinary; + let image = format!("{REL}/image"); + let nested = |levels: usize| { + let open = ""; + format!("{}{}", open.repeat(levels), "".repeat(levels)) + }; + let rows: Vec<(&str, Vec, Refusal)> = vec![ + ( + "external-media", + o().relate(RELS3, "rId9", &format!("{REL}/video"), "https://example.invalid/remote.mp4", true) + .zip(), + ExternalReference, + ), + ( + "external-linked-image", + o().shapes(SLIDE3, "") + .relate(RELS3, "rId9", &image, "https://example.invalid/remote.png", true) + .zip(), + ExternalReference, + ), + ( + "macro-enabled-deck", + o().edit( + "[Content_Types].xml", + "presentationml.presentation.main+xml", + "ms-powerpoint.presentation.macroEnabled.main+xml", + ) + .add("ppt/vbaProject.bin", "synthetic inert bytes") + .content_type("ppt/vbaProject.bin", "application/vnd.ms-office.vbaProject") + .zip(), + ActiveContent, + ), + ( + "run-program-action", + o().edit(SLIDE3, "ppaction://hlinksldjump", "ppaction://program").zip(), + ActiveContent, + ), + ( + "run-macro-action", + o().edit(SLIDE3, "ppaction://hlinkshowjump?jump=nextslide", "ppaction://macro?name=Synthetic") + .zip(), + ActiveContent, + ), + ( + "hover-program-action", + o().edit( + SLIDE3, + "", + "", + ) + .zip(), + ActiveContent, + ), + ( + "embedded-package", + o().shapes(SLIDE3, "") + .relate(RELS3, "rId9", &format!("{REL}/package"), "../embeddings/sheet1.xlsx", false) + .add("ppt/embeddings/sheet1.xlsx", "synthetic inert bytes") + .content_type( + "ppt/embeddings/sheet1.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + .zip(), + ActiveContent, + ), + ( + "activex-control", + o().edit( + SLIDE3, + "", + "", + ) + .relate(RELS3, "rId9", &format!("{REL}/control"), "../activeX/activeX1.xml", false) + .add("ppt/activeX/activeX1.xml", "") + .content_type("ppt/activeX/activeX1.xml", "application/vnd.ms-office.activeX+xml") + .zip(), + ActiveContent, + ), + ( + "relationship-path-escape", + o().relate(RELS3, "rId9", &image, "../../../escape.png", false).zip(), + UnsafePartPath, + ), + ("zip-path-escape", o().add("../escape.xml", "").zip(), UnsafePartPath), + ( + "decompression-limit", + o().add("ppt/media/huge.png", vec![0u8; 8 * 1024 * 1024]).zip(), + DecompressionLimit, + ), + ( + "relationship-without-target", + o().edit(RELS3, " Target=\"slide2.xml\"", "").zip(), + MalformedContainer, + ), + ( + "duplicate-relationship-id", + o().edit(RELS3, "Id=\"rId4\"", "Id=\"rId3\"").zip(), + MalformedContainer, + ), + ( + "dangling-slide-relationship", + o().edit("ppt/presentation.xml", "r:id=\"rId4\"", "r:id=\"rId99\"").zip(), + MalformedContainer, + ), + ( + "slide-relationship-to-theme", + o().edit("ppt/presentation.xml", "r:id=\"rId4\"", "r:id=\"rId5\"").zip(), + MalformedContainer, + ), + ( + "duplicate-slide-id", + o().edit("ppt/presentation.xml", "id=\"258\"", "id=\"256\"").zip(), + MalformedContainer, + ), + ( + "slide-listed-twice", + o().edit("ppt/presentation.xml", "r:id=\"rId4\"", "r:id=\"rId2\"").zip(), + MalformedContainer, + ), + ( + "missing-slide-part", + o().remove("ppt/slides/slide2.xml").zip(), + MalformedContainer, + ), + ( + "dangling-link-relationship", + o().edit(SLIDE3, "r:id=\"rId3\"", "r:id=\"rId42\"").zip(), + MalformedContainer, + ), + ( + "no-main-part", + o().edit("_rels/.rels", "relationships/officeDocument", "relationships/other") + .zip(), + MalformedContainer, + ), + ( + "not-a-presentation", + o().edit( + "[Content_Types].xml", + "presentationml.presentation.main+xml", + "wordprocessingml.document.main+xml", + ) + .zip(), + UnsupportedContainer, + ), + ("malformed-slide-xml", o().edit(SLIDE3, "", "").zip(), MalformedXml), + ( + "slide-id-out-of-range", + o().edit("ppt/presentation.xml", "id=\"258\"", "id=\"12\"").zip(), + MalformedXml, + ), + ("deep-group-nesting", o().shapes(SLIDE3, &nested(100)).zip(), XmlDepthLimit), + ("deeper-group-nesting", o().shapes(SLIDE3, &nested(300)).zip(), XmlDepthLimit), + ]; + for (name, bytes, refusal) in rows { + conformance::check( + &Pptx, + &bytes, + &[], + &Expect::Refuse(refusal), + &Limits::default(), + &Cancel::default(), + ) + .unwrap_or_else(|error| panic!("{name}: {error}")); + } +} + +#[test] +fn pptx_round_trip_preserves_structure() { + for deck in [Deck::ordinary(), decks::unsupported_benign()] { + let first = import(&deck); + let exported = Pptx.export(&first).expect("export"); + assert_eq!( + Pptx.export(&first).unwrap(), + exported, + "export is deterministic" + ); + let again = import_bytes(&exported).expect("re-import of the export"); + // Canonical content excludes package inventory and receipts, so equal + // bytes mean slide order, ids, sections, layouts, shapes, text, tables, + // notes, comments, alt text, links, assets, and anchors all survived. + assert_eq!( + String::from_utf8(again.canonical.clone()).unwrap(), + String::from_utf8(first.canonical.clone()).unwrap() + ); + assert_eq!(again.anchors, first.anchors); + let third = import_bytes(&Pptx.export(&again).unwrap()).unwrap(); + assert_eq!(third.canonical, first.canonical); + } +} + +#[test] +fn pptx_export_carries_fidelity_receipt() { + let first = import(&Deck::ordinary()); + let exported = Pptx.export(&first).unwrap(); + let package = opc::open(&exported, &Limits::default(), &Cancel::default()).unwrap(); + let part = package + .parts + .iter() + .find(|part| part.name == pptx::RECEIPT_PART) + .expect("receipt part"); + assert_eq!(part.content_type, "application/json"); + assert!(package.relationships.iter().any(|r| r.source.is_empty() + && r.kind == pptx::RECEIPT_RELATIONSHIP + && r.target == pptx::RECEIPT_PART)); + let receipt = content(&part.data); + assert_eq!( + text(get(&receipt, "content_sha256")), + sha256_hex(&first.canonical) + ); + let codes: Vec<&str> = arr(get(&receipt, "import_receipt")) + .iter() + .map(text) + .collect(); + assert_eq!(codes, first.receipt); + let export_codes: Vec<&str> = arr(get(&receipt, "export_receipt")) + .iter() + .map(text) + .collect(); + assert!(export_codes.contains(&"generic-master-layout-theme")); + assert!(export_codes.contains(&"comments-written-legacy")); + assert_eq!( + get(&receipt, "fidelity"), + get(&content(&first.canonical), "fidelity") + ); + + // Written through the shared gate: explicit format, contained destination, + // never overwriting. + let root = std::env::temp_dir().join(format!("bran-pptx-export-{}", std::process::id())); + std::fs::create_dir_all(root.join("out")).unwrap(); + let written = export::write_new(&root, "out/deck.pptx", Format::Pptx, &exported, &[]).unwrap(); + assert_eq!(std::fs::read(&written).unwrap(), exported); + assert_eq!( + export::write_new(&root, "out/deck.pptx", Format::Pptx, &exported, &[]), + Err(Refusal::ExportExists) + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn pptx_export_refuses_dlp_before_writing() { + // A canary split across two runs: the shared byte scan cannot see it, the + // adapter's text scan must. + let (head, tail) = canary().split_at(canary().len() / 2); + let split = Deck::ordinary() + .edit( + "ppt/slides/slide1.xml", + "fixture team", + &format!("{head}{tail}"), + ) + .zip(); + let intake = PackageIntake(Format::Pptx) + .import(&split, &Limits::default(), &Cancel::default()) + .unwrap(); + assert!(!has(&intake, "dlp-findings")); + let imported = import_bytes(&split).unwrap(); + assert!(has(&imported, "dlp-findings")); + assert_eq!(Pptx.export(&imported), Err(Refusal::DlpFindings)); + let mut stripped = imported.clone(); + stripped.receipt.retain(|code| code != "dlp-findings"); + assert_eq!( + Pptx.export(&stripped), + Err(Refusal::DlpFindings), + "export scans what it would emit, not only the receipt" + ); + + let marked = Deck::ordinary().edit( + "ppt/notesSlides/notesSlide1.xml", + "Keep it under two minutes.", + "important_boundary", + ); + let imported = import(&marked); + assert!(has(&imported, "public-boundary-violation")); + assert_eq!(Pptx.export(&imported), Err(Refusal::PublicBoundary)); + let mut stripped = imported.clone(); + stripped + .receipt + .retain(|code| code != "public-boundary-violation"); + assert_eq!(Pptx.export(&stripped), Err(Refusal::PublicBoundary)); +} + +#[test] +fn pptx_export_refuses_malformed_or_foreign_content() { + let first = import(&Deck::ordinary()); + let mut broken = first.clone(); + broken.canonical.truncate(broken.canonical.len() / 2); + assert_eq!(Pptx.export(&broken), Err(Refusal::MalformedContainer)); + let mut foreign = first.clone(); + foreign.canonical = b"{\"schema\":\"other\"}".to_vec(); + assert_eq!(Pptx.export(&foreign), Err(Refusal::ExportUnsupported)); + // A shape without an identity cannot be projected faithfully. + let anonymous = Deck::ordinary().edit( + SLIDE3, + "", + "", + ); + let imported = import(&anonymous); + assert!(has(&imported, "shape-identity-missing")); + assert_eq!(Pptx.export(&imported), Err(Refusal::ExportUnsupported)); +} + +#[test] +fn pptx_mutated_decks_never_panic() { + let seeds = [Deck::ordinary(), decks::unsupported_benign()]; + let mut state = 0x5EED_0022_u64; + let mut next = move |bound: usize| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state % bound as u64) as usize + }; + let mut admitted = 0; + // 240 rounds keep this near 5 s in a debug build; each round imports, + // exports, and re-imports a deck. + for round in 0..240 { + let deck = &seeds[round % seeds.len()]; + let bytes = if round % 3 == 0 { + // Container-level damage. + let mut bytes = deck.zip(); + let at = next(bytes.len()); + bytes[at] ^= 1 << next(8); + bytes + } else { + // Content-level damage inside one XML part, re-zipped cleanly. + let mut deck = deck.clone(); + let xml: Vec = (0..deck.0.len()) + .filter(|&i| deck.0[i].0.ends_with(".xml")) + .collect(); + let data = &mut deck.0[xml[next(xml.len())]].1; + let at = next(data.len()); + let len = next(48).min(data.len() - at); + if round % 2 == 0 { + data.drain(at..at + len); + } else { + let copy = data[at..at + len].to_vec(); + data.splice(at..at, copy); + } + deck.zip() + }; + let imported = catch_unwind(AssertUnwindSafe(|| import_bytes(&bytes))) + .unwrap_or_else(|_| panic!("round {round}: import panicked")); + let Ok(imported) = imported else { continue }; + admitted += 1; + let exported = catch_unwind(AssertUnwindSafe(|| Pptx.export(&imported))) + .unwrap_or_else(|_| panic!("round {round}: export panicked")); + if let Ok(exported) = exported { + let again = import_bytes(&exported) + .unwrap_or_else(|refusal| panic!("round {round}: re-import refused: {refusal}")); + assert_eq!( + again.anchors, imported.anchors, + "round {round}: anchors drifted" + ); + } + } + assert!(admitted > 0, "some mutations must stay admissible"); +} + +/// Rewrites every relationship attribute prefix in one slide part. +fn rebind(deck: Deck, part: &str, declaration: &str, prefix: &str) -> Deck { + let mut deck = deck; + let data = &mut deck.0.iter_mut().find(|(name, _)| name == part).unwrap().1; + let text = String::from_utf8(data.clone()).unwrap(); + let text = text + .replace(&format!("xmlns:r=\"{REL}\""), declaration) + .replace(" r:id=", &format!(" {prefix}:id=")) + .replace(" r:embed=", &format!(" {prefix}:embed=")); + *data = text.into_bytes(); + deck +} + +#[test] +fn pptx_relationship_attributes_resolve_by_namespace() { + let ordinary = import(&Deck::ordinary()); + // Another prefix bound to the relationship namespace reads the same. + let renamed = rebind( + Deck::ordinary(), + SLIDE3, + &format!("xmlns:rel=\"{REL}\""), + "rel", + ); + let renamed = import(&renamed); + assert_eq!(renamed.canonical, ordinary.canonical); + // `r` bound to a foreign namespace is not a relationship attribute: the + // picture has no image and the links name no relationship. + let foreign = rebind( + Deck::ordinary(), + SLIDE3, + "xmlns:r=\"urn:example:not-relationships\"", + "r", + ); + let deck = content(&import(&foreign).canonical); + let shapes = arr(get(&arr(get(&deck, "slides"))[1], "shapes")); + assert_eq!(get(&shapes[3], "image"), &Json::Null); + assert_eq!(get(get(&shapes[4], "link"), "slide"), &Json::Null); +} + +/// Opt-in: exports the synthetic decks and opens them in independent readers. +/// Set `BRAN_PPTX_READER_DIR` to a directory the readers may use (a snap +/// LibreOffice needs a non-hidden directory under the home directory) and +/// `BRAN_PPTX_PYTHON` to an interpreter with python-pptx. A missing reader is +/// reported as unavailable, never as passing. +#[test] +#[ignore = "opt-in reader check; needs LibreOffice and python-pptx"] +fn pptx_export_opens_in_independent_readers() { + let dir = std::env::var_os("BRAN_PPTX_READER_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("bran-pptx-readers")); + std::fs::create_dir_all(&dir).unwrap(); + // The synthetic source deck is opened too, so reader output can be + // compared with what the adapter imported. + let mut paths = Vec::new(); + for (name, deck) in [ + ("ordinary", Deck::ordinary()), + ("unsupported-benign", decks::unsupported_benign()), + ] { + let exported = Pptx.export(&import(&deck)).unwrap(); + for (file, bytes) in [ + (format!("{name}-export.pptx"), exported), + (format!("{name}-source.pptx"), deck.zip()), + ] { + let _ = std::fs::remove_file(dir.join(&file)); + paths.push(export::write_new(&dir, &file, Format::Pptx, &bytes, &[]).unwrap()); + } + } + let mut opened = Vec::new(); + + let python = std::env::var("BRAN_PPTX_PYTHON").unwrap_or_else(|_| "python3".to_owned()); + let script = "import hashlib, sys\nfrom pptx import Presentation\nfor path in sys.argv[1:]:\n deck = Presentation(path)\n print('deck', path.rsplit('/', 1)[-1], len(deck.slides))\n for slide in deck.slides:\n print(' slide', slide.slide_id, slide.slide_layout.name)\n for shape in slide.shapes:\n descr = shape._element.xpath('./*[1]/p:cNvPr/@descr')\n text = shape.text_frame.text.replace('\\n', ' | ') if shape.has_text_frame else ''\n kind = shape._element.tag.rsplit('}', 1)[-1]\n image = hashlib.sha256(shape.image.blob).hexdigest()[:16] if hasattr(type(shape), 'image') else ''\n print(' shape', shape.shape_id, kind, repr(text), descr, image)\n if slide.has_notes_slide:\n print(' notes', repr(slide.notes_slide.notes_text_frame.text))\n"; + match Command::new(&python) + .arg("-c") + .arg(script) + .args(&paths) + .output() + { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + println!("python-pptx:\n{stdout}"); + for expected in [ + "slide 256", + "slide 300", + "slide 258", + "Findings", + "Region", + "Keep it under two minutes.", + ] { + assert!( + stdout.contains(expected), + "python-pptx output lacks {expected}" + ); + } + opened.push("python-pptx"); + } + Ok(out) if String::from_utf8_lossy(&out.stderr).contains("No module named 'pptx'") => { + println!("unavailable: python-pptx is not installed for {python}") + } + Ok(out) => panic!( + "python-pptx failed to open an export: {}", + String::from_utf8_lossy(&out.stderr) + ), + Err(error) => println!("unavailable: {python}: {error}"), + } + + // A private profile keeps this run apart from any other LibreOffice + // instance; otherwise a second instance can exit 0 without converting. + let profile = format!( + "-env:UserInstallation=file://{}", + dir.join("lo-profile").display() + ); + let mut converted = 0; + for path in &paths { + let out = match Command::new("libreoffice") + .arg(&profile) + .args(["--headless", "--convert-to", "pdf", "--outdir"]) + .arg(&dir) + .arg(path) + .output() + { + Ok(out) => out, + Err(error) => { + println!("unavailable: libreoffice: {error}"); + break; + } + }; + let pdf = path.with_extension("pdf"); + let bytes = std::fs::read(&pdf).unwrap_or_default(); + assert!( + out.status.success() && bytes.starts_with(b"%PDF"), + "LibreOffice did not convert {}: {}{}", + path.display(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + if let Ok(text) = Command::new("pdftotext").arg(&pdf).arg("-").output() { + let text = String::from_utf8_lossy(&text.stdout); + println!("LibreOffice PDF text ({}):\n{text}", pdf.display()); + } + converted += 1; + } + if converted == paths.len() { + opened.push("LibreOffice"); + } + println!("readers that opened every export: {opened:?}"); +} diff --git a/crates/bran-document/tests/pptx/decks.rs b/crates/bran-document/tests/pptx/decks.rs new file mode 100644 index 0000000..5c3b552 --- /dev/null +++ b/crates/bran-document/tests/pptx/decks.rs @@ -0,0 +1,224 @@ +//! Synthetic PPTX decks for the PPTX adapter tests and the shared corpus. +//! +//! The deck text lives in the reviewable `.parts` fixtures. The one binary +//! part, a small PNG, is generated here because fixtures must stay text. +#![allow(dead_code)] + +use bran_document::conformance::Expect; +use bran_document::zip::{self, WriteEntry}; +use std::io::Write; + +const ORDINARY: &str = + include_str!("../../../../fixtures/enterprise-documents/conformance/pptx-ordinary.parts"); +const SLIDES: &str = + include_str!("../../../../fixtures/enterprise-documents/conformance/pptx-slides.parts"); +const DESIGN: &str = + include_str!("../../../../fixtures/enterprise-documents/conformance/pptx-design.parts"); +pub const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// Receipt codes the unsupported-but-benign deck must carry. +pub const BENIGN_CODES: [&str; 6] = [ + "alternate-content-fallback", + "unsupported-animation", + "unsupported-chart", + "unsupported-media", + "unsupported-smartart", + "unsupported-transition", +]; + +/// Ordered ZIP entries built from `.parts` text. +#[derive(Clone)] +pub struct Deck(pub Vec<(String, Vec)>); + +impl Deck { + pub fn parse(texts: &[&str]) -> Self { + let mut parts: Vec<(String, Vec)> = Vec::new(); + for line in texts.iter().flat_map(|text| 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()); + } + } + Self(parts) + } + + /// Three slides with layouts, sections, a text box, a group, a table, + /// notes, a comment, alt text, links, and one image used twice. + pub fn ordinary() -> Self { + Self::parse(&[ORDINARY, SLIDES, DESIGN]).add("ppt/media/image1.png", png([37, 99, 235])) + } + + pub fn add(mut self, name: &str, data: impl Into>) -> Self { + self.0.push((name.to_owned(), data.into())); + self + } + + pub fn remove(mut self, name: &str) -> Self { + self.0.retain(|(part, _)| part != name); + self + } + + pub fn edit(mut self, name: &str, from: &str, to: &str) -> Self { + let part = self + .0 + .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(); + self + } + + /// Adds a relationship to an existing `.rels` part. + pub fn relate(self, rels: &str, id: &str, kind: &str, target: &str, external: bool) -> Self { + let mode = if external { + " TargetMode=\"External\"" + } else { + "" + }; + let line = format!( + "\n" + ); + self.edit(rels, "", &line) + } + + pub fn content_type(self, part: &str, content_type: &str) -> Self { + let line = + format!("\n"); + self.edit("[Content_Types].xml", "", &line) + } + + /// Inserts shapes at the end of a slide's shape tree. + pub fn shapes(self, slide: &str, xml: &str) -> Self { + self.edit(slide, "", &format!("{xml}")) + } + + pub fn zip_with(&self, reverse: bool, deflate: bool, dos_date: u16) -> Vec { + let mut entries: Vec> = self + .0 + .iter() + .map(|(name, data)| WriteEntry { + name, + data, + deflate, + dos_time: 0x6000, + dos_date, + }) + .collect(); + if reverse { + entries.reverse(); + } + zip::write(&entries) + } + + pub fn zip(&self) -> Vec { + self.zip_with(false, true, 0x5921) + } +} + +/// The ordinary deck plus a transition, an animation, a chart, SmartArt, a +/// video, and alternate content. None of them may disappear silently. +pub fn unsupported_benign() -> Deck { + let frame = |id: u32, uri: &str, body: &str| { + format!( + "{body}" + ) + }; + let text_shape = |id: u32, text: &str| { + format!( + "{text}" + ) + }; + let shapes = [ + frame( + 20, + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "", + ), + frame( + 21, + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "", + ), + format!( + "{}{}", + text_shape(22, "Choice text"), + text_shape(22, "Fallback text") + ), + "".to_owned(), + ] + .concat(); + Deck::ordinary() + .edit( + "ppt/slides/slide1.xml", + "", + "", + ) + .shapes("ppt/slides/slide3.xml", &shapes) + .relate( + "ppt/slides/_rels/slide3.xml.rels", + "rId5", + &format!("{REL}/chart"), + "../charts/chart1.xml", + false, + ) + .relate( + "ppt/slides/_rels/slide3.xml.rels", + "rId6", + &format!("{REL}/video"), + "../media/media1.mp4", + false, + ) + .add( + "ppt/charts/chart1.xml", + "", + ) + .content_type( + "ppt/charts/chart1.xml", + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + ) + .add("ppt/media/media1.mp4", "synthetic inert video bytes") + .content_type("ppt/media/media1.mp4", "video/mp4") +} + +/// Executable PPTX rows of the shared corpus. +pub fn row(row: &str) -> Option<(Vec, Expect)> { + match row { + "pptx-ordinary-projection" => Some(( + Deck::ordinary().zip(), + Expect::Admit(vec!["hyperlink-not-fetched", "field-as-text"]), + )), + "pptx-unsupported-benign-fidelity" => Some(( + unsupported_benign().zip(), + Expect::Admit(BENIGN_CODES.to_vec()), + )), + "pptx-round-trip-anchors" => Some((Deck::ordinary().zip(), Expect::Admit(vec![]))), + _ => None, + } +} + +/// A valid one-pixel RGB PNG. +pub fn png(rgb: [u8; 3]) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let start = out.len(); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let crc = crc32fast::hash(&out[start..]); + out.extend_from_slice(&crc.to_be_bytes()); + } + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&[0, rgb[0], rgb[1], rgb[2]]).unwrap(); + let pixels = encoder.finish().unwrap(); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut out, b"IHDR", &[0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]); + chunk(&mut out, b"IDAT", &pixels); + chunk(&mut out, b"IEND", &[]); + out +} diff --git a/docs/enterprise-document-conformance.md b/docs/enterprise-document-conformance.md index 511210f..3819beb 100644 --- a/docs/enterprise-document-conformance.md +++ b/docs/enterprise-document-conformance.md @@ -52,9 +52,14 @@ 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 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. +[`enterprise-document-docx-adapter.md`](enterprise-document-docx-adapter.md)), +the XLSX adapter (#26, +[`enterprise-document-xlsx.md`](enterprise-document-xlsx.md)), the PPTX +adapter (#22, +[`enterprise-document-pptx-adapter.md`](enterprise-document-pptx-adapter.md)), +and the PDF adapter (#23, +[`enterprise-document-pdf-adapter.md`](enterprise-document-pdf-adapter.md)) +are registered. For each row, `conformance::check` verifies: @@ -109,7 +114,8 @@ 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. The DOCX rows are executable: +issue; it never counts them as passing. All four adapters are registered, so +every row below is executable. The DOCX rows: `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 @@ -119,7 +125,8 @@ anchor. | Row | Needs | |---|---| | `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-ordinary-projection`, `xlsx-unsupported-benign-fidelity`, `xlsx-round-trip-anchors` | #26 (executable) | +| `pptx-ordinary-projection`, `pptx-unsupported-benign-fidelity`, `pptx-round-trip-anchors` | #22 (executable) | XLSX adapter rows run now against the registered adapter: @@ -129,6 +136,17 @@ XLSX adapter rows run now against the registered adapter: | `xlsx-unsupported-benign-fidelity` | `xlsx-features.parts` is admitted, re-encoding invariant, with `unsupported-chart`, `unsupported-image`, `unsupported-drawing`, `unsupported-conditional-formatting`, `rich-text-flattened`, `formula-cached-result-not-recalculated`, and `hyperlink-not-fetched` | | `xlsx-round-trip-anchors` | `xlsx-features.parts` exports, re-imports with identical anchors, and exports the same bytes twice (`round_trip: true`) | +The PPTX adapter (#22) rows are executable. They are built from +`pptx-ordinary.parts`, `pptx-slides.parts`, and `pptx-design.parts` plus one +generated PNG; see +[`enterprise-document-pptx-adapter.md`](enterprise-document-pptx-adapter.md). + +| Row | Expected outcome | +|---|---| +| `pptx-ordinary-projection` | admitted with `hyperlink-not-fetched` and `field-as-text`; identical across re-encodings | +| `pptx-unsupported-benign-fidelity` | admitted with `unsupported-transition`, `unsupported-animation`, `unsupported-chart`, `unsupported-smartart`, `unsupported-media`, `alternate-content-fallback` | +| `pptx-round-trip-anchors` | admitted; export succeeds and re-import keeps every anchor | + The PDF adapter (#23) is registered, so its rows are executable: 27 rows, including `pdf-ordinary-projection`, `pdf-malformed-object-graph`, `pdf-recursive-structure`, `pdf-active-action`, `pdf-embedded-file`, @@ -192,10 +210,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`; `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 | +| 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`; `pdf-ordinary-projection` recorded digest | done for packages and all four adapters | +| Archive order, timestamps, producer differences do not change the result | check step 4 on every admitted row | done for packages, DOCX, and PPTX | +| 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, PPTX, and PDF content | +| Citation anchors survive deterministic round trips | check step 5; `harness_rejects_round_trip_anchor_drift`; `docx-round-trip-anchors`; `xlsx-round-trip-anchors`; `pptx-round-trip-anchors`; `pdf-round-trip-anchors` | done for DOCX, XLSX, PPTX, and PDF | | 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 | @@ -232,6 +250,7 @@ not use. | 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 | +| Measured on 2026-09-30 (debug build, PPTX adapter registered) | 1.0 s, 162 checks | 28.6 s, 162 checks | | Fixture file size | 8 KiB each | same files | The budgets are constants at the top of the test file. A tier that runs @@ -254,17 +273,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 | 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 | +| Text, structure, tables, lists | exact text; normalized headings, lists, tables, sections | exact values and tables; rich text and styles normalized | normalized (text, levels, breaks, table cells; formatting dropped) | 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 | normalized (notes text and links; comment author, initials, text) | annotations normalized | +| Hyperlinks and bookmarks | exact (external targets recorded, never fetched) | exact (external targets recorded, never fetched; unsafe targets quarantined) | recorded, never fetched | outline exact; hyperlinks recorded, never fetched | +| Fields and content controls | normalized (cached result kept, code dropped, never evaluated) | n/a | normalized (a field is kept as its current text) | n/a | +| Headers and footers | unsupported | unsupported (`layout-not-imported`) | normalized (header/footer placeholder text kept as shape text) | n/a | | Formulas and cached values | n/a | text exact, kept apart, never recalculated; active formulas quarantined | n/a | 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 | +| Images and media | exact bytes, content-addressed; floating images normalized to inline | unsupported, typed relationship with digest | images exact (content-addressed); audio and video unsupported | image locators approximated; pixels not decoded | +| Charts, SmartArt, animations | unsupported | unsupported, typed relationship with digest | unsupported (receipted) | 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 | 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 | +| Export | deterministic DOCX with fidelity receipt | deterministic, with fidelity receipt; DLP first | normalized (generic master, layouts, theme; fidelity receipt part) | tagged PDF; active content removed; no PDF/A or PDF/UA claim | ## Acceptance status for #25 @@ -272,11 +291,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 | DOCX and XLSX done; PPTX not done | adapter rows; #22 | +| Fixture matrix, unsupported-benign fidelity | done | adapter rows (#21, #22, #26) | | Required properties | see table above | | -| CLI read-only inspection before export | not done | needs adapter output to inspect; #21, #22, #23, #26 | +| CLI read-only inspection before export | not done | adapter imports exist for all four formats; CLI wiring not built | | 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, DOCX, XLSX, and PDF | content rows pending #22 | +| Compatibility table | done for package features and all four adapters | | diff --git a/docs/enterprise-document-pptx-adapter.md b/docs/enterprise-document-pptx-adapter.md new file mode 100644 index 0000000..a968c9a --- /dev/null +++ b/docs/enterprise-document-pptx-adapter.md @@ -0,0 +1,223 @@ +--- +type: Concept +title: Enterprise-document PPTX adapter +okf_status: active +tags: + - public + - developer +freshness: "2026-09-30" +resource: https://github.com/alphazede/bran +public_boundary: public +--- + +# Enterprise-document PPTX adapter + +This is the native PPTX adapter for +[issue #22](https://github.com/alphazede/bran/issues/22). It is +`bran_document::pptx` and is registered with the shared conformance suite +described in [`enterprise-document-conformance.md`](enterprise-document-conformance.md). +It adds no dependency: it reads packages through the shared OPC intake and +the shared `quick-xml` reader selected in +[`enterprise-document-dependency-review.md`](enterprise-document-dependency-review.md). +No PowerPoint installation, cloud service, or network access is needed. + +## Boundary + +Import calls `opc::open` first, so ZIP safety, part names, duplicate parts, +budgets, DTD refusal, macro and ActiveX parts, external references, and the +package DLP scan are decided once for every OOXML format. The adapter then +adds PPTX rules: + +| Input | Outcome | +|---|---| +| Any relationship of type `package`, `oleObject`, or `control`; any `oleObj` or `control` element | `active-content` | +| Click or hover action `ppaction://program`, `ppaction://macro`, or `ppaction://ole` | `active-content` | +| No main `officeDocument` part | `malformed-container` | +| Main part that is not a presentation, slide show, or template | `unsupported-container` | +| Slide list entry whose relationship is missing, external, not a slide, points at a missing part, or repeats a part or slide id | `malformed-container` | +| Slide id outside 256 to 2147483647, or a non-numeric shape id | `malformed-xml` | +| Run or shape link whose relationship id does not exist | `malformed-container` | +| Groups nested more than 64 deep | `xml-depth-limit` | +| Relationship expansion, projected text copies, or anchors exhaust the cumulative `max_total_bytes` budget | `oversized` | +| Repeated XML traversal or projection exhausts the cumulative `max_xml_nodes` budget | `xml-node-limit` | +| Unbound element or attribute prefix, including an empty prefix binding | `malformed-xml` | + +Nothing is fetched, executed, or played. External hyperlinks are recorded as +text and receipted as `hyperlink-not-fetched` by the intake. + +The shared reader keeps attribute prefixes as written. The adapter resolves +relationship attributes (`id`, `embed`, `link`) by namespace, not by prefix: +any prefix bound to the Transitional or Strict relationship namespace reads as +`r:`, and `r` bound to another namespace is not a relationship attribute. A +producer's prefix choice therefore cannot hide a link or an image. +The shared reader rejects unbound prefixes in every XML part. Projection +budgets count each traversal of a shared target and reserve text and anchor +copies before materializing them; ZIP size alone does not bound this work. + +## Canonical content + +`import` returns the harness `Imported` value. Its canonical bytes are +canonical JSON (the envelope rule) tagged `bran.pptx.content/1`: + +| Key | Content | +|---|---| +| `slides` | In `sldIdLst` order: slide id, cSld name, layout name, hidden flag, shapes, notes, comments | +| `sections` | Section name, section id, and slide ids | +| `slide_size` | `cx`, `cy` | +| shapes | In shape-tree order (reading and z-order), groups nested: kind (`shape`, `picture`, `group`, `table`, `connector`), id, name, alt text (`descr`), alt title, hidden, placeholder type and index, click link, preset geometry, position and size | +| text | Paragraphs with level and runs; a line break is a run of `\n`; a field becomes its text | +| links | External URL, target slide id, or action; never fetched | +| tables | Column widths, row heights, and cell paragraphs | +| notes | Notes body placeholder id and paragraphs | +| comments | Author, initials, and text; legacy and modern comments, threads flattened | +| `assets` | Images by SHA-256, with media type, length, and bytes (hex); one image used twice is one asset | +| `anchors` | Envelope-shaped citation anchors, sorted by id | +| `fidelity` | The seven presentation features of the envelope | + +The canonical content excludes ZIP order, timestamps, compression, and the +package inventory, so re-encodings of one deck give identical bytes. + +## Anchors + +Each anchor has the envelope shape: `id`, `family` `presentation`, `role`, +`text`, `text_digest` (SHA-256 of the text), and a locator with `slide` +(position), `shape`, and `z_index`. + +| Anchor id | Role | Text | +|---|---|---| +| `anc:pptx:slide-:shape-` | `title` for title placeholders, `heading` for subtitles, `table` for tables, otherwise `paragraph` | shape paragraphs joined by line breaks; table cells joined by tabs and rows | +| `…:shape-:alt` | `shape` | alt text | +| `anc:pptx:slide-:notes` | `notes` | speaker notes | +| `anc:pptx:slide-:comment-` | `notes` | comment text (the envelope has no comment role) | + +Slide ids and shape ids come from the deck, so anchors survive slide +reordering and re-export. A repeated shape id on one slide gets a `-z` +suffix and the `duplicate-shape-id` receipt code. + +## Receipt codes + +The receipt is the intake's codes plus these. A code appears only when the +deck contained that feature. + +| Code | Meaning | +|---|---| +| `text-formatting-normalized` | run formatting (font, size, colour, language) is not kept | +| `shape-styling-normalized` | fills, lines, effects, rotation, and flips are not kept | +| `layout-design-normalized` | only layout names are kept; masters, layouts, and themes are not | +| `table-formatting-normalized` | table styles and cell merges are not kept | +| `comment-metadata-normalized` | comment dates and positions are not kept | +| `comment-threads-flattened` | modern comment replies become separate comments | +| `field-as-text` | a field (slide number, date) is kept as its current text | +| `unsupported-transition`, `unsupported-animation` | transitions and animations are not kept | +| `unsupported-chart`, `unsupported-smartart`, `unsupported-graphic-frame` | the frame is left out of the projection | +| `unsupported-media` | audio, video, or sounds are not kept; a video's poster image is | +| `unsupported-content-part` | ink or other content parts are not kept | +| `alternate-content-fallback` | the standard fallback of alternate content was read | +| `alternate-content-omitted` | alternate content without a fallback was left out | +| `hover-action-omitted` | hover actions are not kept | +| `link-target-omitted` | an internal link to something other than a slide was dropped | +| `shape-identity-missing` | a shape had no `cNvPr` id; export refuses such a deck | +| `duplicate-shape-id` | see Anchors | +| `unmapped-parts-omitted` | parts outside the content model (masters, themes, document properties) were not carried | +| `dlp-findings`, `public-boundary-violation` | the joined text failed the shared DLP or boundary check; this catches a canary split across runs, which the intake's byte scan cannot see | + +## Export + +`export` projects canonical content into a new deck. It never copies source +parts. The deck uses one generic master, one layout per distinct layout name, +a generic theme, a notes master when there are notes, and legacy comments. +The package carries `bran/fidelity-receipt.json` (relationship type +`https://schemas.alphazede.dev/bran/relationships/fidelity-receipt`): the +content digest, the import receipt, the export codes +(`generic-master-layout-theme`, `run-properties-defaulted`, +`comments-written-legacy`), and the fidelity map. + +Before returning bytes, export refuses when the import receipt has +`dlp-findings` or `public-boundary-violation`, then runs the shared check on +every paragraph's joined text, every name, alt text, URL, and comment, and +every generated part's name and bytes, including binary image metadata, using +the intake's byte scan. It also refuses content that is not +`bran.pptx.content/1` (`export-unsupported`), malformed content +(`malformed-container`), and shapes without an id (`export-unsupported`). +Writing to disk goes through `export::write_new`: explicit format, contained +destination, no overwrite. + +The output is deterministic: sorted part names, one fixed timestamp, deflate +unless a part would exceed the default intake compression ratio, in which +case it is stored. Part count, part bytes, total expanded bytes, and package +bytes are checked before ZIP buffers grow; the generated package then passes +the shared OPC intake, including XML node and depth limits. +Import, export, and import again give byte-identical canonical content for the +synthetic decks. + +## Evidence + +Tests are in `crates/bran-document/tests/pptx.rs`; the corpus rows are in +`tests/conformance.rs`. All run in `check.sh --fast` except the reader check. + +| Test | Shows | +|---|---| +| `pptx_import_maps_presentation_structure` | slide order, ids, sections, layouts, hidden slides, reading order, groups, text boxes, runs, breaks, fields, links, tables, notes, comments, alt text, assets, anchors, receipt | +| `pptx_import_ignores_archive_order_timestamps_and_compression` | determinism across re-encodings | +| `pptx_relationship_attributes_resolve_by_namespace` | relationship attributes read by namespace, not prefix | +| `pptx_ordinary_projection_is_recorded` | recorded canonical digest | +| `pptx_unsupported_features_are_receipted` | transitions, animations, charts, SmartArt, video, alternate content | +| `pptx_adversarial_decks_are_refused` | 25 hostile decks through `conformance::check` | +| `pptx_round_trip_preserves_structure` | import, export, import keeps canonical content and anchors | +| `pptx_export_carries_fidelity_receipt` | receipt part and relationship, written through the shared gate | +| `pptx_export_refuses_dlp_before_writing` | split canary and boundary marker refused before any bytes | +| `pptx_export_refuses_malformed_or_foreign_content` | typed export refusals | +| `pptx_mutated_decks_never_panic` | 240 seeded container and content mutations; admitted decks round-trip | +| `pptx_review_binary_dlp_rescan` | exact reviewer PNG metadata canary refused even with the import receipt cleared | +| `pptx_review_shared_notes_budget` | exact reviewer shared-notes deck returns `oversized` under a 512 MiB address-space limit on Linux; ordinary deck succeeds under the same limit | +| `pptx_review_compressible_text_round_trip` | exact reviewer 100,000-character run exports deterministically and re-imports with the same content and anchors | +| `pptx_review_unbound_relationship_prefix` | exact reviewer undeclared `r` deck and related empty-binding/unbound-element inputs return `malformed-xml` | +| `pptx_export_opens_in_independent_readers` | opt-in, see below | + +The corpus also runs every package row (macro-enabled decks, external media, +path escape, decompression bombs, and the rest) through the adapter. +The four reviewer ZIPs are preserved in `fixtures/enterprise-documents/pptx-review/` +as raw-deflate hex text. Tests decode them and verify SHA-256 against the +original input before exercising the adapter. They are separate from the +8 KiB conformance fixture directory; its existing budget is unchanged. + +### Independent readers + +`pptx_export_opens_in_independent_readers` is ignored by default. It writes +the exported and the source synthetic decks, opens them with python-pptx, and +converts them with LibreOffice. A missing reader is printed as +`unavailable`. Run it with: + +```sh +BRAN_PPTX_READER_DIR= BRAN_PPTX_PYTHON= \ + cargo test -p bran-document --test pptx pptx_export_opens_in_independent_readers -- --ignored --nocapture +``` + +Recorded on 2026-09-30 with python-pptx 1.0.2 and LibreOffice 26.2.5.2: both +readers opened all four decks. python-pptx listed the same slide ids, layout +names, shape ids in reading order, text, notes, alt text, and image digests +for each export as for its source. LibreOffice converted each deck to a +two-page PDF; the hidden third slide is skipped in both source and export. + +## Acceptance status for #22 + +| Item | Status | Evidence | +|---|---|---| +| Import is deterministic regardless of ZIP ordering and timestamps | done | `pptx-ordinary-projection` row; `pptx_import_ignores_archive_order_timestamps_and_compression`; `pptx_ordinary_projection_is_recorded` | +| Fixtures cover layouts, text boxes, grouped shapes, tables, notes, comments, alt text, links, images, reading order | done | `pptx-*.parts`; `pptx_import_maps_presentation_structure` | +| Adversarial fixtures: external media, macro-enabled decks, malformed relationships, path escape, decompression limits | done | `pptx_adversarial_decks_are_refused`; package rows through the adapter | +| Exported decks open in two independent readers and include a fidelity receipt | done | reader check above; `pptx_export_carries_fidelity_receipt` | +| Import-export-import keeps slide order, structural identities, notes, accessibility metadata, citation anchors | done | `pptx-round-trip-anchors` row; `pptx_round_trip_preserves_structure` | +| DLP and public-boundary checks complete before export | done | `pptx_export_refuses_dlp_before_writing` | +| No PowerPoint installation or cloud service required | done | every test above runs offline in-process; `importers_have_no_network_or_process_access` | + +## Not covered + +- The canonical content is not yet wrapped in a full evidence envelope + (`original`, `parser`, `policy`, `admission`). The envelope needs a + `language` and a single normalized text of at most 8 KiB, which a native + import cannot always supply without inventing values. The CLI inspection + and query ingest paths in #25 are also not built. +- The reader check has not been run against PowerPoint, which is not + installed and not required. +- Audio and video bytes are not carried as assets. diff --git a/fixtures/enterprise-documents/conformance/pptx-design.parts b/fixtures/enterprise-documents/conformance/pptx-design.parts new file mode 100644 index 0000000..ea3e1cc --- /dev/null +++ b/fixtures/enterprise-documents/conformance/pptx-design.parts @@ -0,0 +1,40 @@ +--- ppt/slideMasters/slideMaster1.xml + + + + + + + + + +--- ppt/slideMasters/_rels/slideMaster1.xml.rels + + + + + + +--- ppt/slideLayouts/slideLayout1.xml + + +--- ppt/slideLayouts/_rels/slideLayout1.xml.rels + + + + +--- ppt/slideLayouts/slideLayout2.xml + + +--- ppt/slideLayouts/_rels/slideLayout2.xml.rels + + + + +--- ppt/theme/theme1.xml + + + + + + diff --git a/fixtures/enterprise-documents/conformance/pptx-ordinary.parts b/fixtures/enterprise-documents/conformance/pptx-ordinary.parts new file mode 100644 index 0000000..f283151 --- /dev/null +++ b/fixtures/enterprise-documents/conformance/pptx-ordinary.parts @@ -0,0 +1,62 @@ +--- [Content_Types].xml + + + + + + + + + + + + + + + + + +--- _rels/.rels + + + + +--- ppt/presentation.xml + + + + + + + + + + +--- ppt/_rels/presentation.xml.rels + + + + + + + + + +--- ppt/commentAuthors.xml + + +--- ppt/notesSlides/notesSlide1.xml + + + + +Open with the regional summary.Keep it under two minutes. + +--- ppt/notesSlides/_rels/notesSlide1.xml.rels + + + + +--- ppt/comments/comment1.xml + +Check the subtitle wording. diff --git a/fixtures/enterprise-documents/conformance/pptx-slides.parts b/fixtures/enterprise-documents/conformance/pptx-slides.parts new file mode 100644 index 0000000..64d96bd --- /dev/null +++ b/fixtures/enterprise-documents/conformance/pptx-slides.parts @@ -0,0 +1,56 @@ +--- ppt/slides/slide1.xml + + + +Synthetic quarterly review +Prepared for the fixture team + +--- ppt/slides/_rels/slide1.xml.rels + + + + + + +--- ppt/slides/slide3.xml + + + +Findings + +Revenue grewin every region +See the report on slide 2 + + +Grouped label + + + +Go to the table +Next slide + +--- ppt/slides/_rels/slide3.xml.rels + + + + + + + +--- ppt/slides/slide2.xml + + + +Regional table + +RegionGrowth +North12% + + + +--- ppt/slides/_rels/slide2.xml.rels + + + + + diff --git a/fixtures/enterprise-documents/pptx-review/compressible-text.deflate.hex b/fixtures/enterprise-documents/pptx-review/compressible-text.deflate.hex new file mode 100644 index 0000000..0f6bc5b --- /dev/null +++ b/fixtures/enterprise-documents/pptx-review/compressible-text.deflate.hex @@ -0,0 +1,79 @@ +ed9b5b8824d779c7bb579b5d7b2d3d5896e4c820bb52766470dc5d5dd597b9b033cb5c57c3ae66879d11065b42aea93ed35ddaeaaa4a55f55cb27610 +81106290130c0924f82d710436d8586002015f040a2286843cc4863cc879d05bd083f590084b79c877cea9eaba744f4fef4cefae2cfd7f4c75579ffa +ce77bef39dcb77cee99ead6b0f9c7fa4c4f9a6b1f8dcf7de7af2ed1b174b257e7d82d2beb2e2b91173a3e7778e7c163e573dec3997afd08bb2cf82d0 +f6dc0555afd65485b996d7b6ddce82faccce7a655655c2c874dba6e3b96c413d62a17a65f1d265a142a1cc6eb8a076a3c89fd7b4d0eab29e19563d9f +b9f464cf0b7a66441f838ee69bd62db3c334a3566b699634a312711d2a295b657b66df8994b5434a979604cc095525369817b6a09abeefd89619d173 +6ddf6d178aa9c4455429a79009bbb61ffe0109a8dae822f8a3e34b1893d1773b858c768f578ea7f32c37c89f81dd66ca9619449b668f0434df8f343f +60216511faabe34b1f513f6f6fcfb658dbb3fa3dca52cd2aeb39b98fd59e69bb839a1f674de8506228dff4699b23b4de9109c6fd37a17e7f4c70bd88 +85dbd28ef47eea4d92aa3ed122cbeb710d617233755b920226b564a91f75bd20bc4b66c4da27eb2b4f9b6144d365f6c3dd193d52f764465d378fbc7e +14663fdc1da3a4ee531b65dc4fa3228a4e4cbe9edd3942cda0484d84c3c54b5bb9f8fbd1ef5e7cbd562e95f8f5314a7b9e07358d07a8f0d471f76636 +ba9d32fee622a45a50aa6cb429fa6eb47555917e3959b974cd6aec9a1165142448b31974584491745454140ecdd5b3e8d82f3ff3deedcf9e2f95f8c5 +5347a939b587fdf9ac2ae9e27973123fb403f380b4f71ce9021e85d5387f30153f26dafc899a3c37523226893a864e5b4e301bedeb61b4984f516cea +0386de9869ccd65b0df253306f27bd425bbcac0d658f356675255a9aad4c7683674f1e0f52ebaa10add76ad9c73277b6f0465a78aed8ed3f52ac436a +62439fa35a523b5b470b6a6bb6395b4b34cad827c59207426a4e6f34a414d7c50ea3b80274a7f4037b41bddd243fe8cdd5b94abdb53e5369d4d78cca +d2b2b15e599eab2dad1bade5e6da6ce3eb2ae5d11bf321b3b8b74947d2507a63a8a97ab61578a1b717f1f013b7b9e67b072cf03d5b34bb5e4b1b2ad5 +aab8622abb412d6df3052877caedd6babe565b692c55f46563a5d258adaf5566d7e76a9525feb9beda586baeb706d6a50d947c1a3491706c4e46cb14 +3dd290551699365fa40b436a7189155e64a5516be9955963ae5e596a2c37575aab336b64d64986241da060ddecc9d66979ef8b5e424d98bccbbea2e5 +47767152f9f4b357bef6c603a512bf3e134f2a72c62e4e2d1fc6193cb32449a7eff10b22ed18bb8ca9db55b028b7a939ce8afa3db2a23ed68ac63db2 +c2186b45739a568895516ac5d0aaeb38235ad33422bfb24fad19b19f3879b9f1e6abe75ffb35ddf3ebb17866185674860587d5937a3281e36c113ea3 +534ca2648a9cb4b78f5c6a8ac8b6949b6cdf66077c34dbae1dd9a643b3d0f64d557168046fb47938a508e904e2b61607de8c9d451fbd7c7ef3bd4d9a +393733b3e7988ded199c25347d109765bcd1b69db65801f93b0163b2bafb57037fdbdf0ac4f3cdfd2dd9a27ad2a27281430f6231b9ded917375a217b +2711110b275f4a0eeb3606bd85b797b2c10fb7147d5050ae14feee7795488c5c1e9f7b1d3529993fd5b245847e6216153ede867a62c3a668ee2dc7b4 +58d773da2c508c092cd9f5da477c6522faf149f6f8f3d1e1326558bc6ccef38c22d19cf7f94bc05fa245beea520eec88f4779912b00eb59ee92861bf +d73383a3ea658d0bf1d740bcfac5fcd718f3159bd6942eaf4174e0293ddbed53cd4665d55283125769833e21c6a1e8265a3c168a43f1c5772fbcf479 +da72f2eb0b2386a25cd41406e487764d93c6866a551bbd7c181f1d5eb9f0c48587c9d5fc7a3c1f1df20768670a10d30d0d8a29e6f10d191ada5479a3 +66b42a35bd5233766af5f95a63bed6ac8a5d523c86c4e8f2a86de903a51e89373976f8427ba5cbac5b626c84fdddc88e1ca61c7801af595574e76431 +6ef5e2b71121e417dffa92feec37ca257e3d1a3b72a841cee0449a9d1036ee45d8d811ed3f49c0b0a240089f7d8a0ec808c7e47d80b99567b6554dce +bbe97ae70ffb66405b13e788666fbef2996cde9d28446d275d7e92c844e34356798ad1692b60be19b0b6423d460cc25ce5321eda8d0be44f97000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ef +5b2e6be67cb4c85f03f1ead3ab3f1f1d2e7bed23711bfa8b97e4fb4ec09848b2b69df6e2657a7782a74dffc63ee534e77b6618b1604524f9b6dbd1a4 +682ac27550be4b5bd71e38ff4889f34d63f1b9973e37f749f35ca9c42f85d27c3fd242c76eb3507b3e604e283fe8d5c39e53e59f2f5fa13b659f05a1 +edb90baa5eada90a732daf4d452ea8cfecac576655258c4cb76d3a9ecb16d42316aa57a80a379963469427ecda7ea89012375c50bb51e4cf6b5a6875 +19d95ff57ce6d2933d2fe899117d0c3a9a6f5ab7cc0ed38c5aada505591d6a41a9b2d15e50838db6ae2a3b473e9b44b9b7b7675b6cd5b3fa3de64623 +ca90b5bf6e1e79fd88d49a4187450b6ab59a4dcf090947a9da31a619d334cdf522166ef3a27396a5c95991f176d5a76997e5f5f8a33067559298dca4 +f668b98e51ec9e3ff8e1ff3dfef283a512bf1ecd774ff156e77a4edd27c588907d71de9ca4f6edc03c20a53d4756bc67daae1ae70fa6e2bd449b3fd1 +d80858486a44ee9c496272a0494271cd1e5577dd76b92b42911ecf23bcf2eefed5c0dff6b70221bfb9bf15283675081a40321fb58f7c108b898feebe +b8d10ad93b8988f0aa2f258775cf24ba77ecc8614a6b50444e3f7ff7bb4a24fa64c425d5a4c8ad20be49748749c664ce34e777e95d248ae954ccac7c +924ddc30d9943bae168d412dd861b4ec1d2af55c3d14aeeb903b72c86359abb955877b418fbf53c75028c76c7d965a51558e28f3acd16c194d555483 +8a512c7aded467f51617b048822439c235a9263f08a3abcceb29fc864637b32295a79bfbd7c3488a26227165a545c3eebb54f0df4db6cfdc3e533a01 +3bc8f990b2486fc782b6ab906870a404ac435d73d8df03cddc59cebe13bb6aa0609b31a550827c914eeb3ab67b6bc5b1ad5b4a306f275398ac5b2c12 +2d465d46e5fb5e108dd2142d2a9eab8829a458d21e8d1baef4f6b2be62acd6d71a95f5e652abd2589e59a9ccaecead556aebfa92b15c5f69ac36d7be +aec6bd54a872fb3d556a3752a57b3c58c7d51ed9cfc4d0591c331e6b4977bb1a787d5f993bc5b81cd1d75a0dd97d8eed6c8db87f89cea637079dcd9c +b7ba37a40e997b90ba7642ce4137d552c3c68f347d3019ddd8371d45af8d9e30261c5e3973936a26f61d6becd098628e4353353be5b02a8c2ad1a6ac +ad38e62e73269d9aac4337e9322bfc76c86d46e2b615cf75690af00245d707be8bf30cb92fa3eb180f0e9c45be9a69a63d2271a69175e6096ea4513c +890fb5a4ba9706dd46f8c0b7add87edb2ad6bf99549f9e45fd80293463d39ac1a220bd7de4d2d410d996b26b068ad5358348f1f6e2a98aba5827f00e +a22e0d6b1e76c490a38f526ee03f51e290f70676ec52f758b71d47b43addd32cc57abb2c59030a878551c022ab2b261c12bd496d243d3078a065154d +12311ab5da8806a9dd69ef9e306268b205c60fdf56d20ecbfd28a209b7a91e377f5303995624d66fbe2fef68f12324698df642bfe7c74b00a1fc0c73 +4076da3bd963ad491ce6f5ddf6cdd3c7d9e284e02911fd51f88acc5d879d7db5325b688499d18d30a601bade016f812bfc65c12547896837a506996d +9db141eec4b99b5ca9b0fe7e6fbcff627beecd7768d3fdced88d771d1befc936dec6bddb78db3df24bcea81e6bdba64cd7abbedbb9371bed2e690af8 +f84c6de16a43d2cb0ecd9eefb0aaedd2aac96e6b6112f42a72499ce478da6b9331b46c6301c5be63cd6e4cbd655393c54763c2e3809ffcf7733ffeb3 +8ba512bf461d0718380e18711ca0f0099c2fc7927381fb7f0e60e4cf01f47b790e703359eadd517ced04a6dfb5adf580cc4e366b694ab17ef541fd78 +198a31587ec6ebc9dd64739cf16d5e9b992bf1ba67dd0a15d7234fc79b656d64ae62bb149fdee179432b1b7a671a83d09b2a8a8dccdcae9a91a9f403 +fb1403483488dc41ef3af11b3974cfa685d44dde85a94beed2b88defb558e46a60b7a501767bc573147a58cf2e2446a76b695e7e1e11054a979ecf88 +c742b1a8d484bd69542fca668ee486411ba89d4cb9ec2d77a69cbf0567aad2264588ee5daa916efcfee9aaa3c59d42cb75b4ece7f85821334c4fda26 +368adbc4fa886da2e375bcf7e5ceaf76c2f98d9edbfad5c62c9c077bb9bbb7dc7de7edfffaeaabe552895fc72f770d2c777f5b97bbe3176ebfea373e +fea3f3a512bf7e2fdbfc4f8bee14663fe8675fc449451fe06f76de772bb82dc7b458d773dae4f629ade6c69f76d55b4d7d78c66beacdc1f7237add68 +365bf563a7bc130f4eead9af7972353426a8210f802ae9394c176ca7aae831737bb6a68d7a53afd767c7d774c4dc7e6930b92bbb1d7d417522ea31d1 +21ddb56ff1a556c7e069064f33789ac1cf882cfef5ed821adf242946923290a92729f524a591a43492946692d24c525a494a4b55c4e1130d17b9c7dd +f39ca7644272a76ac9789793e246fb7a182de653645fd51b338dd97aab31a7a6278e712f3d4eb659cbc81a7103168aba14a7c9e9a638e7fd2c7ce2ca +b3e74a257e7d71d49c97097c99990fe1ef7df5338be946e6a91e00d102b5978fcc2245be4efa0b8b737b3ffffbffa14519bf7291f938b79f2d324b45 +1fc443966c5c2bfc00438648f95b9d4ce4be5b817b8acb78d95cc53ef3e4f3fff45773d45ff8f5c5517d2633b3657ace87766693b3fbf0f471dce277 +82615b3e37fbe06fc8fdbf9960d81a18b6270d5b6ff785d18396fca1ac786e44993fa443d7c0d09deed07df7af7ffaef3b174b257e3d12374131709f +dad5e6bc5072e6912afbf0e0144c1e1f72cd6b0e13bfe4146551a7dbb64479a3e46913210ebc82ce2ef55665df7416d4dc31542c40db8e21b975412c +170bd00664484e5f37e6ea33037d86d4372cb75e5f6facb706fa8440bc851992a5fd567d6d3996cd08c51b9ce13a35e75aadb99c7c467f7d487e756e +66a6d6cac9d753f9c6b0fc8ad132f2f28d54be39243fb3525f5a5bcdc93753f9d6b0fdb373fab291936f0d7e2230de3b0391644f36de98544acb741e +d195f668921dd3977ae60b5eb04e32a2816970b962e6dea33df982ba623af66e60c75b6433f324fe655a5848d20a0a7bb63b65eda9422d5b3759d5de +b89af243e1ede8c86162334bdef4688a490e95c5306603e7fa5dba8d8bcccbdda34cdab0bd8e9bffc4bf7669d59bf21b88d315e1b8a92add98a94d4d +d75cedcc7669c51ab3bd3d6645c7a4a41f07bf121af974fac2da28cb763bebbf65bdad68b196194ef1b744b92835482946e14777fef94fd7e89e5f49 +142e1e6cfff9d6e6d5872e7dea123d7e68e3a9d59bf45ee6d747ced1eb5f1e6cff8ade1edc585dda39fcb6b5f5f41f3f543af7b972f4b79f5afa0f5e +c8c6dae6eaf797bffa275bd7cae71e79202df87b6f3df9f60d0afdfcfa4429e545aebaf49578b1f93c5fc784cff1b54051c147bf7bf1f51a09f3eb63 +39052f924ab97e13ebb562c62f3ff3deedcf9e2f95f8f5482ee37f7e443a20bb4c1e55f4a79fbdf2b5371e2895f8f5999c86ea4352832cbea867a439 +6fbe7afeb55fd33dbf1ecb297beae35259fccf224bfda8eb05e128835e3ebff9de2619b3396490f3b0d431e6ff618aca5e7cf7c24b9f27a7f2eb0b39 +65ef3c3aac4cd6b4a07264455fb9f0c485874927bf1ecfe9ddfb64aea2f97f8f29aaf9c5b7bea43ffb8d72895f8fe6d4fce077877f0a335245f1bfbd +52158b7f573ef17fbf8ada8aff9c936a7be33be5d1ffaa535451fc155caae21f5f299ff89bb8a2b6e2cf83526d6ffda83cfac7424515c5ef295315ff +f0e3f289df5a16b515bff64ab57de7a7e593bf042baa2b9e28a7eafee6b5f21d9d2f1735174f0153cd575e2f9f7c26585457dc65a6eac27f29dfd171 +515173f1dc23d5fcbf3f2f9f7c0a32b9a1d57f2ddfd1e6b8a8b9b8cb4b35fff0dfca23f77c450dc508956a98f9657964bcdabaf63b174a62367dac74 +8beebeff4b1e53fe1f diff --git a/fixtures/enterprise-documents/pptx-review/png-metadata-canary.deflate.hex b/fixtures/enterprise-documents/pptx-review/png-metadata-canary.deflate.hex new file mode 100644 index 0000000..8808625 --- /dev/null +++ b/fixtures/enterprise-documents/pptx-review/png-metadata-canary.deflate.hex @@ -0,0 +1,64 @@ +ed5c4b8ce44619eedd6c76c3909548c8834424314e0812a4c7cf76778f7666e9e7ee6877674633b3280989366eb7bbdb59b76d6cf73c5882222e1107 +0417408ab8811429918840e282c4231202c489030738901c724128077240ab241cf8abcacf6af763667a66f398913cb6cb7f7df5d75ff5bfcae55ebb +74dba97b72e8efc7e2d2b3afbdf3f8bbab677239747c16cabe5eb32d5fb7fc6b9bbb8eee3d3bbfd337cf9d877fcc96ee7a866d2db2c23ccf32baa5d9 +6dc3ea2eb257379bf912cb78be6ab555d3b6f4457657f7d8f34b73e7300403952d6f91edf9beb3c0719ed6d3fbaa376f3bba054f3ab6db577db875bb +9ca36ad7d5aece893caf701a6123ef230c16c0ea7a471d983ed3d88172c289ab9b1ecb040ca3c61659d5714c43537d78ce6d596daa997cd0c43cd4c4 +345ecf70bcaf0001cb6537811e8d6e614c45c7ea52158d3eea1c2a475556419eaed1d69935d5f557d43e10708ee3738eab7b5005e3cf8f6f3da37f76 +a763687adbd6067da8329f04eb9ba9dbf9be6a5851cf4771e39950e89193306b7630ea9e58106f3d0bd2ad61c1b27ddddb207cc4d7331f92187a2247 +9add47085e7831735ec206a6e5a432f07bb6eb1d121b01fa7473e58aeaf9602e933787a33d047b3aa62eabbbf6c0f7923787c314c1de3753e2ad64ca +07efa493ff07170e86899ae4b03b5c9a5b4bf9df4fbd7ae64ffc895c0e1d9f86b26bc8a971c84179fbf6bbeb49efb64fff9bf2902c05ca2cb7c1fb2e +b705962172990c4e44530f4493d1064501c8aadbd57df0a4595e110b34d54f5ab04f5ffde0c6a3a7723974a0d22c987d4bd859484211112fa8d3c8a1 +edaadb80de37890890176683faee4ce418a239530d794a53122ce13e7a669b1898e5f665cf5f4a973006cc0151908b724952649093bb6084b3825b3a +c70d550f109358214a4149541751f5f071542ab19854e2f9e463523bd9b81c379e6a76e39b8cb603432c0a65e8258cb3b6bbc82aa542890f1189ef23 +64e1034c5516649950212c7dc70f3a0057ccc03516d91b05908350a897f392d22ce665a921e62b55b199af96f94a5354aa8546497e81853a82bce0e9 +1a923660840325c84343d53734d7f6ec8e8fdc4f30e69c636febae631b78d8053e1ea81895b1b0295b85913650008a847243690a0dbe2657f24255ac +e5e5bad4c8979a653e5f41f7525d6e149a4ac45d3c40e15d344458b0291a2ed174262375dd570d14a46346f8a0c53c6a322ff38a902f8965295f91ab +859a522f3680ad498c841380e2ae34993b2e2d7d3c4b6008c333992b5c5ab369a3f2f033e7bff5cfdb7239743c12181562b169d3f249b4e089902436 +dfe303226e045fe2ccf9a2384a2535a3b8908e880b692c17f21171218ee5a2304b2e706414733114758d6242992513e9c83ee626239f981c6ebcfdc6 +a93ffc07aed1715f601986810e1070687d8293701c07f3f0094c6c44811562b437762d180adfd098757dcbd0b791361b96e11baa095668639d654cd0 +e0e53672a7e0214d175ff281e34df049cbe895532b1fac80e55c4958cf3189ed018485913e8e61191ab40db38d232067d3d575d2ddad0baeb3e1acb9 +f8f9cad61a1951211c5112e0c083808cc43b5bf882a3aa7743121c38398472185b8c660b1a2f66192d6e3142d450aa1574767a8c8f3517f9e77e970d +5b464fb964139e13b2058d8fe7410a7958c1c3bd66aa9adeb3cdb6ee32e2149cb4ecf62e8a4cf03c9ec48fb3e0ef54a1c2d239750155c485ea8283fe +b9e89fbf84a22e66dbf001bfa733aede85d1534dc61bf4fbaabb3b7f8e4344e8bf8bff3b74fd4bbaee3006c49416ea81bf6d337dc31a40cfb2aa7231 +43a1a8b8684e603dc4d3840b748156c517df3ffdbd2f41ca898e2f67a822096a2885fcc4c634b16f989fe7b2c387f1dee197a71f3a7d37881a1d0fa4 +bd437a01ed400e62b6ae8151b11d5f26aea10d9d177951c9f3429e17377969819717f8c23cce92021dc2da65c3d8c20d94eee213d11d1468d77aba76 +1deb863768f9866feaccb6eda29ecde3e91c06e35a3f3865b8104bfbd7dfff0cee031df706821c1a90030811acd3b1db380ab7b189c77f1a87a1f92e +263eb88976810953457340b7f25737588ed8dd38def9c6407521353177c17aa3c8673abb3b958bda08a7fc349e09f483747986de69cdd51dd5d5db0c +cc18ac84a9ce2524d40a1a444f3bc68e3f7075c6d7d5fefe9d109c4df78aeaac6ee146fa38ffabe12207b427881b6312b27c43abfef71e2bdfaf9ecc +e5d0c1a4559f782befd8519155ee61773572edff48f2ef388c487136ee9dd69164e4a1074e7195fd5e6bb28b7ffd17ff7be0953b733974647926e9d8 +338df14c81956c1a161285c7de7a4f554c7b2a650ab3edcfc64df94ba1180eee7fe4a817105d55ed1d464af5834158a187494b2cc935e26aa7e3f6d1 +1926060af04a5249e48320af241614b1407c065a14472be805a12428e10a3a5a710fd6d9b918c9713dff826ef71974817673683e8bcad52d08fc0869 +4812749670342cbe394a7eebfa966e0d74a6eb523e1caa1069078486c500a9bb1b646bc3f28e9091b0cc2d33f28d01c0863ed291a253cf34aceb35d3 +80b837f12683f42d20f19748b6e8d8ae9f85e42f31b6c5601342b7d431c9f2f78daa5013eb5243ce370b15252f578bb57ca95e6ee4f9a65011ab524d +ae171a2fb051020e50d6a0cf12743106ede08c91743b739e61d5591aa38fd11ad205d71e384c791f7a9931d714994c9f91934d0ee6179e6c42219a6c +ea82d65b2518a47654da9850339aa65cccd8784d132263b4ba05597f98f9d006634af54ab11b7633e46f24b3433aa59b26986a7d9f6a4569151e5308 +204db5a59bd39a266dc70aa74c0d5d0e892dca086ab665810980e85488d382a0ce90f81258232418090b64552cc4332214a69814e6043182164f2343 +2eecee5c346dc8bb62430bf83734baff85b0fbf00c87da60b12166d0dce43a6c4b7519ad07b9096377e285a5ae6b6ffb3d506be476b0cac12da18be4 +875b1c925ec4470ba647d3304d3cea700d564aefb7f4c48b5875c1f35dddd77ad8e000e93a8c119140f4804b024de331649ecf18107eafb37b4a8fc1 +911118afbe4a380ed581ef83c12db0a3ec370c908a5f22a2ad09e40a821f4c0931daf383be13ae84af6c45adecc70624cdde648929d308cc1e58edf5 +fdfb59da20d88c6fe35cd2575ba67ef068a5440d42317b10c60c40cfde4623701efd5bb4405064fd6e360352520e38207b11ee0a02c5dcdfeac4fbfb +1be5b76f42d27d736ce22d1d27ded325dee2d125de78cb718aa9bede3654522ecc877b910f3dd1ee01928bf433e605c17a80abefa87dc7d4e70d0ba2 +26a3cd79a1d3cb939038ac71c56e03336883b50bbeefd6bd2b17a75c0ef8edbf9ffdcd4b67723974642d0788c7cb0119cb010c32e0281cfb48af58cf +6c1d603d0cf5f6e45fbbaeeaf40cade902db61b216978c5ca1de446d3062147e06f1642b4c8e13b24da3a9a9162fdbda758fb16c9074bc729d518b1e +17fae91ed71b94a4eb2dca91eb8d810226139775d557c90ebe3d2b101e109241b7cce00402ed181048ada3290c53b2057a1b5c7301c905d76813068c +76cd36996dbca12d0e24b2cbb9b82e5a8ff05da607cf8bf83106c69d9a723665cda264659f240c5c043b1d38992d7b034727f7405d5a010fd13ba41e +09e217f7d71d2e98145c6aa225ef836585849a4e4a13653a4d9432d244d3eeda1fcacc8f9fb07e23a4523f7e4ce01ce5728717eede7cf7ade7de3891 +cba16374b82b1e87bb1fd570777ce0f6e640beeb57a77239747c2139fca3f6b01e2c882340c77b0e8e2e824b6e139b5134377eb54b520ac2b0c52b08 +85e8fd8820898582228d3479d36f3340af790e7123dc3e5e04a57b2a4b0541924ae37b9a61dbe722e3ceb4bac2226bfa3063fc1db86a5f47a1565744 +65222a13519988d68834f4fa76910d2ec212312c8968a4b0440a4be4b0440e4b0a6149212c51c2128565f0e213a80bc9713bb67991148457c1b723a0 +efc428263f82094bd21fd494e90f6a46d31678faeb196ea8a9b9d40739b4cdfbbdf7d0f9674ee672e87822cbe6251c5fc2f21dbbbf0fd5368bd97a66 +e910f7fb0353995bfec77be6939dbffcecbf1094a123e5994789fd609e99007d1c1759927e8dda80415c24d9ab93f0dc87e5b86718c693e1a2e7cce3 +d77efdc332cc17743c91356712962d31738ebfdea2ccc7d80fb8c6abed8993a53bdf03f1bf3785da8ac76a3b496dedd6f3d94a0bf2083f56ff84aaae +78acbab355ddf77ff4bbbf6e9ec9e5d0117e434f3bee7d8b5a5dc02007d654eaa335b27c88901ba68e7772e2b660d26d68b8bd2c7a4822f08297db6d +c16c65b65473914d2d43050490760cd135f15f401710400232442734c5b2548cf04482374cd7949a725389f0304190c20cd142be2535aa016d822848 +7086fb54282b4a39459fc09786e8ebe562915752f4524c2f0fd3d744454cd3cb317d6188be58932a8d7a8abe10d32bc3fc97ca42554cd12bd11681f1 +d28948c29c6c3c33311597983c782a75c0c88e994b7df579db6d020d1e60502e0b5bee0ee4e48b6c4d358d966b0429b29a7812ec4cf3a8228e02ec1b +d68cd163402ed937d2d5feb89ea245e10d7fd7d471320bd2b4c1c4848bca588df548b84e0f2e8326d3744754891be6d7b4d277e8b58b2215c81b88fd +35615a31942016f9996195f903f3c5d13dd63b1d5df34794c4b7d12ea1cca7b327e6b2386b759b1fb1d94673cc25d429784b94f2525109ed85df38fd +ced75e826b74845e985ed8feeedaca85b3730fcec1e3b3cb17ebeb703e818e3b4ec2ff1f6c6fbc09a73b97eb95cd9d9f686b57be7d3677f2b113fecb +0f56fe06e58adf78d2af91af1d7295a7af6d3cb5b279b1b1b95cbbb676b57a194ed5d5ab2bf5cafa53d76a951574aa5c5ebb58b9566c4a95cffc917b +18b1b9dc58a9ffbcfadc77d62e9d3879cf6da37f5d30fc7b113197f55b833400fdf34831c08b67523f964457a47ffe27aef88f3bb27f0c8846a07feb +2346983f3bf1973f6830fae70162b08b778dfab1001a83fe7c3ec630ef9ef8313d0d467f001c83ddbc772f9f03d3b8f497ae316ee7fe31dfbdd230f4 +779e31cceb9f1bf1d5270d417f2f1643bcfaf9c95f8fd168f4e73d31daf223233ef6a121e87d7431c4a34f4cde5547a3d11b8c62b427b911db8d6808 +fa4d670c717771f27b4f1a8d7e7116a3dd559ee2351a0d47af49c770b72fed6d859a46a6d71163e4d7be3ac5aa220d47e7a931dc5bd5bd2d38d1c8f4 +ca498cdcaa4fb18e323da32f37f7965ed3c8749e18233f74313b6ba411681f1723fc7435dbe3ad5dbafd740e5bd3fb72d7e1ea993574f77f diff --git a/fixtures/enterprise-documents/pptx-review/shared-notes-amplification.deflate.hex b/fixtures/enterprise-documents/pptx-review/shared-notes-amplification.deflate.hex new file mode 100644 index 0000000..0a2f9f0 --- /dev/null +++ b/fixtures/enterprise-documents/pptx-review/shared-notes-amplification.deflate.hex @@ -0,0 +1,173 @@ +eddd0f9063f75d18f0f59f3a894988054e6892a6284b42d224be5d3dfd3f7c67ecb3cf31b19dabefc8f0cf7574bbbabbc5bbda45d29def52fe240c81 +76205060a0b4b4d0194261425b48a640874e125292026118421b1228c13098b621a52419c6b8f943dbf7a4d54ada273d3f3d9f15093e9f1beded4a4f +3f7df5fd3d7d7f4fdf5d49a75e7bddf537af44be3738fee0bb6f7dee9bafb97565253a7d7178ded79fd86d759badee4367aeec353b0f1eb9bcb37deb +6de197fca566bbb3b5db3ab65a38b2be9a6fb6367637b75ae78fad7ef59993b7d456f39d6ea3b5d9d8de6d358fad5e6976566f3b7ee3adbd21f2e195 +5b9d63ab17baddbda36b6b9d8d0bcd9d46e7c8ee5eb3155e726eb7bdd3e8863fb6cfafed35361e6e9c6fae05ebeb95b58d7e18b774a33156c3c1ee6c +9e6b5cdceee6efba1c9edf8fa4dddceeace6f7038e6eecd86a636f6f7b6ba3d10d2f5fbbd4da3c7433b7ecdfc491f09abd6d3a17b6f63aaf0a37585d +9b7c13d145d36f21e18a7badf387aeb8b513ddb9e8fce82aaf0bf3d9deda6ce64f35daddfb1b3be1066b7b7bddb5bd76b3135ea537fe91e45b9f70ff +76cf9ddbda686eee6e5cdc09af726474b09dedb11f8fec34b65a07f77c5a349dedf0cc4effbfc2d50ea737ea4c21049fff108a9f9f105abbdd66e774 +3f8ee1f7577d4a86433f69441bbb3bd1089dc137573d96c10da48de4f68bdd0bbbedced314c6fee8e9f695fb1a9d6e582e477f787a1e3dfdb1d30575 +6fe3caeec56e67f487a727a8fed899830a3e9f4175c3d5a9d9fffad493d31b266d7539df6c35db8d6e7373fde9ac30a9a3282c4414c14244515c8828 +4a0b11457921a2a82c4414d58588a2b61051d417a36a2d48f15c8cea59588cf259588cfa59588c025a588c0a5a588c125a588c1a5a588c225a588c2a +1a2c46150d16e4187431aa68b0185534588c2a1a2c46150d16a38a068b514583c5a8a2c16254d1e26254d1e26254d1e2823c955f8c2a5a5c8c2a5a5c +8c2a5a5c8c2a5a5c8c2a5a5c8c2a5a5c8c2a5a5a8c2a5a5a8c2a5a5a8c2a5a5a908ee86254d1d26254d1d26254d1d26254d1d26254d1d26254d1f262 +54d1f26254d1f26254d1f26254d1f282fc626931aa687931aa687931aa687931aa687931aa686531aa686531aa686531aa686531aa686531aa686541 +7e3fbf1855b4b21855b4b21855b4b21855b4ba1855b4ba1855b4ba1855b4ba1855b4ba1855b4ba1855b4ba207fe6b41855b4ba1855b4ba1855b4b618 +55b4b61855b4b61855b4b61855b4b61855b4b61855b4b61855b4b6207f2dba1855b4b61855b4be1855b4be1855b4be1855b4be1855b4be1855b4be18 +55b4be1855b4be1855b4be207f74fff456d1b5de0b788fdf786aec15c3cf7afb337e75fd9a9595e8f405e1790f452fc35d8b5e52dbc9fc4ae107465f +8f9bf115c363afe95d3d3468fe9ecd63abed7b360babf97e969e7cf07ea2eedc4fd484db38b4453872a37dbed93db63af175bcd14bc0d6c6eee7e1c4 +7ef6d4ab7ee2e1e7acac44a7e8dc49c364cef0ded1d1a1fa293eda489387cd76e39170f49ded7e0aa2d70dafee5fbf7d55f238186d2fd5948fedaf23 +21f5ee63677bb3ff92c87b36efed748f8f9f93df0af781a050aa966ac54a29cc53fbe8d660af88f6f6d8d5f7471c1d6b304ab932b87a6b3dbaf2f885 +d5830b0bf10b0f6eb815c42fac1f5c588c5d58593fb8b014bfb0707061397e61707061257e61f1e0c26afcc2d2c185b5f885e5830bebf10b87192ac4 +53541949513c4795618e0af1245586492ac4b3541d66a9104f537598a6423c4fd5619e0af1445587892ac433551d66aa104f557598aa423c57d561ae +8278aeaac35c05f15c5547f6a778aeaac35c05f15cd586b90ae2b9aa0d7315c473551be62a88e7aa36cc5510cf556d98ab209eabda3057413c57b561 +ae8af15cd586b92ac673551be6aa18cf556de4c117cf557d98ab623c57f561ae8af15cd587b92ac673551fe6aa18cf557d98ab623c57f561ae8af15c +d587b92ac573551fe6aa14cf557d98ab523c57f561ae4ab15c15d7472a55297ee93057a572fcd261ae4a95f8a5c35c95aaf14b87b92ad5e2970e7355 +aac72f1de6aabc1ebf7498ab72217ee93057e5207ee93057e578ae0ac35c95e3b92a8c94f578ae0ac35c95e3b92a0c73558ee7aa30cc55399eabc230 +57e578ae0ac35c55e2b92a0c735589e7aa30cc55259eabc230579578ae8261ae2af15c05c35c55e2b90a46d6c078ae8261ae2af15c05c35c55e2b90a +86b9aac473150c73558de72a18e6aa1acf5530cc55359eab6098ab6a3c57c561aeaaf15c1587b9aac673551ce6aa1acf5571e480219eabe23057d578 +ae8ac35c55e3b92a0e73558be7aa38cc552d9eabe23057b578ae8ac35cd5e2b92a0d73558be7aa34cc552d9eabd23057b578ae4ac35cd5e2b92a8d1c +5dc573551ae6aa16cf556998ab7a3c57a561aeeaf15c0d0f7e5bf578ae4ac35cd5e3b92a0f73558fe7aa3ccc553d9eabf23057f578aecac35cd5e3b9 +2a0f73558fe7aa3c72285a1f1ecd8f1eba9f7e637ee372f8942928d4c3670de1bdd8b8726cb5522bd7a21ffad7187d72717cfcc959ebcb5eff7b1f2a +afac44a72fdd7f72d67fe67bf8295aff99f0d3f08c36fe84367cee71159fcef69efb0f9fc54e7e5f92b5496114e61846617a18c11cc308a687519c63 +18c5e96194e61846697a18e53986519e1e46658e6154a687519d6318d5e961d4e618466d7a18f53986514f285ff32ca385a43a3ad7429a50490bf32c +a585845a5a9867312d2454d3c23ccb6921a19e16e659500b0915b530cf925a48a8a9857916d54242552dccb3ac1612ea6a619e85b5905059837956d6 +20a1b206f3acac41d231ea5c0f52132a6b30cfca1a2454d6609e953548a8acc13c2b6b905059837956d620a1b206f3acac4142650de659598384ca1a +ccb3b2060995b538cfca5a4ca8acc57956d66242652dceb3b216939effcfb5019050598bf3acacc584ca5a9c67652d2654d6e23c2b6b31a1b216e759 +598b0995b538cfca5a4ca8acc57956d66242652dcdb3b296122a6b699e95b59450594bf3acaca584ca5a9a67652d25f556e7da5c4da8aca57956d652 +42652dcdb3b296122a6b699e95b59450594bf3acaca584ca5a9a67652d2554d6f23c2b6b39a1b296e75959cb0995b53ccfca5a4ea8ace57956d67242 +652dcfb3b296937e6f35d75f5c2554d6f23c2b6b39a1b296e75959cb0995b53ccfca5a4ea8ace57956d6724265adccb3b256122a6b659e95b5925059 +2bf3acac9584ca5a996765ad2454d6ca3c2b6b25a1b256e659592b497f1330d73f0a48a8ac957956d64a4265adccb3b256122a6b659e95b5925059ab +f3acacd584ca5a9d6765ad2654d6ea3c2b6b35a1b256e75959ab0995b53acfca5a4da8acd57956d66a4265adceb3b25693fede6aae7f70955059abf3 +acacd584ca5a9d6765ad2654d6da3c2b6b2da1b2d6e659596b0995b536cfca5a4ba8acb57956d65a4265adcdb3b2d6122a6b6d9e95b59650596bf3ac +acb584ca5a9b6765ad25fd2deb5cff9835a1b2d6e659596b0995b53ecfca5a4fa8acf57956d67a4265adcfb3b2d6132a6b7d9e95b59e5059ebf3acac +f584ca5a9f6765ad2754d6fa3c2b6b3da1b2d6e75959eb0995b53ecfca5a4f7a9dc05c5f287050590fbd5dc7f80bc21e7beff5effb64f87d747afefe +0bc2e29f44fe14deaf6363a73fcebd9deed579838c91317b2f9a0b4369f5de5be6f49556f742b3bbb5917fa07969abf948b3bd9adf6a6d75b71adb9d +f0d20756f3db8d4ef79ecde8d573abf98ded76efdbc18be646e23cfc8e261fbd1094d6aebb69253a0d5e3497f049f64f2159bd91fe3abeab493469a7 +b7377baf61dc3bd36e36fb77f7d2ddedbdd37ba7dabdcbefbf74aa3fa385c18cf65f1f195eb0bf59efc7d6a5de376b87ae7e7eb049efed4ef6fa5bc6 +c70e0ef69668bef2f7ec34ce37f385831b1abb95e8ffbd0bf96eef211bbd0c73e7fceae096a34bd7466fa2b337082bbcf1e4188a8318eeef4df7a9ed +c646f3c2eef666b39d0f5244727677f34ab867f7f7e3278b67ef68f7f21de1158edfda381a5db17766e3e85ef4a51d7de91ebf0c0000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f014ddbad638da3d1e7d6df7be +ee1dbf75ff4b3bfad23dfeda66732fbfd5cd5f6c6d36dbf9ee23bbf99dadd6c56eb37364c255d7f68e762fdfb1bb79a5f76d67eff88dfdffcfb49bcd +de591ba7b7377bdfb476c3218edf78eab5d75d7ff34ae47b83e30fbee93337bcf5e5d7acac44a75786e7eded75d77a1b9edededa6c76d61e6a37b73b +23e7148e5cded93e129d79eb6de177f94bcd76676bb7756cb570647d35df6c6dec6e6eb5ce1f5bfdea33276fa9ade63bdd466bb3b1bddb6a1e5bbdd2 +ecacde1686f74073bbd10dafd3b9b0b5d7c98783b43ac7562f74bb7b47d7d63a1b179a3b8dce91ddbd662bbce4dc6e7ba7d10d7f6c9f5fdb6b6c3cdc +38df5c0bd6d72b6bedd131560f0d9abf67f3d86afb9ecdc26afecc95bd669ac177cf9ddbda68deb9bb7171a7d9ea4eb88db54e74efc3011bedf3cdee +b1d52347fae7ec5fd04bcbea5a94fcb1bb7738ddefbce1c5377c5198eae8f482fd746fecee4437da197cd31b2b7376c309dfb9b7d3ede7f5e85eaacc +b69b9df0767b51ef6cf7effc4e63abb57abc375abe71b17b61b71d25350c6233bcf3c17a50b965bd70cb7a7066bd7874bd7474bd7c647d3dbc706bf3 +72186bef7a7bbbe1dc863f84e75ee9fdb7169ddb6d5eee1e3f71a1b9f170be7ba199ef5c3cdbddea6e37f38fecb6a37b76a4b73b47dbf4f6dc9dfdff +c2fb733891ad8d8ffdeeaf5db7b2129d9eb79fc8d8843c852476b637f753d84893c2cd76e39170d0b1eced5fbf7d55f6bfd5ab37a1bd7a705022a23b +dbba74777beff4dea976eff2fb2f9d6a8733194d64bed5d809d3d29fbbf082fdcd7a3fb62ef5be593b74f5f3834d7a59dceb6f191f3b188c7da637ff +85839b181bbfb7235dc8777b8fe28d6ebbb7f1eae056a3cbd74687ef0cae3ba8888da367c3ff7b678e96d87618c47623da079aad5bbefaf4ea5abfee +9ebed20a77caeed646fe9b2e36dadd667bfb4abeddbcb4d57c245ddd4dbabfc5c1fd3d3dd8e5831477397c7cf4eff2e091f594ef7af7f8a97673afd1 +6e6ee6c33da6f7201cbb7323193abb7f83d1a5e7b62e772fb69bf96eb3b1937d110affdf6edfd7d87bdda5de8d847b6f98e413bdb3f6c2474fffce8d +6cb2d67b1c1e7ee8bff5a5f52f695cbbb2129df2e30ffdfe6ad5b150dddbb8b27bb11b5faefae78f6d345cba2685165ccdd086871163918d1e6f1c3a +d2981a57f16ac6355881c7a29ab82ca758e27ff61d9f7bc14f3d7b65253a4d5a998a56a6849569bf4a9edc6a45a9e8ac7efe57aaeaf84a554951b6bb +576799ea1e1fa4e1a9af3fa5837b111e5dddb17b395f1cbb1ff968acc10a339eb1d1a8a3a82e9f6bef44ff873b467480572bd682f5fd83bc5a50ae04 +e5fe9a11de4c7e23bcbc5ca8152ad1061b57a203c79e5e6a8623edb53bddbb9bbb3bf9e89bf0d1dddce8ae46e7372e85077efd4d079becdfd97e44f1 +f4dd78287f0f342f355b179bf9f3ed436b7878957eb6f737dc6ae5c34ddbd17a7f3edc35e3f93e18394ad6f6a5ed83b5717f80d3cda90b69f4df85ed +add6c327b6b7c2e3def6d1ad4109ebdfb7fd4dbac7a3c5385c9c77dbdd4923758fe7775bf95e09397c4be7c2c74d34e83fbca37022b8b37857e99693 +e5db2bb794eea89eb8a57667fdae5bd64f166e0fee289e28dd59beeb5b56070717d150ad8b3babfdd183e1a0e77acf18fb777be27ed67be81c4f783c +ae0f76b7bbdbbb17f7f2f50c8fcb09fb5aa5d4df7da6ee6ca5fdfdabb7b315ca073b5be3e8c685d7f5c7e85ffbe0dcbb9ee49a07bbe9da30b0e4475a +e1a018bdee52633b3f78e673b860a47c788d853bb89b83f8a6061b7b4c35b7b7c352ddccf8b03af4a8eacd697800b9dd38dbdc4e5b9a362eb706bbcc +89e8db58da0e9e119cd86db5c212101e9d16864f0bf6af134bdfc85853327890ac3057d5f2708f182433184de693a4317c14a7c9e1dae0eede78b0db +f472b0b7b5b11fffd6c6e1fb5f1edcfdf0b2dea17658b1c363868d70911e3e2f39db68e7372e84cf4df2bbe7f64b55b88b9d6fef3ed2bd103eaca365 +a7f7900b7fec6f7790bfde2dc6b27710c7d970f738b9b5bddd9bf5f0fbb04a3577ce3607c780bd8475baed6677e342afe0849b3e10ce513f030717ac +8d0e9466c528adaf4f9890f559f7ee942bc65a7f06921fbe95c13cdc71b1db0d0b6e79755afd0e27a8b1d1ed1dbfededf5bf0b0f7e7a5b86c768df78 +71676fff10a037f853a801a365efc933564993b0dd8badcd07b2afb3870bc26ebebbdb7b2ed96d9cdd6e3ef5a395daa149a84e9e848409b8b0fb4834 +03b7455f8eb5c244f5fb775767426a95a73821b324f7fe68d05ef49fef27dedf77bafed813e193ee27129f78173df14ef7c43b98df13efad9d302f63 +41ed3437b71afdf30b47f65ae7e7f344fb4238523b7a7c0e638986ed84e3362f3776f6b69b47b65ae151d3d6e65a67b0e8ddd23f241e5ce3beddcd30 +98f0b0add90ed7bea961979ec6de7fefc720653be0dd1f7ff05ddff58c9595e834a91d1068074c6807e4a3021e1d8e2d75c7faaaf5011e181ceacdb4 +be9e6f37f62e6c6d9c6c87610f9eac0dcf99daa13e13dd463e3838fcdc3f9e3c3b78723c92dbf1d11a63b778efeec6c39d7c6b37ccf4b0733de15a87 +e7e5f0a533f61b2aa34b6fb574b0f40e07da0f72e4db3b1bdd46fe627b2bc303a83721fd67d067b7f7ff0b137a6e2b3c907a20da85c35df26cf8b8dd +ff7e6d7f93bbdb5b9bfd00b6364fec6ee7c30b8ba3071293cf5f1b5e37ea4774dbf90be1e5d5dec5bd817b772ae5de34692f1abd72b7ff8461ed60d8 +7483f7f796d9068ffe6b3fa5bb747fb8425c789aee51217859b6bbb3b6bf53ac8ded68a33fefb715461ea64ff634b174f8696271c2d3c4edddf3bb0b +f9cc6ffd49fa3785b1a77eeb0907ce07cfe59ebec3dd273ef5876f78ef352b2bd169fae16ee07077590f77930fdc1ebd58cafdfcf52b2bd1e925a3d3 +7f5f6f77ea8cfe7015fedaa03f90bf3998df11dca9edc646f3c2ee76f4475657e9682eb9db55ac940bf18a572e940f7e3f522806e572a538b5e4a5ff +3383e8d73c63f730cd9f1b440b60ba3f35c8f08ba0f17b5a2a960bc5622df99e4ea8ed371e14f7fcd9f38563abdbdd708fe95e0ebfdb7c383ad43a1f +44e705d17941745e10f58836a25fdf1e5bddff66704e3038e7609be2e09ce2e09cd2e09cd2e09cf2e09cf2e09ccae09cca6abed77c0a1f2efde7b8e7 +76b75fd33f63f0ddeadae0f1de2f8af76c467fdb347e4e7f5f2d94aaa55ab152aaaf0e3b8efb7be9b46dcbeb23db06fb1378e8a66edc3faf5f6e0ed7 +bc5feebcf8b66fb87665253abd7a52cd1b59f8462a9fe56fa1fecce2eaaecc57b501141ea0ee8cafccbd73fa5fd3fe85c5b5e73ef0b6c7c383b2e834 +b6324f4bfb535b99fb03fd756cb28cae6b87fe00a3bf44f6ff566764e57eba16eeab7818df9faec3fbcc973ff44b3f540ff797e8f4ea49fbcc48651b +d973fec656b67e758f978f6907bf291eb6d75c5b7bf6a7c3f47f3ac5c336f0b07db287edeed96f9cfca00df3913fb1dbea8657fe1bfad00d3c74afee +43f7333ffc9e0f9e79c6ca4a74ba797f0a0e2fdc9953dd38da1be4293f52f7ffbe7ad005ebb70fa391efda6ef6fe92b3775be14e777aa3777b93b60f +9f44f41a5eedf367c3bd357fa9b17d6c75ac0db5bf41f8b423b6ddc99efdedf637089f80c4b62b9c0ceac5eac178417fbcf876278b274b272b07e3f5 +36d87f0a13db367cbe55bceb8efd6d4736da7f8213bf4fe57aa5521fdb7e64fc626cfb3bebd5ea7a656cfbe270fb527cfb13412518dfbe34dcbe1cdb +be7aa278fb5d778e6d5f1e6e5f89c75fab17ee08c6b6af1cfc894072760e36193c274b0e66b8d5dac8ced3db95ce854536615fda697ce36efb64b84d +6f82c30757ab57b9cf85cfc98fad9e686c6f9d6d6fed3f456e8c5cb2ff97699d4367ad1d1a7067ab7595471f0eb8367adffa777527e99e464de1d3dd +2bdbcdde93d9309bbb61891934957b0fe3e64172f72e84dfeedfe4f87673bad25a3cdeedd6f84fd1af5d2ac572ff3710d96e62bb351caa1054d7afda +58f5f5a71cd7dae17bdc3c77aeb9d19d72cef0c783bf129a78e9d5df786d526467cf9f5cb2bded70c46b230fa7fddf128dad5207e71c5e859f77e6fd +6fb92bfc3e3a0d56e1c38ded7f7ceafebb9f73e30b6f0c2f7ece3dafb9f381f0ff6ba2d333af0dbffe93474e3f1afef7ec7beebcfdcce57fb171eabe +6f7dcecab52fbda6fbcf5f78fb87a21bb9e7aefbeffc7777bce1dbc76fb6f9919537ec4537b7327ccde2feef41ce375bcd76a3dbdc5cef1d018cfe35 +c2d56e1aaf8dbf9835fa7dcd78983ffdfb8f9ffb5cf87d747ae9a45fd78c07db3f3c7c1a0ef3e24779bdd7b47cde5f4773f8106ff6492e2cd324174c +72a6490e96699203939c69928bcb34c945939c69924bcb34c925939c6992cbcb34c965939c69922bcb34c915939c6992abcb34c955939c69926bcb34 +c935939c6992ebcb34c975939c38c92f9cd60c59b096d7cb92bb217a5e19a7b9b054d3aceb95719a83a59a667daf8cd35c5caa69d6f9ca38cda5a59a +66bdaf8cd35c5eaa69d6fdca38cd95a59a66fdaf8cd35c5daa69d601cb38cdb5a59a663db08cd35c5faa69d605cb36cdc15275c1025db08cd3bc545d +b040172ce3342f55172cd005cb38cd4bd5050b74c1324ef35275c1025db08cd3bc545db040172ce3342f55172cd005cb38cd4bd5050b74c1324ef352 +75c1025db08cd3bc545db040172cdb341797aa0b56d405cb38cd4bd5052bea82659ce6a5ea821575c1324ef35275c18aba6019a779a9ba60455db08c +d3bc545db0a22e58c6695eaa2e5851172ce3342f5517aca80b96719a97aa0b56d405cb38cd4bd5052bea82659be6d25275c14aba6019a779a9ba6025 +5db08cd3bc545db0922e58c6695eaa2e5849172ce3342f5517aca40b96719a97aa0b56d205cb38cd4bd5052be982659ce6a5ea829574c1324ef35275 +c14aba6019a779a9ba60255db06cd35c5eaa2e5859172ce3342f5517acac0b96719a97aa0b56d605cb38cd4bd5052beb82659ce6a5ea829575c1324e +f35275c1caba6019a779a9ba60655db08cd3bc545db0b22e58c6695eaa2e5859172ce3342f5517acac0b966d9a2b4bd505abe882659ce6a5ea825574 +c1324ef35275c12aba6019a779a9ba60155db08cd3bc545db08a2e58c6695eaa2e5845172ce3342f5517aca20b96719a97aa0b56d105cb38cd4bd505 +abe882659ce6a5ea825574c1b24d7375a9ba60555db08cd3bc545db0aa2e58c6695eaa2e5855172ce3342f5517acaa0b96719a97aa0b56d505cb38cd +4bd505abea82659ce6a5ea825575c1324ef35275c1aaba6019a779a9ba60555db08cd3bc545db0aa2e58b669ae2d5517aca60b96719a97aa0b56d305 +cb38cd4bd505abe982659ce6a5ea82d574c1324ef35275c16aba6019a779a9ba60355db08cd3bc545db09a2e58c6695eaa2e584d172ce3342f5517ac +a60b96719a97aa0b56d305cb36cdf5a5ea82d575c1324ef35275c1eaba6019a779a9ba60755db08cd3bc545db0ba2e58c6695eaa2e585d172ce3342f +5517acae0b96719a97aa0b56d705cb38cd4bd505abeb82659ce6a5ea82d575c1324ef35275c1eaba60fd69bee6da9baf1b26f0ddb73ef7cdd7dcbab2 +129dbe7865e84dd7445fbffec46eab1b86f350742f3a0f46631e1ee0596f7fc6afae871b47a72f181b200887ec4f422feb87aff8d953affa89879fb3 +b2129d6e1ebbe2771feb4fe4e88e32e9a65b5ff6fadffb507965253a7de9d808af786d7f84fecd1f1e6762388fbdf7faf77d32fc3e3a3d7f6cb0fa9b +fb836decee445373fbc5ee85dd766752401fbd1094d6aebb69253a8d07f4e0b7f7c74898acc383bde93337bcf5e56152a3d32bc706fbf5b7dc141bac +7f4f0f0d39f18ebef38617dff045e198d1e90563e3d6beeba6d13bda197c3331bcd6c6c77ef7d7ae5b59894ecf1b1be6cdffe8a6d1c76167ea3d7ceb +4beb5fd2b87665253ae5c786f8e6efb929fe50ee24dfaf9f7dc7e75ef053cf5e59894ee301bde8fbe201152705f47da7eb8f3d1106f3442ca0c77f74 +5a40c5a901bdfbe30fbeebbb9eb1b2129dc6037ae58fc7030a2605f4c4a7fef00def0de7293a8d07f427ff7a5a40c1d4801ebd58cafdfcf52b2bd1e9 +2563a33df6d323a3ddd7e8749bedcee80f13a7ef973b2fbeed1bc24c45a7578f0df7917f3b61b8911047069d18e8b5e73ef0b6c7c3bb1c9dc603fd96 +9f1b19f9dec695dd8bddcee80f1303fdf2877ee987eae150d1693cd077bc73c27023818e0c3a31d06baead3dfbd3e1a89f8e055afaf7c98106b305ba +fd0b69029d3ef59ff9e1f77cf04cb81f46a7f162fb895fec8fdc0d57a666ffebc4243eefccfbdf7257f87d741a1fe1e27bfa23ec3437b71a6b5b3be1 +125938b2d73a7f7884d1638af1c2f367efb969e211c6faa4404697fc978e0df383bf7c53c201c0fad4e44c0fecb7df3b39b0c26c815dfe4f49811532 +04f60bbf3239b060b6c01e7c5f52604186c07ef4fd93032bce16d81dff3929b06286c0def4ab93032bcd16d8cb7f2d29b05286c09abf3e39b0f26c81 +3dfb034981953304f655bf3139b0ca6c817dea379202ab64086ced372707569d2db00fff665260d50c81ddfc5b9303abcd16d87ffcada4c06a1902fb +f4072707569f2db07ff5db4981d55305f6c2b1111ffd2f530aec9396fe978d8df39dff35b1c2ae6708edfd1f9a125a61b6d0767e2731b44286d0fecd +87a78416cc16da998f2486166408ed077f774a68c5d942fb8adf4b0cad9821b437feb729a195660b6df5f713432b6508adf1d129a195670bed597f90 +185a394368af79744a6895d942fbe4a389a1553284b6f6475342abce16da47fe2831b46a86d09effc75342abcd16dabbff3831b45a86d0feeab129a1 +d5670bed27ff2431b42cabc19ffcf72947b533ae06dff33f120f6bb3ac06bff13fa78436e36ad0f958626859568377fee994d0665c0dbeeee389a165 +590dfed9ff9a12da8cabc1ed7f96185a96d5e04dff7b4a6833ae06aff8f3c4d0b2ac06e73f3125b4195783e77e3231b42cabc1fd9f9a12da8cabc15f +7e2a31b42cab41f92fa68436e36af0077f91185a96d5e0ef3c3e25b4195783f73d9e185a96d5e0ba27a68436e36af0334f2486966535f8f8ff99d24a +987135f8814f27f612b2ac06bffd9929a1cdb81a5cf96c6268595683fff0b929a1cdb81abce1af1243cbb21afcf8ff9d12da8cabc1ddff2f31b42cab +c177aee4268736e36a70e49a5c52685956839d6ba78436e36af0bceb1243cbb21a9cb97e4a6833ae069fbb3e31b42cabc157dc3025b4195783c76e48 +0c2dcb6ab0facc29a1cdb81a7ce09989a165590d9e75e394d0665c0dde716362685956834f7ec1e4d04a33ae063ff2eca4d04a5956838f3c674a6833 +ae06dff68589a165590ddefddc29a1cdb81a9cbb2931b42cabc14fe6a68436e36a70df172586966535f89e2f9e12da8cab41e9e6c4d0b2ac069de74d +096dc6d5e045cf4f0c2dcb6af0755f3225b41957836bff766268595683db5f3025b41957833f7d41626859568357bc684a6833ae061f7c5162685956 +83e7be784a6833ae06bff8e2c4d0b2ac067ff9772787569e7135f8b12f4d0aad9c6535f883fc94d0665c0ddef292c4d0b2ac06ef5b9d12da8cabc1f6 +972586966535f899974e096dc6d5e0f4cb1243cbb21afcc097e7123e9a3e7d68475f9e4bf7d1f1e943bbf28a5cc2c7a9a70fed257f2f97eee3ced387 +f68657e6123e023c7d68cf7c552edd4774a70fedee57e7123eb63a7d689f78752eddc74aa70fedc8915cc2472da70fedc34772e93e0a397d68cf5bcf +257c3c70fad0deb59e4bf7f1bde943fb5c2197f091b6e9437b5b904bf791b3e9437bac984bf818d6f4a17d772997ee6352d387f681722ee1a343d387 +d6aee4d27db467fad0de51cd257cdc65fad0beb6964bf77194e943fb917a2ee1231ad387f6954773e93e42317d68dff615b9848f154c1fdacb6fcda5 +fbd8bff4a19d3b964bf828bcf4a17de1f15cba8faa4b1fda7db7e5123ebe2d7d688fdf964bf7f16ae9432bdd9e4bf8c8b1f4a17df4f65cba8f044b1f +da8b4ee4123e262b7d68bf722297ee63acd28776ed5db9848f764a1fdadbefcaa5fbe8a5f4a1fde9c95cc2c711a50fedfbefcea5fbb8a0f4a17df035 +b9848fd0491fdae57b72e93ee2267d68bff855b9848f7d491fda43afcda5fb5896f4a1fdd8bdb9848f2a491fdac9fb72e93e4a247d686fb93f97f0f1 +1ae943bbe575b9741f7f913eb4ed53b9848f84481fdacd7f3f97ee231bd28776fa815cc2c718a40fedb30fe4d27dcc40fad08e9ec925bcf57efad0fe +f84c2edd5be3a70fed25afcf25bc5d7cfad07efdf5b9746fe79e3eb4677e4d2ee12dced387f6735f934bf716e4e943fbc4d7e612de963b7d68fff4eb +72e9de363b7d681ffefa5cc25b49a70fed5bbf2197eead9ed387f6ae0773096f7f9c3eb4e63fc8a57b7be2f4a1bdeda15cc25bf6a60fedde37e4d2bd +a56efad0bebb914b789bd9f4a115cfe6d2bd0d6cfad0da1bb984b7464d1fda0b3773e9deba347d685fdbcc25bc9d67fad0ae39974bf7769be943fbca +f3b984b7a04c1fdac7cee7d2bd4564fad05ebe954b78dbc4f4a1fdd6562edddb1aa60fed0b1fce25bcd55ffad07ee1e15cbab7e24b1fdae3dbb984b7 +a74b1fdabfdcc9a57bfbb8f4a17db4954b784bb5f4a17dc76e2edd5b9ea50fed57f672096f03963eb487bf2997ee6dbad287f6f6762ee1adabd287f6 +402797eeada5d287f6fddd5cc2db2da50fad7e3197eeed90d28776f9522ee12d82d287967f2497ee2d7cd287f6d0e55cc2dbdaa40fed195772e9de76 +267d6827df984b782b96f4a1fdf91b73e9de2a257d68b77c732ee1ed43d287f63bdf9c4bf7f61ea75efbb76e88aef268f8efb13b56566efed65cf8d3 +ff07 diff --git a/fixtures/enterprise-documents/pptx-review/undeclared-r.deflate.hex b/fixtures/enterprise-documents/pptx-review/undeclared-r.deflate.hex new file mode 100644 index 0000000..f86db81 --- /dev/null +++ b/fixtures/enterprise-documents/pptx-review/undeclared-r.deflate.hex @@ -0,0 +1,63 @@ +ed5c5b88245719ee9dacbb71dc80c6dc31b12c6204939ebad7740f3bb34edf36c3eece0c339327b36caaababbb2b5b5d555655cfc5180982880fde1e +f4417c14820a8a4210042fc18822823e18d007a390171105f32021890ffee79cba9eaebecc74cf6c6266a0a6ab4ffde73bfff9cff96fa7cee9cd2bb7 +9dbdab80febe2cae5cffde3f1f796de37ca180ae0f42d927ab8e1d18767063e7c035fceb0bfb3debe225f8c7ec1a9e6f3af6322b2cf02c63d8bad332 +edce32fbc44ea35862193fd0ec966639b6b1cc1e183e7b6965fe228660a0b2ed2fb3dd20709738ced7bb464ff3171cd7b0e149dbf17a5a005fbd0ee7 +6afa4dad637022cfab9c4ed82806088305b09ad1d6fa56c0d4f7a19c70e21996cf3221c3a8b16556735dcbd4b5009e73bb768b6aa61836b10035318d +df355dff512060b9fc26d0a3e12d8ca8e8da1daaa2d9439d43e5a8ca06c8d3335b06b3a979c1bad60302ce7503cef50c1faa60fc85d1ade7f4cf69b7 +4ddd68397abf075516d2603d2bf375a1a79976dcf361dcf81614fae44398353b18f5502c88b79e05e9d6b0603b81e16f133e92fb990f49023d9623dd +e921043fba99392f51039372b2da0fba8ee71f131b21fa6473e59ae607602ed35f8e477b08f6644c5dd50e9c7ee0a7bf1c0f5304fbc84c89b792a900 +bc9341fe4f2f1c0c1337c96177b832bf99f1bfeffdeef95ff3670a0574bd0fca6e20a7c62107e51fd9ef6ea5bddb11fd6fc643b21428b3d602efbbd6 +125886c8653c38114d2d144d4e1b1405206b5ec708c093e679452cd04c3f69c1def8d70b3b2fdf5628a00b95e6c11c59c2ee521a8a8878499b440e2d +4fdb03f49e454480bc301bd677271aa4ccdc4e8160ae7cab454cc25aebaa1fac644b1813464d14e445b924a932f4cc5b32a371e4562e7203d543c434 +5684a2a8a9ea22aa1e3d8e4b2516934a3c9f7e4c6aa71b9793c633cd6e7f9ad1f7615044a10cbd8491d10f9659b5a494f80891782b42163dc0546541 +960915c232f683b00370c7f43d73997d460139084aad5c94d4c6625196ea6271b522368a9532bfda10d58a522fc9cfb2504790977c4347d2068c68a0 +047960a87aa6ee39bed30e90c308e732e73a7b86e73a269ef0029f0c5482cad8d8f86cc0489b2864444279466d0875be2aaf16858a582dca35a95e2c +35ca7c71157d976a725d69a83177c90045dfe221c282cdd070a9a67319a9198166a2b01a33c2872d1651934599578562492c4bc555b9a254d5da621d +d81ac748340128ee4ae3b9e3b2d2c7b3048630fa247385cbea226d061e7af2d267fe0226005d1f0ecd00b1b1b4317837dadc54109118dcd1210c3784 +2f71e67c511c65d290615c4827c48534920bf984b8104772a1cc920b1ccb245c0cc449c3985067c94436164fb8c9c900c60708afbe78f6a57fc33dba +ee092dc320d0142182de233829c7319d874f6162230aac10a3bd7d60c35004a6ce6c19bba6b187b4d9b4cdc0d42cb042db5b2c638106afb5903b050f +6979f8960f1d6f8a4f5a46cf9f5d7f6b1d2ce77aca7a8e4845a71016469a5120e5cd64b6b1b31bb46dab85232077c7330cd2ddddcb9ebbed6e7af8f9 +faee261951211a5112e0c083908cc43bbbf886a3aa7722121c38b98472105b8c670b1a2f660d2d473142dc50a615f4e97699006b2ef2cfbd0e1bb58c +9e72e9267c37620b1a1fcd8314f1b08e877bd3d274a3eb582dc363c40938693aad031499e0793c8e1f7729d8af4085958bda12aa880bb52517fdf3d0 +bf6005455dcc9e19007ed7603ca303a3a7598cdfeff534ef60e1228788d07f0fff77e9fa570cc3654c88296dd48360cf617aa6dd879ee555e5128622 +5171f19cc07a88a70917ea02ad8acfbd79ee4b1f8324115d1fcf514512d4500af9ae8d6912dfb0b0c0e5870fa3bdc38fce3d78ee4e1035baeecf7a87 +ec92d7540e62b6ae81d1b01d5f23aea1059d1779512df24291177778698997977865016749a10e61ed72606ce10b941ee00fa23b28d0ae760dfd26d6 +0dbfdf0cccc032983dc7433d5bc0d3390ac6f55ef891e3426cfdef7ffa0db80f74dd1d0a726040a6102258a753b771126e63078fff240e430f3c4c3c +bd89f680094b4373c0b08b4f6cb31cb1bb49bcf3a9bee6416a621d80f54691cf6476772217b51d4df9493c13e807e9f20cbdd3a667b89a67b4189831 +5809339d4b49a81936889eb6cdfda0ef194c6068bda33b21f8b4bc6b9abbb18b1be9e1fcaf8a8b5cd09e306e4c48c8f20dadfa5f7ab87caf365728a0 +8bc9aa3ef156fea9a322ebd283ee6ae86afd89e4df491891e16cd45ba813c9c8230f9ce12aff4dd47817ff831ffef7fee72f140ae8caf34cd2a9671a +e199422bd9306d240a9fbdf59e6a31eba9d409cc76301b3715ac446298deffc8712f20baaa38fb8c94e90783b0220f9395589a6bc4d57edbeba14f98 +1828c02b4925910f83bc92a8a8a2427c065a14472be88a5012d468051dadb887ebec5c82e47a7e70d9707a0cba41fb2ff48045e5da2e047e84342209 +3b4b381a14df3c25bf2d63d7b0fb06d3f1281f0e5588b44342d36680d43b08b3b54179c7c84858d6ae15fbc61060db18ea48d147d732ed9b55cb84b8 +37f52683f42d24095648b6e83a5e908714ac308ecd601342b7d4b6c8f2f73315a12ad6a4ba5c6c28ab6a51ae2c568ba55ab95ee41bc2aa5891aa724d +a93fcbc6093840d9fd1e4bd0c504b48d3346d2eddc7986556765843ec66b48973da7ef32e523e865ce5c5365327d864e36399c5f78b2094a3cd9b425 +bdbb413048edb8b43ea6663c4db984b1d19a26c4c6686317b2fe28f3a10dc684ea956137ea66c4df50660774cab02c30d5c611d58ad22a3ca610405a +5ad3b026354dfabe1d4d992aba1d105b9c11541ddb061300d1a990a405619d01f1a5b08648301616c86a51496644244c312dcc3162042d9e44865cd4 +ddf978da90b7bba61ef26fea74ff95a8fbf00c87da60b12166d0bdf43a6c53f318bd0bb909e3b49385a58ee7ec055d506be476b0cac1574217cb0fb7 +3820bd988f264c8f86695978d4e11eac94d16b1aa917b1da921f7846a077b1c101d22d18232281f80197069ac463c83c9f3320fc6167f7841e832323 +305a7dd5681c2afd200083abb0c3ec370c90865f22a2cd04e40e821f4c0931dad3fd9e1bad84afefc6ad1cc506a4cdde7889a99308cce9dbadada3fb +59da20384ce0e05c32d09a96317db452a20661317f10460c40d7d943237009fd5bb6415064fd6e36035252a71c90c308771d8162ee6f75e2fd95edf2 +abaf43d2fdfac8c45b3a4dbc274bbcc5934bbcf126e10c533da3656aa45c5888760f1f7ba2dd05240fe967c20b82f501d7d8d77aae652c9836444d66 +8bf323a75724217154e39ad30266d096680f7cdfad7b572e4eb81cf0b37f5cffe917ce170ae8ca5b0e104f97037296031864c05138f68e5eb19ed93a +c05614ea1dcabf763ccded9a7ac303b6a3642d2919ba42bd83da60c438fc0ce3c966941ca7649b45d3322d5e75f49b3e633b20e964e53aa7163d2ef4 +d343ae37a869d7bb28c7ae37010a994cddd6b440233bf80ead4078404806ddb4c20f1068db84406a0b4d6198924dd0dbf09e0b492e7b668b3060b6aa +8ec5ece10d6d4920915fce2575d17a44e0315d78be881f6360dca9096753de2c4a570e48c2c0c5b0938193d9723870f4e14dd5a575f010dd63ea9120 +7ef468dde1c249c165265afa7bb8ac9052d37169a24ca789524e9a68391de76d99f9f163d66f844ceac78f089ce35ceef8c2ddd75ffbeb532f9e2914 +d0353cdc154fc3dd776ab83b3a707ba52f7fe085b38502ba3e921efe617b58a70be208d0e99e83938be0d2dbc46614cd8d5eed92544518b4788aa0c4 +ef4704495414551a6af226df66805ef31ce346b823bc08caf654961441924aa37b9a63dbe763e3ce343bc2326b053063827db86bdd44a15647446522 +2a13519988d68874f4fa76990d6fa212312a8969a4a8448a4ae4a8448e4a94a844894ad4a8446519bcf804ea4272dcb6633d4e0aa2bbf0ec08e83b31 +8ae943305149f6404d993e50339c56e1e9d333dc4053f3990339b4cdfb85ffe0a527e70a05743d9667f3528e2f65f94edddfdb6a9bc56c3db3748cfb +fd81a9dc2dffa33df35cfbb7dffe0f0465e8ca78e661629fce3313a0ffc74596b45fa33660101749f6eaa43cf77139ee1986f164b8e839f3c88d9f7c +bd0cf3055d8fe5cd9994654bcd9cd3d35b94f91879806bb4da9e992b5d7803c4ffc6046a2b9eaaed38b5759a4fe72b2dc8233a5efe2e555df1547567 +abba6f7ee3e77fd8395f28a02b3af54e3bee238b5a5bc220536b2a75688d2c1f22e4ba65e09d9cb82d9874db3a6e2f8f1e9208bce0e5759a305b995d +cd5a6633cb502101a41d03740dfc17d2850490800cd0090db12c2dc67822c11ba46b480db9a1c67898204c61066821df92ea95903645142638837d52 +caaa5aced0a7f0a501fa5a7971915733f452422f0fd2574555ccd2cb09bd3240bf589556ebb50cbd92d0ab83fc97ca4245ccd0abf11681d1d28949a2 +9c6c34330915979a3c782ab5c1c88e984b3ded69c76b000d1e60502e1b5bee36e4e4cb6c55b3cca6678629b2967a12ee4cf3a9228e02ec99f68cd113 +402edd37d2d5dea89ea245e1ede0c03270320bd274c0c4448bca588d8d58b86e176ec326b3742754891be4d7b2b3dfd06b175552c81b88a33561d909 +94202ef233c32af353f3c5d13d36da6d430f8694245fe35d42b94f674fcce571d6ec34de61b38de6984ba953f89628e3a5e212da0bdfbdf3abcfd7e1 +1e5d9117a617b6bfb8b97ef98ef907e6e1f11d6b8fd7b6e0f30cba6e9f83ff5fdddb7e053e2eacd55677f6bfa56f5efbec1d85b987cf04df7c60f58f +a891b5fa7aedfb95a73eb779e5ccdc5db70dff35bfe8ef39049df7db7e3400fd734409c073e7333f4e4457a47f6e27a9f8e7dbf37f7c8746a07fa923 +41f8db85b1bfdb4183d187fb13b037df3feca83f8d411f7e4f301eba73ec51781a8c3ebe9b807de7eec31ce6a571e973aa09ee7df78e38b54ac3d0a7 +341318edbe2167366908fab45702f1e487c69ffda2d1e8c33909da5b0f0d39aa4343d0bbe01288971f1dbf278e46a3b7072568f3dc90cd423404fd9e +328178491dffd69246a35f7b2568bf2c4df0128c86a3579413b81f2f1f6e7d9946a6570113e4eb9f98604d9086a3b3cc04ee6b95c32d17d1c8f4ba47 +827c576d825590c919bdda385c724c23d3595e82fcfbcbf9391f8d407ba804e1898d7c7fb579e53de70ad89ade53b80977bfdb40dffe07 diff --git a/tools/ci/test-budget.json b/tools/ci/test-budget.json index b3303fa..9f5cc7e 100644 --- a/tools/ci/test-budget.json +++ b/tools/ci/test-budget.json @@ -405,6 +405,25 @@ "source": "crates/bran-document/tests/pdf.rs", "support_fixtures": [] }, + { + "stable_id": "P3-PPTX-ADAPTER", + "phase": 3, + "slice": "3.4", + "runner": "rust", + "name": "pptx_round_trip_preserves_structure", + "category": "conformance", + "classification": "new", + "source": "crates/bran-document/tests/pptx.rs", + "support_fixtures": [ + "fixtures/enterprise-documents/conformance/pptx-design.parts", + "fixtures/enterprise-documents/conformance/pptx-ordinary.parts", + "fixtures/enterprise-documents/pptx-review/compressible-text.deflate.hex", + "fixtures/enterprise-documents/pptx-review/png-metadata-canary.deflate.hex", + "fixtures/enterprise-documents/pptx-review/shared-notes-amplification.deflate.hex", + "fixtures/enterprise-documents/pptx-review/undeclared-r.deflate.hex", + "fixtures/enterprise-documents/conformance/pptx-slides.parts" + ] + }, { "stable_id": "P4-SEALED-RELEASE", "phase": 4,