diff --git a/src-tauri/src/pdf_engine/mod.rs b/src-tauri/src/pdf_engine/mod.rs index 208d9fb..9e7a506 100644 --- a/src-tauri/src/pdf_engine/mod.rs +++ b/src-tauri/src/pdf_engine/mod.rs @@ -24,8 +24,11 @@ pub mod overlay; pub mod poster; pub mod qpdf; pub mod render; +pub mod source_content; #[cfg(test)] mod source_edit_fixtures; +#[cfg(test)] +mod source_content_integ; pub mod stamp; pub mod textexport; pub mod validate_output; diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs new file mode 100644 index 0000000..5bb2b59 --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -0,0 +1,1710 @@ +//! Read-only source-content classifier (#33). +//! +//! Walks page streams and Form XObjects on the original source path. Does not +//! write a dest, call `Document::replace_text`, or apply page `/Rotate`. + +use crate::error::AppError; +use crate::pdf_engine::crop; +use lopdf::{content::Content, Dictionary, Document, Object, ObjectId, Stream}; +use std::collections::HashMap; +use std::path::Path; + +const FILE_CAP_BYTES: u64 = 400 * 1024 * 1024; +const MAX_FORM_DEPTH: usize = 8; +const MAX_STREAM_BYTES: usize = 32 * 1024 * 1024; +const MAX_DECODED_TOTAL: usize = 64 * 1024 * 1024; +const MAX_OPS: usize = 50_000; +const MAX_OCCURRENCES: usize = 5_000; +const MAX_GSTATE_STACK: usize = 64; + +const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; +const FNV_OFFSET: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + Text, + Image, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceCapability { + Supported, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceRect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceOccurrence { + pub page_index: u32, + pub kind: SourceKind, + pub rect: SourceRect, + pub locator: String, + pub capability: SourceCapability, + pub reason: Option, +} + +pub fn classify_source_content(path: &Path) -> Result, AppError> { + let (doc, fp) = open_source(path)?; + classify_doc(&doc, fp) +} + +pub fn resolve_source_locator( + path: &Path, + locator: &str, +) -> Result { + if !path.is_file() { + return Err(AppError::invalid_pdf(&path_str(path))); + } + let meta = std::fs::metadata(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + if meta.len() > FILE_CAP_BYTES { + return Err(file_too_large()); + } + let bytes = std::fs::read(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + let fp = fnv1a_u64(&bytes); + let loc_fp = parse_locator_fp(locator).ok_or_else(stale)?; + if loc_fp != fp { + return Err(stale()); + } + classify_source_content(path)? + .into_iter() + .find(|o| o.locator == locator) + .ok_or_else(stale) +} + +fn open_source(path: &Path) -> Result<(Document, u64), AppError> { + if !path.is_file() { + return Err(AppError::invalid_pdf(&path_str(path))); + } + let meta = std::fs::metadata(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + if meta.len() > FILE_CAP_BYTES { + return Err(file_too_large()); + } + let bytes = std::fs::read(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + let fp = fnv1a_u64(&bytes); + let doc = Document::load(path).map_err(|e| { + AppError::invalid_pdf(&path_str(path)).with_details(format!("lopdf: {e}")) + })?; + if doc.is_encrypted() { + return Err(encrypted()); + } + if document_is_signed(&doc) { + return Err(signed()); + } + if doc.catalog().is_err() { + return Err(malformed_content("The PDF catalog is missing or unreadable.")); + } + Ok((doc, fp)) +} + +fn classify_doc(doc: &Document, fp: u64) -> Result, AppError> { + let mut walker = Walker { + doc, + fp, + decoded_total: 0, + op_count: 0, + pending: Vec::new(), + paint_counts: HashMap::new(), + }; + let pages = doc.get_pages(); + let mut nums: Vec = pages.keys().copied().collect(); + nums.sort_unstable(); + for num in nums { + let Some(&page_id) = pages.get(&num) else { + continue; + }; + walker.walk_page(page_id, num.saturating_sub(1))?; + } + Ok(walker.finish()) +} + +struct Walker<'a> { + doc: &'a Document, + fp: u64, + decoded_total: usize, + op_count: usize, + pending: Vec, + paint_counts: HashMap, +} + +#[derive(Clone, Copy, Default)] +struct Flags { + inline_image: bool, + nested_form: bool, + type3: bool, + vertical: bool, + clipped: bool, + pattern: bool, + rotated: bool, + skewed: bool, + no_tounicode: bool, + ambiguous: bool, + masked: bool, + shared: bool, + geometry: bool, +} + +struct Pending { + page_index: u32, + kind: SourceKind, + rect: SourceRect, + locator: String, + flags: Flags, + paint_id: Option, +} + +#[derive(Clone)] +struct GState { + ctm: [f64; 6], + clip_active: bool, + clip_pending: bool, + fill_pattern: bool, + stroke_pattern: bool, + masked: bool, + font_name: Option>, + font_size: f64, + leading: f64, + hscale: f64, + tc: f64, + tw: f64, +} + +impl Default for GState { + fn default() -> Self { + Self { + ctm: IDENTITY, + clip_active: false, + clip_pending: false, + fill_pattern: false, + stroke_pattern: false, + masked: false, + font_name: None, + font_size: 0.0, + leading: 0.0, + hscale: 100.0, + tc: 0.0, + tw: 0.0, + } + } +} + +impl GState { + fn pattern(&self) -> bool { + self.fill_pattern || self.stroke_pattern + } + + fn clipped(&self) -> bool { + self.clip_active || self.clip_pending + } +} + +struct TextState { + tm: [f64; 6], + tlm: [f64; 6], +} + +impl Default for TextState { + fn default() -> Self { + Self { + tm: IDENTITY, + tlm: IDENTITY, + } + } +} + +struct FormInfo { + matrix: [f64; 6], + bytes: Vec, + has_resources: bool, +} + +struct TextInspect { + type3: bool, + vertical: bool, + no_tounicode: bool, + ambiguous: bool, + width: f64, + font_id: ObjectId, +} + +impl Walker<'_> { + fn walk_page(&mut self, page_id: ObjectId, page_index: u32) -> Result<(), AppError> { + let geom_unsafe = page_geom_unsafe(self.doc, page_id); + let owners = page_resource_owners(self.doc, page_id); + let ids = self.doc.get_page_contents(page_id); + if ids.is_empty() { + return Ok(()); + } + let contents_id = ids[0]; + let mut bytes = Vec::new(); + for id in &ids { + let stream = self + .doc + .get_object(*id) + .ok() + .and_then(|o| o.as_stream().ok()) + .ok_or_else(|| malformed_content("A page content stream is unreadable."))?; + let chunk = decompress_stream(stream)?; + self.add_decoded(chunk.len())?; + if !bytes.is_empty() { + bytes.push(b'\n'); + } + bytes.extend_from_slice(&chunk); + } + let mut visiting = Vec::new(); + self.walk_stream( + page_index, + contents_id, + &bytes, + &owners, + GState::default(), + 0, + &mut visiting, + geom_unsafe, + ) + } + + fn walk_stream( + &mut self, + page_index: u32, + contents_id: ObjectId, + bytes: &[u8], + resource_owners: &[ObjectId], + mut gs: GState, + form_depth: usize, + visiting: &mut Vec, + geom_unsafe: bool, + ) -> Result<(), AppError> { + let inline_total = count_inline_images(bytes); + // BI…EI: do not trust a prefix Content::decode Ok; strip payloads first. + let stripped = if inline_total > 0 { + Some(strip_inline_image_payloads(bytes)) + } else { + None + }; + let decode_src = stripped.as_deref().unwrap_or(bytes); + let ops = match Content::decode(decode_src) { + Ok(c) => c.operations, + Err(_) => { + return Err(malformed_content( + "A page content stream could not be decoded.", + )); + } + }; + self.bump_ops(ops.len())?; + + let mut ts = TextState::default(); + let mut stack: Vec = Vec::new(); + let mut inline_emitted = 0usize; + let nested = form_depth > 0; + + for (op_index, op) in ops.iter().enumerate() { + match op.operator.as_str() { + "q" => { + if stack.len() < MAX_GSTATE_STACK { + stack.push(gs.clone()); + } + } + "Q" => { + if let Some(prev) = stack.pop() { + gs = prev; + } + } + "cm" => { + if let Some(m) = six_nums(&op.operands) { + gs.ctm = mul(gs.ctm, m); + } + } + "W" | "W*" => { + gs.clip_pending = true; + } + "n" => { + if gs.clip_pending { + gs.clip_active = true; + gs.clip_pending = false; + } + } + "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => { + if gs.clip_pending { + gs.clip_active = true; + gs.clip_pending = false; + } + } + "cs" => { + gs.fill_pattern = + operand_selects_pattern_space(self.doc, resource_owners, &op.operands); + } + "CS" => { + gs.stroke_pattern = + operand_selects_pattern_space(self.doc, resource_owners, &op.operands); + } + "scn" | "sc" => { + if gs.fill_pattern || is_pattern_name(&op.operands) { + gs.fill_pattern = true; + } + } + "SCN" | "SC" => { + if gs.stroke_pattern || is_pattern_name(&op.operands) { + gs.stroke_pattern = true; + } + } + "gs" => { + if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) { + apply_extgstate_smask(self.doc, resource_owners, name, &mut gs); + } + } + "rg" | "g" | "k" => { + gs.fill_pattern = false; + } + "RG" | "G" | "K" => { + gs.stroke_pattern = false; + } + "BT" => { + ts.tm = IDENTITY; + ts.tlm = IDENTITY; + } + "ET" => {} + "Tf" => { + if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) { + gs.font_name = Some(name.to_vec()); + } + if let Some(size) = op.operands.get(1).and_then(obj_f64) { + gs.font_size = size; + } + } + "Tc" => { + if let Some(c) = op.operands.first().and_then(obj_f64) { + gs.tc = c; + } + } + "Tw" => { + if let Some(w) = op.operands.first().and_then(obj_f64) { + gs.tw = w; + } + } + "Tz" => { + if let Some(z) = op.operands.first().and_then(obj_f64) { + gs.hscale = z; + } + } + "TL" => { + if let Some(l) = op.operands.first().and_then(obj_f64) { + gs.leading = l; + } + } + "Td" => { + if let Some((tx, ty)) = two_nums(&op.operands) { + apply_td(&mut ts, tx, ty); + } + } + "TD" => { + if let Some((tx, ty)) = two_nums(&op.operands) { + gs.leading = -ty; + apply_td(&mut ts, tx, ty); + } + } + "Tm" => { + if let Some(m) = six_nums(&op.operands) { + ts.tm = m; + ts.tlm = m; + } + } + "T*" => { + let ty = -gs.leading; + apply_td(&mut ts, 0.0, ty); + } + "Tj" | "'" | "\"" | "TJ" => { + let mut show_ops = op.operands.as_slice(); + if op.operator == "\"" { + if let Some(aw) = op.operands.first().and_then(obj_f64) { + gs.tw = aw; + } + if let Some(ac) = op.operands.get(1).and_then(obj_f64) { + gs.tc = ac; + } + if op.operands.len() >= 2 + && obj_f64(&op.operands[0]).is_some() + && obj_f64(&op.operands[1]).is_some() + { + show_ops = &op.operands[2..]; + } + } + if op.operator == "'" || op.operator == "\"" { + let ty = -gs.leading; + apply_td(&mut ts, 0.0, ty); + } + let (pieces, adj) = text_pieces(show_ops); + self.emit_text( + page_index, + contents_id, + op_index as u32, + resource_owners, + &gs, + &mut ts, + &pieces, + adj, + nested, + geom_unsafe, + )?; + } + "Do" => { + let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) else { + continue; + }; + let Some(id) = lookup_xobject(self.doc, resource_owners, name) else { + continue; + }; + if xobject_is_form(self.doc, id) { + self.enter_form( + page_index, + id, + resource_owners, + &gs, + form_depth, + visiting, + geom_unsafe, + )?; + } else if xobject_is_image(self.doc, id) { + self.emit_image( + page_index, + contents_id, + op_index as u32, + id, + &gs, + nested, + geom_unsafe, + )?; + } + } + "BI" => { + self.emit_inline( + page_index, + contents_id, + op_index as u32, + &gs, + nested, + geom_unsafe, + )?; + inline_emitted += 1; + } + "EI" | "ID" => {} + _ => {} + } + } + + while inline_emitted < inline_total { + self.emit_inline( + page_index, + contents_id, + ops.len() as u32 + inline_emitted as u32, + &gs, + nested, + geom_unsafe, + )?; + inline_emitted += 1; + } + Ok(()) + } + + fn enter_form( + &mut self, + page_index: u32, + form_id: ObjectId, + resource_owners: &[ObjectId], + gs: &GState, + form_depth: usize, + visiting: &mut Vec, + geom_unsafe: bool, + ) -> Result<(), AppError> { + if form_depth >= MAX_FORM_DEPTH { + return Err(malformed_content( + "Form XObject nesting is deeper than 8.", + )); + } + if visiting.contains(&form_id) { + return Err(malformed_content("A Form XObject refers to itself.")); + } + let info = load_form(self.doc, form_id)?; + self.add_decoded(info.bytes.len())?; + let mut child_gs = gs.clone(); + child_gs.ctm = mul(gs.ctm, info.matrix); + visiting.push(form_id); + let mut child_owners = Vec::new(); + if info.has_resources { + child_owners.push(form_id); + } + child_owners.extend_from_slice(resource_owners); + let result = self.walk_stream( + page_index, + form_id, + &info.bytes, + &child_owners, + child_gs, + form_depth + 1, + visiting, + geom_unsafe, + ); + visiting.pop(); + result + } + + fn emit_text( + &mut self, + page_index: u32, + contents_id: ObjectId, + op_index: u32, + resource_owners: &[ObjectId], + gs: &GState, + ts: &mut TextState, + pieces: &[Vec], + tj_adj: f64, + nested: bool, + geom_unsafe: bool, + ) -> Result<(), AppError> { + let inspect = inspect_text( + self.doc, + resource_owners, + gs.font_name.as_deref(), + pieces, + tj_adj, + gs.font_size, + ); + let effective = mul(gs.ctm, ts.tm); + let sx = (effective[0] * effective[0] + effective[1] * effective[1]).sqrt(); + let sy = (effective[2] * effective[2] + effective[3] * effective[3]).sqrt(); + let height = (gs.font_size.abs() * sy).max(0.01); + let th = gs.hscale / 100.0; + let shown = inspect.width * th; + let width = (shown * sx).abs().max(0.01); + let tx = (inspect.width + spacing_advance(pieces, gs.tc, gs.tw)) * th; + let flags = Flags { + nested_form: nested, + type3: inspect.type3, + vertical: inspect.vertical, + clipped: gs.clipped(), + pattern: gs.pattern(), + rotated: is_rotated_tm(effective), + skewed: is_skewed_tm(effective), + no_tounicode: inspect.no_tounicode, + ambiguous: inspect.ambiguous, + masked: gs.masked, + geometry: geom_unsafe, + ..Flags::default() + }; + self.push(Pending { + page_index, + kind: SourceKind::Text, + rect: SourceRect { + x: effective[4], + y: effective[5], + w: width, + h: height, + }, + locator: encode_locator( + self.fp, + page_index, + SourceKind::Text, + contents_id, + op_index, + inspect.font_id, + ), + flags, + paint_id: None, + })?; + // Shown-width advance is Tm only; Tlm stays at the line origin. + ts.tm = mul(ts.tm, [1.0, 0.0, 0.0, 1.0, tx, 0.0]); + Ok(()) + } + + fn emit_image( + &mut self, + page_index: u32, + contents_id: ObjectId, + op_index: u32, + image_id: ObjectId, + gs: &GState, + nested: bool, + geom_unsafe: bool, + ) -> Result<(), AppError> { + let flags = Flags { + nested_form: nested, + clipped: gs.clipped(), + pattern: gs.pattern(), + masked: gs.masked || image_is_masked(self.doc, image_id), + geometry: geom_unsafe, + ..Flags::default() + }; + self.push(Pending { + page_index, + kind: SourceKind::Image, + rect: unit_square_bbox(gs.ctm), + locator: encode_locator( + self.fp, + page_index, + SourceKind::Image, + contents_id, + op_index, + image_id, + ), + flags, + paint_id: Some(image_id), + }) + } + + fn emit_inline( + &mut self, + page_index: u32, + contents_id: ObjectId, + op_index: u32, + gs: &GState, + nested: bool, + geom_unsafe: bool, + ) -> Result<(), AppError> { + let flags = Flags { + inline_image: true, + nested_form: nested, + clipped: gs.clipped(), + pattern: gs.pattern(), + masked: gs.masked, + geometry: geom_unsafe, + ..Flags::default() + }; + self.push(Pending { + page_index, + kind: SourceKind::Image, + rect: unit_square_bbox(gs.ctm), + locator: encode_locator( + self.fp, + page_index, + SourceKind::Image, + contents_id, + op_index, + (0, 0), + ), + flags, + paint_id: None, + }) + } + + fn add_decoded(&mut self, n: usize) -> Result<(), AppError> { + self.decoded_total = self.decoded_total.saturating_add(n); + if self.decoded_total > MAX_DECODED_TOTAL { + return Err(malformed_content("Decoded content exceeds 64 MB.")); + } + Ok(()) + } + + fn bump_ops(&mut self, n: usize) -> Result<(), AppError> { + self.op_count = self.op_count.saturating_add(n); + if self.op_count > MAX_OPS { + return Err(malformed_content( + "The page content has more than 50,000 operators.", + )); + } + Ok(()) + } + + fn push(&mut self, pending: Pending) -> Result<(), AppError> { + if self.pending.len() >= MAX_OCCURRENCES { + return Err(malformed_content( + "This PDF has more than 5,000 text or image occurrences.", + )); + } + if let Some(id) = pending.paint_id { + *self.paint_counts.entry(id).or_insert(0) += 1; + } + self.pending.push(pending); + Ok(()) + } + + fn finish(self) -> Vec { + let counts = self.paint_counts; + self.pending + .into_iter() + .map(|mut p| { + if let Some(id) = p.paint_id { + if counts.get(&id).copied().unwrap_or(0) > 1 { + p.flags.shared = true; + } + } + let (capability, reason) = pick_reason(&p.flags); + SourceOccurrence { + page_index: p.page_index, + kind: p.kind, + rect: p.rect, + locator: p.locator, + capability, + reason, + } + }) + .collect() + } +} + +fn pick_reason(flags: &Flags) -> (SourceCapability, Option) { + let code = if flags.inline_image { + Some("INLINE_IMAGE") + } else if flags.nested_form { + Some("NESTED_FORM") + } else if flags.type3 { + Some("TYPE3") + } else if flags.vertical { + Some("VERTICAL") + } else if flags.clipped { + Some("CLIPPED") + } else if flags.pattern { + Some("PATTERN") + } else if flags.rotated { + Some("ROTATED_TEXT") + } else if flags.skewed { + Some("SKEWED_TEXT") + } else if flags.no_tounicode { + Some("NO_TOUNICODE") + } else if flags.ambiguous { + Some("AMBIGUOUS_UNICODE") + } else if flags.masked { + Some("MASKED_IMAGE") + } else if flags.shared { + Some("SHARED_XOBJECT") + } else if flags.geometry { + Some("GEOMETRY") + } else { + None + }; + match code { + Some(c) => (SourceCapability::Unsupported, Some(c.to_string())), + None => (SourceCapability::Supported, None), + } +} + +fn page_geom_unsafe(doc: &Document, page_id: ObjectId) -> bool { + if crop::page_rotation(doc, page_id) != 0 { + return true; + } + if (crop::page_user_unit(doc, page_id) - 1.0).abs() > 1e-9 { + return true; + } + let mb = crop::media_box(doc, page_id); + if let Some(cb) = crop::crop_box(doc, page_id) { + if (cb[0] - mb[0]).abs() > 1e-6 || (cb[1] - mb[1]).abs() > 1e-6 { + return true; + } + } + false +} + +fn page_resource_owners(doc: &Document, page_id: ObjectId) -> Vec { + let mut out = Vec::new(); + let mut cur = Some(page_id); + let mut steps = 0; + while let Some(id) = cur { + if steps > 32 { + break; + } + steps += 1; + let Some(dict) = object_dict(doc, id) else { + break; + }; + if dict.get(b"Resources").is_ok() { + out.push(id); + } + cur = dict.get(b"Parent").ok().and_then(|o| o.as_reference().ok()); + } + out +} + +fn object_dict(doc: &Document, id: ObjectId) -> Option<&Dictionary> { + match doc.get_object(id).ok()? { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + } +} + +fn resources_of<'a>(doc: &'a Document, owner: ObjectId) -> Option<&'a Dictionary> { + match object_dict(doc, owner)?.get(b"Resources").ok()? { + Object::Dictionary(d) => Some(d), + Object::Reference(id) => doc.get_dictionary(*id).ok(), + _ => None, + } +} + +fn named_resource_entry<'a>( + doc: &'a Document, + owner: ObjectId, + category: &[u8], + name: &[u8], +) -> Option<&'a Object> { + let res = resources_of(doc, owner)?; + let cat = match res.get(category).ok()? { + Object::Dictionary(d) => d, + Object::Reference(id) => doc.get_dictionary(*id).ok()?, + _ => return None, + }; + cat.get(name).ok() +} + +fn lookup_xobject(doc: &Document, owners: &[ObjectId], name: &[u8]) -> Option { + for &owner in owners { + if let Some(Object::Reference(id)) = named_resource_entry(doc, owner, b"XObject", name) { + return Some(*id); + } + } + None +} + +enum FontRef<'a> { + Id(ObjectId), + Dict(&'a Dictionary), +} + +fn lookup_font<'a>(doc: &'a Document, owners: &[ObjectId], name: &[u8]) -> Option> { + for &owner in owners { + match named_resource_entry(doc, owner, b"Font", name) { + Some(Object::Reference(id)) => return Some(FontRef::Id(*id)), + Some(Object::Dictionary(d)) => return Some(FontRef::Dict(d)), + _ => {} + } + } + None +} + +fn font_dict<'a>(doc: &'a Document, font: &FontRef<'a>) -> Option<&'a Dictionary> { + match font { + FontRef::Id(id) => match doc.get_object(*id).ok()? { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + }, + FontRef::Dict(d) => Some(*d), + } +} + +fn xobject_subtype<'a>(doc: &'a Document, id: ObjectId) -> Option<&'a [u8]> { + object_dict(doc, id)? + .get(b"Subtype") + .ok()? + .as_name() + .ok() +} + +fn xobject_is_form(doc: &Document, id: ObjectId) -> bool { + xobject_subtype(doc, id) == Some(b"Form") +} + +fn xobject_is_image(doc: &Document, id: ObjectId) -> bool { + xobject_subtype(doc, id) == Some(b"Image") +} + +fn image_is_masked(doc: &Document, id: ObjectId) -> bool { + let Some(dict) = object_dict(doc, id) else { + return false; + }; + if dict.get(b"Mask").is_ok() || dict.get(b"SMask").is_ok() { + return true; + } + match dict.get(b"ImageMask") { + Ok(Object::Boolean(true)) => true, + Ok(Object::Integer(i)) if *i != 0 => true, + _ => false, + } +} + +fn load_form(doc: &Document, id: ObjectId) -> Result { + let stream = doc + .get_object(id) + .ok() + .and_then(|o| o.as_stream().ok()) + .ok_or_else(|| malformed_content("A Form XObject is unreadable."))?; + Ok(FormInfo { + matrix: matrix_from_dict(&stream.dict), + has_resources: stream.dict.get(b"Resources").is_ok(), + bytes: decompress_stream(stream)?, + }) +} + +fn matrix_from_dict(dict: &Dictionary) -> [f64; 6] { + let Ok(obj) = dict.get(b"Matrix") else { + return IDENTITY; + }; + let arr = match obj { + Object::Array(a) => a, + _ => return IDENTITY, + }; + if arr.len() < 6 { + return IDENTITY; + } + let mut m = IDENTITY; + for (i, item) in arr.iter().take(6).enumerate() { + match obj_f64(item) { + Some(n) => m[i] = n, + None => return IDENTITY, + } + } + m +} + +fn decompress_stream(stream: &Stream) -> Result, AppError> { + let data = match stream.decompressed_content() { + Ok(d) => d, + Err(_) => stream.content.clone(), + }; + if data.len() > MAX_STREAM_BYTES { + return Err(malformed_content( + "A content stream is larger than 32 MB decompressed.", + )); + } + Ok(data) +} + +fn inspect_text( + doc: &Document, + owners: &[ObjectId], + font_name: Option<&[u8]>, + pieces: &[Vec], + tj_adj: f64, + font_size: f64, +) -> TextInspect { + let font_ref = font_name.and_then(|n| lookup_font(doc, owners, n)); + let font_id = match &font_ref { + Some(FontRef::Id(id)) => *id, + _ => (0, 0), + }; + let dict = font_ref.as_ref().and_then(|f| font_dict(doc, f)); + let mut type3 = false; + let mut vertical = false; + let mut no_tounicode = false; + let mut ambiguous = false; + let mut width_sum = 0.0; + if let Some(font) = dict { + type3 = is_type3(font); + vertical = is_vertical(doc, font); + if is_cid_or_type0(font) { + if tounicode_usable(doc, font) { + ambiguous = true; + } else { + no_tounicode = true; + } + } + for piece in pieces { + width_sum += glyph_width_sum(doc, font, piece); + } + } else { + for piece in pieces { + width_sum += piece.iter().map(|&b| helvetica_width(b)).sum::(); + } + } + let size = font_size.abs().max(0.01); + TextInspect { + type3, + vertical, + no_tounicode, + ambiguous, + width: (width_sum + tj_adj) / 1000.0 * size, + font_id, + } +} + +fn is_type3(font: &Dictionary) -> bool { + font.get(b"Subtype").ok().and_then(|o| o.as_name().ok()) == Some(b"Type3") + || font.get(b"CharProcs").is_ok() +} + +fn is_cid_or_type0(font: &Dictionary) -> bool { + match font.get(b"Subtype").ok().and_then(|o| o.as_name().ok()) { + Some(b"Type0") | Some(b"CIDFontType0") | Some(b"CIDFontType2") => return true, + _ => {} + } + if font.get(b"CIDSystemInfo").is_ok() || font.get(b"DescendantFonts").is_ok() { + return true; + } + matches!( + font.get(b"Encoding").ok().and_then(|o| o.as_name().ok()), + Some(b"Identity-H") | Some(b"Identity-V") + ) +} + +fn is_vertical(doc: &Document, font: &Dictionary) -> bool { + if font.get(b"Encoding").ok().and_then(|o| o.as_name().ok()) == Some(b"Identity-V") { + return true; + } + if wmode_is_1(font) { + return true; + } + descendant_is_vertical(doc, font) +} + +fn wmode_is_1(font: &Dictionary) -> bool { + matches!(font.get(b"WMode").ok().and_then(obj_f64), Some(w) if (w - 1.0).abs() < 0.5) +} + +fn descendant_is_vertical(doc: &Document, font: &Dictionary) -> bool { + let items = descendant_font_objects(doc, font); + for item in &items { + let dict = match item { + Object::Dictionary(d) => d, + Object::Reference(id) => match doc.get_object(*id) { + Ok(Object::Dictionary(d)) => d, + Ok(Object::Stream(s)) => &s.dict, + _ => continue, + }, + _ => continue, + }; + if dict.get(b"Encoding").ok().and_then(|o| o.as_name().ok()) == Some(b"Identity-V") + || wmode_is_1(dict) + { + return true; + } + } + false +} + +fn descendant_font_objects(doc: &Document, font: &Dictionary) -> Vec { + match font.get(b"DescendantFonts") { + Ok(Object::Array(a)) => a.clone(), + Ok(Object::Reference(id)) => match doc.get_object(*id) { + Ok(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }, + _ => Vec::new(), + } +} + +fn tounicode_usable(doc: &Document, font: &Dictionary) -> bool { + let Ok(obj) = font.get(b"ToUnicode") else { + return false; + }; + let bytes = match obj { + Object::Stream(s) => s.get_plain_content().unwrap_or_else(|_| s.content.clone()), + Object::Reference(id) => match doc.get_object(*id) { + Ok(Object::Stream(s)) => s.get_plain_content().unwrap_or_else(|_| s.content.clone()), + _ => return false, + }, + _ => return false, + }; + let text = String::from_utf8_lossy(&bytes); + text.contains("begincmap") + && (text.contains("beginbfchar") || text.contains("beginbfrange")) +} + +fn glyph_width_sum(doc: &Document, font: &Dictionary, bytes: &[u8]) -> f64 { + if is_cid_or_type0(font) { + return (bytes.len() / 2) as f64 * descendant_dw(doc, font); + } + if let Some(widths) = explicit_widths(doc, font) { + return bytes.iter().map(|&b| widths[b as usize]).sum(); + } + bytes.iter().map(|&b| helvetica_width(b)).sum() +} + +fn descendant_dw(doc: &Document, font: &Dictionary) -> f64 { + if let Some(n) = font.get(b"DW").ok().and_then(obj_f64) { + return n; + } + for item in descendant_font_objects(doc, font) { + let dict = match &item { + Object::Dictionary(d) => d, + Object::Reference(id) => match doc.get_object(*id) { + Ok(Object::Dictionary(d)) => d, + _ => continue, + }, + _ => continue, + }; + if let Some(n) = dict.get(b"DW").ok().and_then(obj_f64) { + return n; + } + } + 500.0 +} + +fn explicit_widths(doc: &Document, font: &Dictionary) -> Option<[f64; 256]> { + // Helvetica table only when /Widths is truly absent (Standard-14). + let arr = font.get_deref(b"Widths", doc).ok()?.as_array().ok()?; + let first = font + .get_deref(b"FirstChar", doc) + .ok() + .and_then(|o| o.as_i64().ok()) + .unwrap_or(0) + .max(0) as usize; + let last = font + .get_deref(b"LastChar", doc) + .ok() + .and_then(|o| o.as_i64().ok()) + .map(|n| n.max(0) as usize) + .unwrap_or_else(|| first.saturating_add(arr.len().saturating_sub(1)).min(255)); + let mut widths = [500.0; 256]; + for (i, obj) in arr.iter().enumerate() { + let code = first + i; + if code > last || code > 255 { + break; + } + if let Some(n) = obj_f64(obj) { + widths[code] = n; + } + } + Some(widths) +} + +/// Standard-14 Helvetica widths per 1000. Locked: H=667, i=278. +fn helvetica_width(code: u8) -> f64 { + match code { + b' ' => 278.0, + b'!' => 278.0, + b'"' => 355.0, + b'#' => 556.0, + b'$' => 556.0, + b'%' => 889.0, + b'&' => 667.0, + b'\'' => 191.0, + b'(' => 333.0, + b')' => 333.0, + b'*' => 389.0, + b'+' => 584.0, + b',' => 278.0, + b'-' => 333.0, + b'.' => 278.0, + b'/' => 278.0, + b'0'..=b'9' => 556.0, + b':' => 278.0, + b';' => 278.0, + b'<' => 584.0, + b'=' => 584.0, + b'>' => 584.0, + b'?' => 556.0, + b'@' => 1015.0, + b'A' => 667.0, + b'B' => 667.0, + b'C' => 722.0, + b'D' => 722.0, + b'E' => 667.0, + b'F' => 611.0, + b'G' => 778.0, + b'H' => 667.0, + b'I' => 278.0, + b'J' => 500.0, + b'K' => 667.0, + b'L' => 556.0, + b'M' => 833.0, + b'N' => 722.0, + b'O' => 778.0, + b'P' => 667.0, + b'Q' => 778.0, + b'R' => 722.0, + b'S' => 667.0, + b'T' => 611.0, + b'U' => 722.0, + b'V' => 667.0, + b'W' => 944.0, + b'X' => 667.0, + b'Y' => 667.0, + b'Z' => 611.0, + b'[' => 278.0, + b'\\' => 278.0, + b']' => 278.0, + b'^' => 469.0, + b'_' => 556.0, + b'`' => 333.0, + b'a' => 556.0, + b'b' => 556.0, + b'c' => 500.0, + b'd' => 556.0, + b'e' => 556.0, + b'f' => 278.0, + b'g' => 556.0, + b'h' => 556.0, + b'i' => 278.0, + b'j' => 222.0, + b'k' => 500.0, + b'l' => 278.0, + b'm' => 833.0, + b'n' => 556.0, + b'o' => 556.0, + b'p' => 556.0, + b'q' => 556.0, + b'r' => 333.0, + b's' => 500.0, + b't' => 278.0, + b'u' => 556.0, + b'v' => 500.0, + b'w' => 722.0, + b'x' => 500.0, + b'y' => 500.0, + b'z' => 500.0, + b'{' => 334.0, + b'|' => 260.0, + b'}' => 334.0, + b'~' => 584.0, + _ => 556.0, + } +} + +fn is_rotated_tm(m: [f64; 6]) -> bool { + let [a, b, c, d, _, _] = m; + let det = a * d - b * c; + if det.abs() < 1e-6 { + return false; + } + // 90° / 270°: off-axis, a≈d, b≈-c. + if (b.abs() > 0.1 || c.abs() > 0.1) && (a - d).abs() < 0.25 && (b + c).abs() < 0.25 { + return true; + } + // 180°: axis-aligned invert. + a < 0.0 && d < 0.0 && b.abs() <= 0.05 && c.abs() <= 0.05 +} + +fn is_skewed_tm(m: [f64; 6]) -> bool { + let [a, b, c, d, _, _] = m; + if b.abs() < 0.05 && c.abs() < 0.05 { + return false; + } + let det = a * d - b * c; + if det.abs() < 1e-6 { + return false; + } + !is_rotated_tm(m) +} + +fn mul(m: [f64; 6], n: [f64; 6]) -> [f64; 6] { + let [a, b, c, d, e, f] = m; + let [a2, b2, c2, d2, e2, f2] = n; + [ + a * a2 + c * b2, + b * a2 + d * b2, + a * c2 + c * d2, + b * c2 + d * d2, + a * e2 + c * f2 + e, + b * e2 + d * f2 + f, + ] +} + +fn apply_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +fn unit_square_bbox(ctm: [f64; 6]) -> SourceRect { + let pts = [ + apply_point(ctm, 0.0, 0.0), + apply_point(ctm, 1.0, 0.0), + apply_point(ctm, 0.0, 1.0), + apply_point(ctm, 1.0, 1.0), + ]; + let min_x = pts.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let min_y = pts.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let max_x = pts.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let max_y = pts.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + SourceRect { + x: min_x, + y: min_y, + w: (max_x - min_x).max(0.01), + h: (max_y - min_y).max(0.01), + } +} + +fn apply_td(ts: &mut TextState, tx: f64, ty: f64) { + let m = mul(ts.tlm, [1.0, 0.0, 0.0, 1.0, tx, ty]); + ts.tm = m; + ts.tlm = m; +} + +fn obj_f64(obj: &Object) -> Option { + match obj { + Object::Integer(i) => Some(*i as f64), + Object::Real(r) => Some(*r as f64), + _ => None, + } +} + +fn six_nums(operands: &[Object]) -> Option<[f64; 6]> { + if operands.len() < 6 { + return None; + } + let start = operands.len() - 6; + let mut m = [0.0; 6]; + for i in 0..6 { + m[i] = obj_f64(&operands[start + i])?; + } + Some(m) +} + +fn two_nums(operands: &[Object]) -> Option<(f64, f64)> { + if operands.len() < 2 { + return None; + } + let n = operands.len(); + Some((obj_f64(&operands[n - 2])?, obj_f64(&operands[n - 1])?)) +} + +fn is_pattern_name(operands: &[Object]) -> bool { + operands + .iter() + .any(|o| o.as_name().ok() == Some(b"Pattern")) +} + +fn operand_selects_pattern_space( + doc: &Document, + owners: &[ObjectId], + operands: &[Object], +) -> bool { + let Some(obj) = operands.first() else { + return false; + }; + if color_space_object_is_pattern(doc, obj) { + return true; + } + let Ok(name) = obj.as_name() else { + return false; + }; + if name == b"Pattern" { + return true; + } + for &owner in owners { + if let Some(entry) = named_resource_entry(doc, owner, b"ColorSpace", name) { + return color_space_object_is_pattern(doc, entry); + } + } + false +} + +fn color_space_object_is_pattern(doc: &Document, obj: &Object) -> bool { + let resolved = match doc.dereference(obj) { + Ok((_, o)) => o, + Err(_) => return false, + }; + match resolved { + Object::Name(n) => n.as_slice() == b"Pattern", + Object::Array(arr) => arr.first().is_some_and(|first| { + let first = doc.dereference(first).map(|(_, o)| o).unwrap_or(first); + first.as_name().ok() == Some(b"Pattern") + }), + _ => false, + } +} + +fn apply_extgstate_smask(doc: &Document, owners: &[ObjectId], name: &[u8], gs: &mut GState) { + for &owner in owners { + let Some(entry) = named_resource_entry(doc, owner, b"ExtGState", name) else { + continue; + }; + let Some(dict) = deref_dict(doc, entry) else { + continue; + }; + let Ok(smask) = dict.get(b"SMask") else { + return; + }; + let resolved = doc.dereference(smask).map(|(_, o)| o).unwrap_or(smask); + gs.masked = resolved.as_name().ok() != Some(b"None"); + return; + } +} + +fn deref_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> { + match doc.dereference(obj) { + Ok((_, Object::Dictionary(d))) => Some(d), + Ok((_, Object::Stream(s))) => Some(&s.dict), + _ => None, + } +} + +fn text_pieces(operands: &[Object]) -> (Vec>, f64) { + let mut pieces = Vec::new(); + let mut adj = 0.0; + for obj in operands { + collect_text_obj(obj, &mut pieces, &mut adj); + } + (pieces, adj) +} + +fn collect_text_obj(obj: &Object, pieces: &mut Vec>, adj: &mut f64) { + match obj { + Object::String(s, _) => pieces.push(s.clone()), + Object::Integer(i) => *adj -= *i as f64, + Object::Real(r) => *adj -= *r as f64, + Object::Array(arr) => { + for item in arr { + collect_text_obj(item, pieces, adj); + } + } + _ => {} + } +} + +fn spacing_advance(pieces: &[Vec], tc: f64, tw: f64) -> f64 { + let mut extra = 0.0; + for piece in pieces { + extra += piece.len() as f64 * tc; + extra += piece.iter().filter(|&&b| b == b' ').count() as f64 * tw; + } + extra +} + +fn strip_inline_image_payloads(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len()); + let mut i = 0; + // Normal → Keys (after BI) → Payload (after ID). Copy BI/keys/ID/EI; + // drop only the raw bytes between ID and EI so Content::decode still + // yields BI at the CTM in force there. + let mut after_bi = false; + let mut after_id = false; + while i < data.len() { + if after_id { + if is_op_token(data, i, b"EI") { + out.extend_from_slice(b"EI"); + i += 2; + after_id = false; + after_bi = false; + } else { + i += 1; + } + continue; + } + if data[i].is_ascii_whitespace() { + out.push(data[i]); + i += 1; + continue; + } + if data[i] == b'%' { + let start = i; + while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { + i += 1; + } + out.extend_from_slice(&data[start..i]); + continue; + } + if data[i] == b'(' { + let start = i; + i = skip_literal(data, i); + out.extend_from_slice(&data[start..i]); + continue; + } + if data[i] == b'<' && data.get(i + 1) != Some(&b'<') { + let start = i; + i = skip_hex(data, i); + out.extend_from_slice(&data[start..i]); + continue; + } + if !after_bi && is_op_token(data, i, b"BI") { + out.extend_from_slice(b"BI"); + i += 2; + after_bi = true; + continue; + } + if after_bi && is_op_token(data, i, b"ID") { + out.extend_from_slice(b"ID"); + i += 2; + out.push(b' '); + after_id = true; + continue; + } + if after_bi && is_op_token(data, i, b"EI") { + out.extend_from_slice(b"EI"); + i += 2; + after_bi = false; + continue; + } + out.push(data[i]); + i += 1; + } + out +} + +fn count_inline_images(data: &[u8]) -> usize { + let mut count = 0; + let mut i = 0; + let mut in_inline = false; + while i < data.len() { + if data[i].is_ascii_whitespace() { + i += 1; + continue; + } + if !in_inline && data[i] == b'%' { + while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { + i += 1; + } + continue; + } + if !in_inline && data[i] == b'(' { + i = skip_literal(data, i); + continue; + } + if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') { + i = skip_hex(data, i); + continue; + } + if !in_inline && is_op_token(data, i, b"BI") { + in_inline = true; + i += 2; + continue; + } + if in_inline && is_op_token(data, i, b"EI") { + count += 1; + in_inline = false; + i += 2; + continue; + } + i += 1; + } + count +} + +fn is_delim(b: u8) -> bool { + b.is_ascii_whitespace() + || matches!( + b, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +fn is_op_token(data: &[u8], i: usize, token: &[u8]) -> bool { + if i + token.len() > data.len() || &data[i..i + token.len()] != token { + return false; + } + let before = i == 0 || is_delim(data[i - 1]); + let after = i + token.len() == data.len() || is_delim(data[i + token.len()]); + before && after +} + +fn skip_literal(data: &[u8], start: usize) -> usize { + let mut i = start + 1; + let mut depth = 1; + while i < data.len() && depth > 0 { + match data[i] { + b'\\' => { + i = i.saturating_add(2); + continue; + } + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + i += 1; + } + i +} + +fn skip_hex(data: &[u8], start: usize) -> usize { + let mut i = start + 1; + while i < data.len() && data[i] != b'>' { + i += 1; + } + if i < data.len() { + i + 1 + } else { + i + } +} + +fn document_is_signed(doc: &Document) -> bool { + if let Ok(cat) = doc.catalog() { + if cat.get(b"Perms").is_ok() { + return true; + } + } + for obj in doc.objects.values() { + if object_is_signature(obj) { + return true; + } + } + false +} + +fn object_is_signature(obj: &Object) -> bool { + let dict = match obj { + Object::Dictionary(d) => d, + Object::Stream(s) => &s.dict, + _ => return false, + }; + let is_sig = name_is(dict, b"Type", b"Sig") || name_is(dict, b"FT", b"Sig"); + is_sig && dict.get(b"ByteRange").is_ok() +} + +fn name_is(dict: &Dictionary, key: &[u8], expect: &[u8]) -> bool { + dict.get(key).ok().and_then(|o| o.as_name().ok()) == Some(expect) +} + +fn fnv1a_u64(bytes: &[u8]) -> u64 { + let mut hash = FNV_OFFSET; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +fn encode_locator( + fp: u64, + page_index: u32, + kind: SourceKind, + contents_id: ObjectId, + op_index: u32, + object_id: ObjectId, +) -> String { + let k = match kind { + SourceKind::Text => 0u8, + SourceKind::Image => 1u8, + }; + format!( + "v1:{fp:016x}:{page_index}:{k}:{}:{}:{op_index}:{}:{}", + contents_id.0, contents_id.1, object_id.0, object_id.1 + ) +} + +fn parse_locator_fp(locator: &str) -> Option { + let rest = locator.strip_prefix("v1:")?; + let hex = rest.split(':').next()?; + u64::from_str_radix(hex, 16).ok() +} + +fn path_str(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn file_too_large() -> AppError { + AppError::new( + "FILE_TOO_LARGE", + "File too large to classify", + "Classifying source content needs the document loaded into memory, and this file is over 400 MB.", + ) + .with_suggestion("Use a smaller PDF.") +} + +fn malformed_content(message: impl Into) -> AppError { + AppError::new( + "MALFORMED_CONTENT", + "This PDF content cannot be read", + message, + ) + .with_suggestion("Open the file in a PDF editor that can repair it, or use a different PDF.") +} + +fn encrypted() -> AppError { + AppError::new( + "ENCRYPTED", + "This PDF is encrypted", + "OffPDF cannot classify source content in an encrypted PDF.", + ) + .with_suggestion("Unlock the PDF and try again.") +} + +fn signed() -> AppError { + AppError::new( + "SIGNED", + "This PDF is signed", + "OffPDF cannot classify source content in a signed PDF.", + ) + .with_suggestion("Use an unsigned copy of the file.") +} + +fn stale() -> AppError { + AppError::new( + "STALE", + "This locator is stale", + "The source file no longer matches the locator fingerprint.", + ) +} diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs new file mode 100644 index 0000000..3c64efb --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -0,0 +1,1631 @@ +//! Read-only source-content classifier tests (#33). +//! +//! Production module is added by impl: `crate::pdf_engine::source_content`. +//! This file only imports the locked exports. Fail-today is a missing module. +//! +//! Locked surface (field names so impl can match): +//! +//! ```ignore +//! pub struct SourceOccurrence { +//! pub page_index: u32, +//! pub kind: /* enum or string: text | image */, +//! pub rect: /* { x, y, w, h } unrotated PDF user space */, +//! pub locator: String, +//! pub capability: /* enum or string: supported | unsupported */, +//! pub reason: Option, +//! } +//! pub fn classify_source_content(path: &Path) -> Result, AppError>; +//! pub fn resolve_source_locator(path: &Path, locator: &str) -> Result; +//! ``` + +#![cfg(test)] + +use crate::error::AppError; +use crate::pdf_engine::source_content::{ + classify_source_content, resolve_source_locator, SourceOccurrence, +}; +use lopdf::{Dictionary, Document, Object, Stream}; +use serde::Deserialize; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// Same 400 MiB gate as forms / links / outline. +const FILE_CAP_BYTES: u64 = 400 * 1024 * 1024; + +const FROZEN_REASONS: &[&str] = &[ + "NO_TOUNICODE", + "AMBIGUOUS_UNICODE", + "TYPE3", + "NESTED_FORM", + "ROTATED_TEXT", + "SKEWED_TEXT", + "VERTICAL", + "CLIPPED", + "PATTERN", + "SHARED_XOBJECT", + "INLINE_IMAGE", + "MASKED_IMAGE", + "ENCRYPTED", + "SIGNED", + "MALFORMED", + "STALE", + "GEOMETRY", +]; + +const STAND_INS: &[(&str, &str, &str)] = &[ + ("text-type3.pdf", "text", "TYPE3"), + ("text-nested-form.pdf", "text", "NESTED_FORM"), + ("text-rotated.pdf", "text", "ROTATED_TEXT"), + ("text-skewed.pdf", "text", "SKEWED_TEXT"), + ("image-in-form.pdf", "image", "NESTED_FORM"), + ("image-inline.pdf", "image", "INLINE_IMAGE"), + ("image-mask.pdf", "image", "MASKED_IMAGE"), +]; + +const GEOM_ONLY: &[&str] = &[ + "geom-crop-offset.pdf", + "geom-user-unit.pdf", + "geom-rotate-90.pdf", + "geom-rotate-180.pdf", + "geom-rotate-270.pdf", +]; + +#[derive(Debug, Deserialize)] +struct Manifest { + fixtures: Vec, +} + +#[derive(Debug, Deserialize)] +struct FixtureRow { + id: String, + path: String, + intent: String, +} + +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-classify-{}-{}-{}", + name, + std::process::id(), + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn file(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn corpus_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("fixtures") + .join("source-edit") +} + +fn fixture(name: &str) -> PathBuf { + let path = corpus_dir().join(name); + assert!( + path.is_file(), + "committed fixture {} must exist under fixtures/source-edit/", + name + ); + path +} + +fn load_manifest() -> Manifest { + let path = corpus_dir().join("manifest.json"); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("fixtures/source-edit/manifest.json must be readable: {e}")); + serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("fixtures/source-edit/manifest.json must parse: {e}")) +} + +fn debug_token(v: &T) -> String { + format!("{v:?}") + .trim_matches('"') + .split("::") + .last() + .unwrap_or("") + .trim() + .to_ascii_lowercase() +} + +fn kind_token(occ: &SourceOccurrence) -> String { + debug_token(&occ.kind) +} + +fn capability_token(occ: &SourceOccurrence) -> String { + debug_token(&occ.capability) +} + +fn reason_code(occ: &SourceOccurrence) -> Option { + occ.reason.as_ref().and_then(|r| { + let trimmed = r.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn classify(path: &Path, must_id: &str) -> Vec { + classify_source_content(path).unwrap_or_else(|e| { + panic!( + "{must_id}: classify_source_content({}) must succeed: {e}", + path.display() + ) + }) +} + +fn first_of_kind<'a>( + hits: &'a [SourceOccurrence], + kind: &str, + must_id: &str, +) -> &'a SourceOccurrence { + hits.iter() + .find(|o| kind_token(o) == kind) + .unwrap_or_else(|| { + panic!( + "{must_id}: expected a {kind} occurrence; got {:?}", + hits.iter() + .map(|o| (kind_token(o), capability_token(o), reason_code(o))) + .collect::>() + ) + }) +} + +fn assert_supported_text_or_image(occ: &SourceOccurrence, kind: &str, must_id: &str) { + assert_eq!(kind_token(occ), kind, "{must_id}: kind must be {kind}"); + assert_eq!( + capability_token(occ), + "supported", + "{must_id}: capability must be supported; got {} reason={:?}", + capability_token(occ), + reason_code(occ) + ); + assert!( + reason_code(occ).is_none(), + "{must_id}: supported must not carry a refuse reason; got {:?}", + reason_code(occ) + ); + assert!( + !occ.locator.trim().is_empty(), + "{must_id}: locator must be a non-empty opaque string" + ); + assert!( + occ.rect.w > 0.0 && occ.rect.h > 0.0, + "{must_id}: rect w/h must be positive; got w={} h={}", + occ.rect.w, + occ.rect.h + ); +} + +fn assert_unsupported(occ: &SourceOccurrence, kind: &str, reason: &str, must_id: &str) { + assert_eq!(kind_token(occ), kind, "{must_id}: kind must be {kind}"); + assert_eq!( + capability_token(occ), + "unsupported", + "{must_id}: capability must be unsupported; got {}", + capability_token(occ) + ); + assert_ne!( + capability_token(occ), + "supported", + "{must_id}: must never be supported" + ); + assert_eq!( + reason_code(occ).as_deref(), + Some(reason), + "{must_id}: reason must be {reason}; got {:?}", + reason_code(occ) + ); + assert!( + FROZEN_REASONS.contains(&reason), + "{must_id}: {reason} is not a frozen reason code" + ); + assert!( + !occ.locator.trim().is_empty(), + "{must_id}: locator must be present even when unsupported" + ); +} + +fn looks_like_overlay_fallback(s: &str) -> bool { + let lower = s.to_ascii_lowercase(); + lower.contains("use overlay") + || lower.contains("cover-and-overlay") + || lower.contains("cover and overlay") + || lower.contains("overlay fallback") + || lower.contains("fallback to overlay") +} + +fn dir_names(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +fn flip_one_payload_byte(bytes: &mut [u8]) { + if let Some(i) = bytes.windows(2).position(|w| w == b"Hi") { + bytes[i] ^= 0x01; + return; + } + let i = bytes.len() / 2; + bytes[i] ^= 0x01; +} + +fn expect_err_code(result: Result, AppError>, code: &str, must_id: &str) { + match result { + Ok(hits) => panic!( + "{must_id}: expected AppError.code={code}, got Ok({} occurrences)", + hits.len() + ), + Err(err) => assert_eq!( + err.code, code, + "{must_id}: AppError.code must be {code}; got {} ({})", + err.code, err.message + ), + } +} + +fn expect_bounds_err( + result: Result, AppError>, + allowed: &[&str], + must_id: &str, +) { + match result { + Ok(hits) => panic!( + "{must_id}: broken/missing input must be AppError {:?}, not Ok({} occurrences)", + allowed, + hits.len() + ), + Err(err) => assert!( + allowed.iter().any(|c| err.code == *c), + "{must_id}: AppError.code must be one of {allowed:?}; got {} ({})", + err.code, + err.message + ), + } +} + +// --- CLASSIFY-API ----------------------------------------------------------- + +#[test] +fn classify_api_lists_occurrences() { + let path = fixture("text-tj.pdf"); + let hits = classify(&path, "CLASSIFY-API"); + assert!( + !hits.is_empty(), + "CLASSIFY-API: classify_source_content(text-tj.pdf) must list occurrences" + ); +} + +// --- CLASSIFY-SOURCE-PATH --------------------------------------------------- + +#[test] +fn classify_uses_corpus_source_path_not_page_pdf() { + let path = fixture("text-tj.pdf"); + let rendered = path.to_string_lossy(); + assert!( + rendered.contains("fixtures/source-edit") && rendered.ends_with("text-tj.pdf"), + "CLASSIFY-SOURCE-PATH: must pass the corpus source path, not pagePdf / --empty --pages; got {}", + path.display() + ); + // Do not spawn qpdf --empty --pages and do not call page_pdf_b64. + let hits = classify(&path, "CLASSIFY-SOURCE-PATH"); + let occ = first_of_kind(&hits, "text", "CLASSIFY-SOURCE-PATH"); + assert_eq!( + occ.page_index, 0, + "CLASSIFY-SOURCE-PATH: text-tj.pdf page_index is 0 on the original source" + ); +} + +// --- CLASSIFY-TRY-EDIT-TJ --------------------------------------------------- + +#[test] +fn classify_text_tj_supported_at_origin() { + let path = fixture("text-tj.pdf"); + let hits = classify(&path, "CLASSIFY-TRY-EDIT-TJ"); + let occ = first_of_kind(&hits, "text", "CLASSIFY-TRY-EDIT-TJ"); + assert_supported_text_or_image(occ, "text", "CLASSIFY-TRY-EDIT-TJ"); + assert_eq!( + occ.page_index, 0, + "CLASSIFY-TRY-EDIT-TJ: page_index must be 0" + ); + assert!( + (occ.rect.x - 72.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-TJ: origin x must be ~72; got {}", + occ.rect.x + ); + assert!( + (occ.rect.y - 720.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-TJ: origin y must be ~720; got {}", + occ.rect.y + ); + assert!( + occ.rect.w > 0.0 && occ.rect.h > 0.0, + "CLASSIFY-TRY-EDIT-TJ: w/h must be positive; got w={} h={}", + occ.rect.w, + occ.rect.h + ); +} + +// --- CLASSIFY-TRY-EDIT-IMAGE ------------------------------------------------ + +#[test] +fn classify_image_unique_supported() { + let path = fixture("image-unique.pdf"); + let hits = classify(&path, "CLASSIFY-TRY-EDIT-IMAGE"); + let occ = first_of_kind(&hits, "image", "CLASSIFY-TRY-EDIT-IMAGE"); + assert_supported_text_or_image(occ, "image", "CLASSIFY-TRY-EDIT-IMAGE"); + assert_eq!( + occ.page_index, 0, + "CLASSIFY-TRY-EDIT-IMAGE: page_index must be 0" + ); + assert!( + (occ.rect.x - 72.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-IMAGE: x must be ~72; got {}", + occ.rect.x + ); + assert!( + (occ.rect.y - 400.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-IMAGE: y must be ~400; got {}", + occ.rect.y + ); + assert!( + (occ.rect.w - 40.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-IMAGE: w must be ~40; got {}", + occ.rect.w + ); + assert!( + (occ.rect.h - 40.0).abs() <= 1.0, + "CLASSIFY-TRY-EDIT-IMAGE: h must be ~40; got {}", + occ.rect.h + ); +} + +// --- CLASSIFY-NO-TOUNICODE -------------------------------------------------- + +#[test] +fn classify_cid_no_tounicode_is_unsupported() { + let path = fixture("text-cid-no-tounicode.pdf"); + let hits = classify(&path, "CLASSIFY-NO-TOUNICODE"); + let occ = first_of_kind(&hits, "text", "CLASSIFY-NO-TOUNICODE"); + assert_unsupported(occ, "text", "NO_TOUNICODE", "CLASSIFY-NO-TOUNICODE"); +} + +// --- CLASSIFY-SHARED-IMAGE -------------------------------------------------- + +#[test] +fn classify_image_reused_shared_xobject() { + let path = fixture("image-reused.pdf"); + let hits = classify(&path, "CLASSIFY-SHARED-IMAGE"); + let images: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "image").collect(); + assert_eq!( + images.len(), + 2, + "CLASSIFY-SHARED-IMAGE: image-reused.pdf must yield two image occurrences; got {:?}", + hits.iter() + .map(|o| ( + o.page_index, + kind_token(o), + capability_token(o), + reason_code(o) + )) + .collect::>() + ); + for occ in images { + assert_unsupported(occ, "image", "SHARED_XOBJECT", "CLASSIFY-SHARED-IMAGE"); + } +} + +// --- CLASSIFY-STAND-INS ----------------------------------------------------- + +#[test] +fn classify_stand_ins_locked_reasons() { + for (file, kind, reason) in STAND_INS { + let path = fixture(file); + let hits = classify(&path, "CLASSIFY-STAND-INS"); + let occ = first_of_kind(&hits, kind, "CLASSIFY-STAND-INS"); + assert_unsupported(occ, kind, reason, &format!("CLASSIFY-STAND-INS: {file}")); + for other in &hits { + assert_ne!( + capability_token(other), + "supported", + "CLASSIFY-STAND-INS: {file} must never return supported" + ); + } + } +} + +// --- CLASSIFY-NO-SILENT-OVERLAY --------------------------------------------- + +#[test] +fn classify_stand_ins_never_supported_or_overlay_fallback() { + let manifest = load_manifest(); + let stand_in_rows: Vec<&FixtureRow> = manifest + .fixtures + .iter() + .filter(|r| r.intent == "unsupported-stand-in") + .collect(); + assert!( + !stand_in_rows.is_empty(), + "CLASSIFY-NO-SILENT-OVERLAY: manifest must list unsupported-stand-in rows" + ); + for row in stand_in_rows { + let path = fixture(&row.path); + let hits = classify(&path, "CLASSIFY-NO-SILENT-OVERLAY"); + for occ in &hits { + assert_ne!( + capability_token(occ), + "supported", + "CLASSIFY-NO-SILENT-OVERLAY: {} must not be supported", + row.id + ); + if let Some(code) = reason_code(occ) { + assert!( + !looks_like_overlay_fallback(&code), + "CLASSIFY-NO-SILENT-OVERLAY: {} reason must not be an overlay fallback; got {code}", + row.id + ); + assert!( + FROZEN_REASONS.contains(&code.as_str()), + "CLASSIFY-NO-SILENT-OVERLAY: {} reason {code} is not a frozen code", + row.id + ); + } + assert!( + !looks_like_overlay_fallback(&capability_token(occ)), + "CLASSIFY-NO-SILENT-OVERLAY: {} capability must not be an overlay fallback", + row.id + ); + } + } +} + +// --- CLASSIFY-NO-SAVE ------------------------------------------------------- + +#[test] +fn classify_does_not_write_source_or_dest() { + let path = fixture("text-tj.pdf"); + let parent = path + .parent() + .expect("CLASSIFY-NO-SAVE: fixture has a parent dir"); + let before_names = dir_names(parent); + let before_bytes = fs::read(&path).unwrap(); + let before_meta = fs::metadata(&path).unwrap(); + let before_mtime = before_meta.modified().ok(); + let before_len = before_meta.len(); + + // Entry point must not write, whether it returns Ok or Err. + let _ = classify_source_content(&path); + + let after_bytes = fs::read(&path).unwrap(); + assert_eq!( + after_bytes, before_bytes, + "CLASSIFY-NO-SAVE: source bytes of text-tj.pdf must be unchanged" + ); + let after_meta = fs::metadata(&path).unwrap(); + assert_eq!( + after_meta.len(), + before_len, + "CLASSIFY-NO-SAVE: source length must be unchanged" + ); + if let (Some(before), Ok(after)) = (before_mtime, after_meta.modified()) { + assert_eq!( + after, before, + "CLASSIFY-NO-SAVE: source mtime must be unchanged" + ); + } + let after_names = dir_names(parent); + assert_eq!( + after_names, before_names, + "CLASSIFY-NO-SAVE: no dest sibling may be created next to the fixture; before={before_names:?} after={after_names:?}" + ); +} + +// --- CLASSIFY-NO-EDITABLE-CLAIM --------------------------------------------- + +#[test] +fn classify_try_edit_is_not_auto_supported() { + let cid = classify( + &fixture("text-cid-tounicode.pdf"), + "CLASSIFY-NO-EDITABLE-CLAIM", + ); + let cid_occ = first_of_kind(&cid, "text", "CLASSIFY-NO-EDITABLE-CLAIM"); + assert_unsupported( + cid_occ, + "text", + "AMBIGUOUS_UNICODE", + "CLASSIFY-NO-EDITABLE-CLAIM: text-cid-tounicode.pdf is try-edit but must not be treated as supported", + ); + assert_ne!( + reason_code(cid_occ).as_deref(), + Some("NO_TOUNICODE"), + "CLASSIFY-NO-EDITABLE-CLAIM: text-cid-tounicode.pdf has a ToUnicode CMap; NO_TOUNICODE is the wrong reason" + ); + + let kerned = classify(&fixture("text-tj-kerned.pdf"), "CLASSIFY-NO-EDITABLE-CLAIM"); + let kerned_occ = first_of_kind(&kerned, "text", "CLASSIFY-NO-EDITABLE-CLAIM"); + assert_supported_text_or_image( + kerned_occ, + "text", + "CLASSIFY-NO-EDITABLE-CLAIM: text-tj-kerned.pdf is the human pick for supported", + ); + + let manifest = load_manifest(); + let try_edit: Vec<&FixtureRow> = manifest + .fixtures + .iter() + .filter(|r| r.intent == "try-edit") + .collect(); + assert!( + try_edit.iter().any(|r| r.id == "text-cid-tounicode"), + "CLASSIFY-NO-EDITABLE-CLAIM: manifest still marks text-cid-tounicode as try-edit" + ); + assert!( + try_edit.iter().any(|r| r.id == "text-tj-kerned"), + "CLASSIFY-NO-EDITABLE-CLAIM: manifest still marks text-tj-kerned as try-edit" + ); +} + +// --- CLASSIFY-STALE --------------------------------------------------------- + +#[test] +fn classify_mutated_copy_locator_is_stale() { + let src = fixture("text-tj.pdf"); + let hits = classify(&src, "CLASSIFY-STALE"); + let occ = first_of_kind(&hits, "text", "CLASSIFY-STALE"); + let locator = occ.locator.clone(); + assert!( + !locator.trim().is_empty(), + "CLASSIFY-STALE: locator must be a non-empty opaque string" + ); + + match resolve_source_locator(&src, &locator) { + Ok(_) => {} + Err(err) => assert_ne!( + err.code.as_str(), + "STALE", + "CLASSIFY-STALE: original source must not be STALE" + ), + } + + let scratch = Scratch::new("stale"); + let copy = scratch.file("copy.pdf"); + fs::copy(&src, ©).unwrap(); + let mut bytes = fs::read(©).unwrap(); + flip_one_payload_byte(&mut bytes); + fs::write(©, &bytes).unwrap(); + + let err = resolve_source_locator(©, &locator) + .expect_err("CLASSIFY-STALE: resolve_source_locator on a mutated copy must be Err, not Ok"); + assert_eq!( + err.code, "STALE", + "CLASSIFY-STALE: AppError.code must be STALE; got {} ({})", + err.code, err.message + ); +} + +// --- CLASSIFY-BOUNDS -------------------------------------------------------- + +#[test] +fn classify_missing_path_is_invalid_pdf() { + let scratch = Scratch::new("missing"); + let missing = scratch.file("no-such.pdf"); + expect_err_code( + classify_source_content(&missing), + "INVALID_PDF", + "CLASSIFY-BOUNDS: missing path", + ); +} + +#[test] +fn classify_broken_tiny_pdf_is_app_error() { + let scratch = Scratch::new("broken"); + let path = scratch.file("tiny.pdf"); + fs::write(&path, b"%PDF-1.4\n%% truncated").unwrap(); + expect_bounds_err( + classify_source_content(&path), + &["MALFORMED_CONTENT", "INVALID_PDF"], + "CLASSIFY-BOUNDS: broken tiny PDF", + ); +} + +#[test] +fn classify_geom_only_pages_are_empty() { + for name in [ + "geom-crop-offset.pdf", + "geom-user-unit.pdf", + "geom-rotate-90.pdf", + ] { + let hits = classify(&fixture(name), "CLASSIFY-BOUNDS"); + assert!( + hits.is_empty(), + "CLASSIFY-BOUNDS: {name} is geom-only (re f) and must return an empty list, not a fake supported/GEOMETRY row; got {:?}", + hits.iter() + .map(|o| (kind_token(o), capability_token(o), reason_code(o))) + .collect::>() + ); + } + for name in GEOM_ONLY { + let hits = classify(&fixture(name), "CLASSIFY-BOUNDS"); + assert!( + hits.iter().all(|o| capability_token(o) != "supported"), + "CLASSIFY-BOUNDS: {name} must not mark a geom-only page supported" + ); + } +} + +#[test] +fn classify_oversize_sparse_file_is_file_too_large() { + // Sparse tempfile — do not commit a 400 MiB PDF. + let scratch = Scratch::new("huge"); + let path = scratch.file("huge.pdf"); + let f = File::create(&path).unwrap(); + f.set_len(FILE_CAP_BYTES + 1).unwrap(); + drop(f); + expect_err_code( + classify_source_content(&path), + "FILE_TOO_LARGE", + "CLASSIFY-BOUNDS: set_len(400MiB+1)", + ); +} + +// --- PR 97 review fold (R1–R5) --------------------------------------------- +// Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. + +fn box_obj(b: [i64; 4]) -> Object { + Object::Array(b.into_iter().map(Object::Integer).collect()) +} + +fn helvetica_resources() -> Dictionary { + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + let mut fonts = Dictionary::new(); + fonts.set("F1", Object::Dictionary(font)); + let mut res = Dictionary::new(); + res.set("Font", Object::Dictionary(fonts)); + res +} + +fn write_helvetica_page(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(helvetica_resources())); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path).expect("write generated classifier fixture"); +} + +fn write_text_with_empty_sig_widget(path: &Path) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET\n".to_vec(), + ))); + let widget_id = doc.new_object_id(); + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(helvetica_resources())); + page.set("Annots", vec![Object::Reference(widget_id)]); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut widget = Dictionary::new(); + widget.set("Type", "Annot"); + widget.set("Subtype", "Widget"); + widget.set("FT", "Sig"); + widget.set("T", Object::string_literal("Sig1")); + widget.set("Rect", box_obj([72, 72, 172, 92])); + widget.set("P", page_id); + doc.objects.insert(widget_id, Object::Dictionary(widget)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut acro = Dictionary::new(); + acro.set("Fields", vec![Object::Reference(widget_id)]); + let acro_id = doc.add_object(Object::Dictionary(acro)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + catalog.set("AcroForm", acro_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write empty-sig-widget classifier fixture"); +} + +fn write_applied_signature(path: &Path) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET\n".to_vec(), + ))); + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(helvetica_resources())); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut sig = Dictionary::new(); + sig.set("Type", "Sig"); + sig.set( + "ByteRange", + vec![ + Object::Integer(0), + Object::Integer(10), + Object::Integer(20), + Object::Integer(30), + ], + ); + let _sig_id = doc.add_object(Object::Dictionary(sig)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write applied-signature classifier fixture"); +} + +// --- R1 -------------------------------------------------------------------- + +#[test] +fn classify_180_degree_text_is_rotated() { + let scratch = Scratch::new("r1-180"); + let path = scratch.file("text-180.pdf"); + write_helvetica_page(&path, b"BT /F1 12 Tf -1 0 0 -1 200 400 Tm (Hi) Tj ET\n"); + let hits = classify(&path, "R1"); + let occ = first_of_kind(&hits, "text", "R1"); + assert_ne!( + capability_token(occ), + "supported", + "R1: 180° Tm [-1 0 0 -1 200 400] must not be supported" + ); + assert_unsupported(occ, "text", "ROTATED_TEXT", "R1"); +} + +// --- R2 -------------------------------------------------------------------- + +#[test] +fn classify_second_tj_advances_tm() { + let scratch = Scratch::new("r2-advance"); + let path = scratch.file("two-tj.pdf"); + write_helvetica_page(&path, b"BT /F1 12 Tf 72 720 Td (Hel) Tj (lo) Tj ET\n"); + let hits = classify(&path, "R2"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "text").collect(); + assert_eq!( + texts.len(), + 2, + "R2: (Hel) Tj (lo) Tj must emit two text occurrences; got {:?}", + hits.iter() + .map(|o| (kind_token(o), o.rect.x, o.rect.y)) + .collect::>() + ); + assert!( + (texts[0].rect.x - 72.0).abs() <= 1.0, + "R2: first origin x must be ~72; got {}", + texts[0].rect.x + ); + assert!( + texts[1].rect.x > texts[0].rect.x, + "R2: second rect.x must be > first (must not share origin 72); first x={} second x={}", + texts[0].rect.x, + texts[1].rect.x + ); + assert!( + (texts[1].rect.x - 72.0).abs() > 1.0, + "R2: second show must not reuse origin 72; first x={} second x={}", + texts[0].rect.x, + texts[1].rect.x + ); +} + +// --- R3 -------------------------------------------------------------------- + +#[test] +fn classify_empty_sig_widget_does_not_refuse_file() { + let scratch = Scratch::new("r3-empty-sig"); + let path = scratch.file("empty-sig.pdf"); + write_text_with_empty_sig_widget(&path); + let hits = match classify_source_content(&path) { + Ok(hits) => hits, + Err(err) => panic!( + "R3: empty /FT /Sig widget (Type Annot, no ByteRange) + Helvetica (Hi) Tj must be Ok with a text occurrence, not AppError SIGNED; got {} ({})", + err.code, err.message + ), + }; + let occ = first_of_kind(&hits, "text", "R3"); + assert_ne!( + reason_code(occ).as_deref(), + Some("SIGNED"), + "R3: Helvetica text on a file with an empty Sig widget must not be SIGNED" + ); +} + +#[test] +fn classify_applied_signature_is_signed() { + let scratch = Scratch::new("r3-applied-sig"); + let path = scratch.file("applied-sig.pdf"); + write_applied_signature(&path); + expect_err_code( + classify_source_content(&path), + "SIGNED", + "R3: /Type /Sig + ByteRange still refuses", + ); +} + +// --- R4 -------------------------------------------------------------------- + +#[test] +fn classify_text_after_inline_image_is_kept() { + let scratch = Scratch::new("r4-inline-rest"); + let path = scratch.file("inline-then-text.pdf"); + write_helvetica_page( + &path, + b"q 24 0 0 12 72 400 cm\n\ +BI\n\ +/W 2 /H 1 /CS /DeviceRGB /BPC 8 /F /AHx\n\ +ID\n\ +C8101010C810>\n\ +EI\n\ +Q\n\ +BT /F1 12 Tf 72 720 Td (Hi) Tj ET\n", + ); + let hits = classify(&path, "R4"); + let _text = first_of_kind(&hits, "text", "R4"); +} + +// --- R5 -------------------------------------------------------------------- + +#[test] +fn classify_text_bounds_use_tm_scale() { + let scratch = Scratch::new("r5-tm-scale"); + let path = scratch.file("tf1-tm12.pdf"); + write_helvetica_page(&path, b"BT /F1 1 Tf 12 0 0 12 72 720 Tm (Hi) Tj ET\n"); + let hits = classify(&path, "R5"); + let occ = first_of_kind(&hits, "text", "R5"); + assert!( + (occ.rect.h - 12.0).abs() <= 1.0, + "R5: /F1 1 Tf + 12 0 0 12 Tm must report height ~12, not ~1; got h={}", + occ.rect.h + ); + assert!( + (occ.rect.h - 1.0).abs() > 1.0, + "R5: rect.h must not stay at Tf size ~1; got h={}", + occ.rect.h + ); +} + +// --- PR 97 review fold r2 (R6–R9) ------------------------------------------ +// Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. + +fn write_type3_and_helvetica_page(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + + let proc_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + b"10 0 0 0 10 10 d1\n0 0 10 10 re f\n".to_vec(), + ))); + let mut char_procs = Dictionary::new(); + char_procs.set("x", proc_id); + + let mut enc = Dictionary::new(); + enc.set("Type", "Encoding"); + enc.set( + "Differences", + vec![Object::Integer(120), Object::Name(b"x".to_vec())], + ); + + let mut t3 = Dictionary::new(); + t3.set("Type", "Font"); + t3.set("Subtype", "Type3"); + t3.set("FontBBox", box_obj([0, 0, 10, 10])); + t3.set( + "FontMatrix", + Object::Array(vec![ + Object::Real(1.0), + Object::Real(0.0), + Object::Real(0.0), + Object::Real(1.0), + Object::Real(0.0), + Object::Real(0.0), + ]), + ); + t3.set("CharProcs", Object::Dictionary(char_procs)); + t3.set("Encoding", Object::Dictionary(enc)); + t3.set("FirstChar", 120); + t3.set("LastChar", 120); + t3.set("Widths", vec![Object::Integer(10)]); + + let mut f1 = Dictionary::new(); + f1.set("Type", "Font"); + f1.set("Subtype", "Type1"); + f1.set("BaseFont", "Helvetica"); + + let mut fonts = Dictionary::new(); + fonts.set("T3", Object::Dictionary(t3)); + fonts.set("F1", Object::Dictionary(f1)); + let mut res = Dictionary::new(); + res.set("Font", Object::Dictionary(fonts)); + + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(res)); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write Type3+Helvetica classifier fixture"); +} + +// --- R6 -------------------------------------------------------------------- + +#[test] +fn classify_inline_image_uses_ctm_at_bi() { + let path = fixture("image-inline.pdf"); + let hits = classify(&path, "R6"); + let occ = first_of_kind(&hits, "image", "R6"); + assert!( + (occ.rect.x - 72.0).abs() <= 1.0, + "R6: image-inline.pdf image rect.x must be ~72 (CTM at BI), not the unit square at origin; got x={}", + occ.rect.x + ); + assert!( + (occ.rect.y - 400.0).abs() <= 1.0, + "R6: image-inline.pdf image rect.y must be ~400 (CTM at BI), not the unit square at origin; got y={}", + occ.rect.y + ); + assert!( + (occ.rect.w - 24.0).abs() <= 1.0, + "R6: image-inline.pdf image rect.w must be ~24 (CTM at BI), not the unit square; got w={}", + occ.rect.w + ); + assert!( + (occ.rect.h - 12.0).abs() <= 1.0, + "R6: image-inline.pdf image rect.h must be ~12 (CTM at BI), not the unit square; got h={}", + occ.rect.h + ); +} + +// --- R7 -------------------------------------------------------------------- + +#[test] +fn classify_q_restores_type3_after_helvetica() { + let scratch = Scratch::new("r7-q-type3"); + let path = scratch.file("q-type3.pdf"); + write_type3_and_helvetica_page( + &path, + b"BT /T3 12 Tf (x) Tj q /F1 12 Tf (y) Tj Q (z) Tj ET\n", + ); + let hits = classify(&path, "R7"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "text").collect(); + assert_eq!( + texts.len(), + 3, + "R7: (x) Tj q /F1 (y) Tj Q (z) Tj must emit three text occurrences; got {:?}", + hits.iter() + .map(|o| (kind_token(o), capability_token(o), reason_code(o))) + .collect::>() + ); + let last = texts[2]; + assert_ne!( + capability_token(last), + "supported", + "R7: last show (z) after Q must not be Helvetica supported; got {} reason={:?}", + capability_token(last), + reason_code(last) + ); + assert_unsupported(last, "text", "TYPE3", "R7"); +} + +// --- R8 -------------------------------------------------------------------- + +#[test] +fn classify_tc_advances_second_tj() { + let scratch = Scratch::new("r8-tc"); + let path = scratch.file("tc-two-tj.pdf"); + write_helvetica_page(&path, b"BT /F1 12 Tf 2 Tc 72 720 Td (Hi) Tj (there) Tj ET\n"); + let hits = classify(&path, "R8"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "text").collect(); + assert_eq!( + texts.len(), + 2, + "R8: (Hi) Tj (there) Tj must emit two text occurrences; got {:?}", + hits.iter() + .map(|o| (kind_token(o), o.rect.x, o.rect.y)) + .collect::>() + ); + // Helvetica H=667 i=278 → 11.34 at Tf=12. 2 Tc on two glyphs adds 4 + // user units, so second.x ≈ first.x + 15.34, not first.x + 11.34. + assert!( + texts[1].rect.x > texts[0].rect.x + 13.0, + "R8: 2 Tc must push second.x past first.x + no-Tc Hi width 11.34; first.x={} second.x={} (need second.x > first.x + 13)", + texts[0].rect.x, + texts[1].rect.x + ); +} + +// --- R9 -------------------------------------------------------------------- + +#[test] +fn classify_source_drops_rotated_fixture_parenthetical() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pdf_engine/source_content.rs"); + let src = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "R9: must read source_content.rs via CARGO_MANIFEST_DIR ({}): {e}", + path.display() + ) + }); + assert!( + !src.contains("keeps text-rotated.pdf green"), + "R9: source_content.rs must not contain the exact substring `keeps text-rotated.pdf green`" + ); +} + +// --- PR 97 review fold r3 (R10–R12) ---------------------------------------- +// Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. + +/// 2×2 DeviceRGB, same bytes as the #32 unique/mask fixtures. +const R3_TINY_RGB: &[u8] = &[200, 16, 16, 16, 200, 16, 16, 16, 200, 200, 200, 16]; + +fn write_type1_indirect_widths(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + + // FirstChar 'H' (72) … LastChar 'i' (105): 34 glyph slots, all 1000. + const FIRST_CHAR: i64 = 72; + const LAST_CHAR: i64 = 105; + let widths: Vec = (FIRST_CHAR..=LAST_CHAR) + .map(|_| Object::Integer(1000)) + .collect(); + let widths_id = doc.add_object(Object::Array(widths)); + + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + font.set("FirstChar", FIRST_CHAR); + font.set("LastChar", LAST_CHAR); + font.set("Widths", Object::Reference(widths_id)); + + let mut fonts = Dictionary::new(); + fonts.set("F1", Object::Dictionary(font)); + let mut res = Dictionary::new(); + res.set("Font", Object::Dictionary(fonts)); + + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(res)); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write Type1 indirect-Widths classifier fixture"); +} + +fn write_pattern_cs_page(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + let mut fonts = Dictionary::new(); + fonts.set("F1", Object::Dictionary(font)); + + let mut cs = Dictionary::new(); + cs.set("Cs1", Object::Name(b"Pattern".to_vec())); + + let mut pat = Dictionary::new(); + pat.set("Type", "Pattern"); + pat.set("PatternType", 1); + pat.set("PaintType", 1); + pat.set("TilingType", 1); + pat.set("BBox", box_obj([0, 0, 10, 10])); + pat.set("XStep", 10); + pat.set("YStep", 10); + pat.set("Resources", Object::Dictionary(Dictionary::new())); + let pat_id = doc.add_object(Object::Stream(Stream::new( + pat, + b"0 0 10 10 re f\n".to_vec(), + ))); + let mut patterns = Dictionary::new(); + patterns.set("P1", Object::Reference(pat_id)); + + let mut res = Dictionary::new(); + res.set("Font", Object::Dictionary(fonts)); + res.set("ColorSpace", Object::Dictionary(cs)); + res.set("Pattern", Object::Dictionary(patterns)); + + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(res)); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write Pattern ColorSpace classifier fixture"); +} + +fn write_extgstate_smask_image(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + + let mut img = Dictionary::new(); + img.set("Type", "XObject"); + img.set("Subtype", "Image"); + img.set("Width", 2); + img.set("Height", 2); + img.set("ColorSpace", "DeviceRGB"); + img.set("BitsPerComponent", 8); + let img_id = doc.add_object(Object::Stream(Stream::new(img, R3_TINY_RGB.to_vec()))); + + let mut sm = Dictionary::new(); + sm.set("Type", "XObject"); + sm.set("Subtype", "Image"); + sm.set("Width", 2); + sm.set("Height", 2); + sm.set("ColorSpace", "DeviceGray"); + sm.set("BitsPerComponent", 8); + let smask_id = doc.add_object(Object::Stream(Stream::new(sm, vec![255, 200, 180, 255]))); + + let mut gs = Dictionary::new(); + gs.set("Type", "ExtGState"); + gs.set("SMask", Object::Reference(smask_id)); + let gs_id = doc.add_object(Object::Dictionary(gs)); + + let mut xobjects = Dictionary::new(); + xobjects.set("Im0", Object::Reference(img_id)); + let mut extg = Dictionary::new(); + extg.set("Gs1", Object::Reference(gs_id)); + let mut res = Dictionary::new(); + res.set("XObject", Object::Dictionary(xobjects)); + res.set("ExtGState", Object::Dictionary(extg)); + + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(res)); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write ExtGState SMask + unique Image classifier fixture"); +} + +// --- R10 ------------------------------------------------------------------- + +#[test] +fn classify_indirect_widths_not_helvetica_fallback() { + let scratch = Scratch::new("r10-widths"); + let path = scratch.file("indirect-widths.pdf"); + write_type1_indirect_widths(&path, b"BT /F1 12 Tf 72 720 Td (Hi) Tj ET\n"); + let hits = classify(&path, "R10"); + let occ = first_of_kind(&hits, "text", "R10"); + assert!( + (occ.rect.w - 24.0).abs() <= 1.0, + "R10: Type1 indirect /Widths 1000,1000 at Tf=12 must report w≈24, not Helvetica fallback ≈11.34; got w={}", + occ.rect.w + ); + assert!( + (occ.rect.w - 11.34).abs() > 1.0, + "R10: rect.w must not stay on the Helvetica table ≈11.34; got w={}", + occ.rect.w + ); +} + +// --- R11 ------------------------------------------------------------------- + +#[test] +fn classify_named_pattern_cs_is_unsupported() { + let scratch = Scratch::new("r11-pattern"); + let path = scratch.file("pattern-cs.pdf"); + write_pattern_cs_page( + &path, + b"BT /F1 12 Tf /Cs1 cs /P1 scn 72 720 Td (Hi) Tj ET\n", + ); + let hits = classify(&path, "R11"); + let occ = first_of_kind(&hits, "text", "R11"); + assert_ne!( + capability_token(occ), + "supported", + "R11: /Cs1 cs Pattern resource + (Hi) Tj must not be supported; got {} reason={:?}", + capability_token(occ), + reason_code(occ) + ); + assert_unsupported(occ, "text", "PATTERN", "R11"); +} + +// --- R12 ------------------------------------------------------------------- + +#[test] +fn classify_extgstate_smask_image_is_masked() { + let scratch = Scratch::new("r12-gs-smask"); + let path = scratch.file("gs-smask.pdf"); + write_extgstate_smask_image(&path, b"q 40 0 0 40 72 400 cm /Gs1 gs /Im0 Do Q\n"); + let hits = classify(&path, "R12"); + let occ = first_of_kind(&hits, "image", "R12"); + assert_ne!( + capability_token(occ), + "supported", + "R12: unique Image after ExtGState /Gs1 /SMask must not be supported; got {} reason={:?}", + capability_token(occ), + reason_code(occ) + ); + assert_unsupported(occ, "image", "MASKED_IMAGE", "R12"); +} + +// --- PR 97 review fold r4 (R13) -------------------------------------------- +// Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. + +fn write_unique_rgb_image(path: &Path, content: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + content.to_vec(), + ))); + + let mut img = Dictionary::new(); + img.set("Type", "XObject"); + img.set("Subtype", "Image"); + img.set("Width", 2); + img.set("Height", 2); + img.set("ColorSpace", "DeviceRGB"); + img.set("BitsPerComponent", 8); + let img_id = doc.add_object(Object::Stream(Stream::new(img, R3_TINY_RGB.to_vec()))); + + let mut xobjects = Dictionary::new(); + xobjects.set("Im0", Object::Reference(img_id)); + let mut res = Dictionary::new(); + res.set("XObject", Object::Dictionary(xobjects)); + + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set("Contents", content_id); + page.set("Resources", Object::Dictionary(res)); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write unique 2×2 DeviceRGB Image classifier fixture"); +} + +// --- R13a ------------------------------------------------------------------ + +#[test] +fn classify_stacked_cm_image_origin() { + let scratch = Scratch::new("r13a-stacked-cm"); + let path = scratch.file("stacked-cm.pdf"); + write_unique_rgb_image( + &path, + b"q 2 0 0 2 0 0 cm 20 0 0 20 36 200 cm /Im0 Do Q\n", + ); + let hits = classify(&path, "R13a"); + let occ = first_of_kind(&hits, "image", "R13a"); + assert_supported_text_or_image(occ, "image", "R13a"); + assert!( + (occ.rect.x - 72.0).abs() <= 1.0 + && (occ.rect.y - 400.0).abs() <= 1.0 + && (occ.rect.w - 40.0).abs() <= 1.0 + && (occ.rect.h - 40.0).abs() <= 1.0, + "R13a: stacked cm image rect must be ~{{x:72, y:400, w:40, h:40}}, not origin ~(36, 200); got {{x:{}, y:{}, w:{}, h:{}}}", + occ.rect.x, + occ.rect.y, + occ.rect.w, + occ.rect.h + ); + assert!( + (occ.rect.x - 36.0).abs() > 1.0 || (occ.rect.y - 200.0).abs() > 1.0, + "R13a: stacked cm must not leave the image at the second-cm translation (36, 200); got {{x:{}, y:{}, w:{}, h:{}}}", + occ.rect.x, + occ.rect.y, + occ.rect.w, + occ.rect.h + ); +} + +// --- R13b ------------------------------------------------------------------ + +#[test] +fn classify_scaled_tm_second_show_x() { + let scratch = Scratch::new("r13b-scaled-tm"); + let path = scratch.file("scaled-tm-two-tj.pdf"); + write_helvetica_page( + &path, + b"BT /F1 1 Tf 12 0 0 12 72 720 Tm (Hel) Tj (lo) Tj ET\n", + ); + let hits = classify(&path, "R13b"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "text").collect(); + assert_eq!( + texts.len(), + 2, + "R13b: (Hel) Tj (lo) Tj must emit two text occurrences; got {:?}", + hits.iter() + .map(|o| (kind_token(o), o.rect.x, o.rect.y)) + .collect::>() + ); + let first = texts[0]; + let second = texts[1]; + // Helvetica H=667 e=556 l=278 → 1.501 at Tf=1. Scaled Tm 12× must + // advance ~18.012 user units → second.x ≈ 90, not text-space 1.501 + // added in user space (≈73.5). + assert!( + second.rect.x > first.rect.x + 15.0 || (second.rect.x - 90.0).abs() <= 2.0, + "R13b: second rect.x after 12 0 0 12 72 720 Tm (Hel) Tj must be ≈90 (±2), not ≈73.5; first.x={} second.x={}", + first.rect.x, + second.rect.x + ); + assert!( + (second.rect.x - 73.5).abs() > 1.0, + "R13b: second rect.x must not stay at origin+text-space width ≈73.5; first.x={} second.x={}", + first.rect.x, + second.rect.x + ); +} + +// --- PR 97 review fold r5 (R14) -------------------------------------------- +// Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. +// Page /Contents is an array of two streams; stream 1 has no trailing whitespace +// so a join without a separator fuses `Tj`+`ET` into `TjET`. + +const R14_STREAM_1: &[u8] = b"BT /F1 12 Tf 72 720 Td (Hi) Tj"; +const R14_STREAM_2: &[u8] = b"ET\nBT /F1 12 Tf 72 680 Td (Lo) Tj ET"; + +fn stream_content_bytes(doc: &Document, obj: &Object) -> Vec { + let id = obj + .as_reference() + .expect("Contents array entry must be a stream ref"); + doc.get_object(id) + .expect("content stream object") + .as_stream() + .expect("content must be a stream") + .content + .clone() +} + +fn write_helvetica_two_content_streams(path: &Path, stream1: &[u8], stream2: &[u8]) { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let content1_id = doc.add_object(Object::Stream( + Stream::new(Dictionary::new(), stream1.to_vec()).with_compression(false), + )); + let content2_id = doc.add_object(Object::Stream( + Stream::new(Dictionary::new(), stream2.to_vec()).with_compression(false), + )); + let mut page = Dictionary::new(); + page.set("Type", "Page"); + page.set("Parent", pages_id); + page.set("MediaBox", box_obj([0, 0, 612, 792])); + page.set( + "Contents", + vec![ + Object::Reference(content1_id), + Object::Reference(content2_id), + ], + ); + page.set("Resources", Object::Dictionary(helvetica_resources())); + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path) + .expect("write two-stream Contents classifier fixture"); + + // Lock the on-disk page /Contents shape: array of two streams, exact bytes. + let reloaded = + Document::load(path).unwrap_or_else(|e| panic!("reload two-stream Contents fixture: {e}")); + let page_id = *reloaded + .get_pages() + .get(&1) + .expect("two-stream fixture must have page 1"); + let page = reloaded + .get_object(page_id) + .expect("page 1 object") + .as_dict() + .expect("page 1 dict"); + let contents = page.get(b"Contents").expect("page /Contents"); + let refs = match contents { + Object::Array(arr) => arr, + other => panic!( + "two-stream fixture page /Contents must be an array of two stream refs, got {other:?}" + ), + }; + assert_eq!( + refs.len(), + 2, + "two-stream fixture page /Contents must have two stream refs; got {}", + refs.len() + ); + assert_eq!( + stream_content_bytes(&reloaded, &refs[0]).as_slice(), + stream1, + "two-stream fixture stream 1 bytes must be exact (no trailing newline)" + ); + assert_eq!( + stream_content_bytes(&reloaded, &refs[1]).as_slice(), + stream2, + "two-stream fixture stream 2 bytes must match" + ); +} + +// --- R14 ------------------------------------------------------------------- + +#[test] +fn classify_contents_array_two_streams_do_not_fuse() { + let scratch = Scratch::new("r14-two-streams"); + let path = scratch.file("two-contents-streams.pdf"); + write_helvetica_two_content_streams(&path, R14_STREAM_1, R14_STREAM_2); + let hits = classify(&path, "R14"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|o| kind_token(o) == "text").collect(); + assert_eq!( + texts.len(), + 2, + "R14: two Contents streams (Hi@720 then Lo@680) must emit two text occurrences; got {:?}", + hits.iter() + .map(|o| (kind_token(o), o.rect.x, o.rect.y)) + .collect::>() + ); + assert!( + texts.iter().any(|o| (o.rect.y - 720.0).abs() <= 1.0), + "R14: expected a text occurrence at y≈720 (Hi); got {:?}", + texts.iter().map(|o| (o.rect.x, o.rect.y)).collect::>() + ); + assert!( + texts.iter().any(|o| (o.rect.y - 680.0).abs() <= 1.0), + "R14: expected a text occurrence at y≈680 (Lo); got {:?}", + texts.iter().map(|o| (o.rect.x, o.rect.y)).collect::>() + ); +}