From 54873eca0b829076250cf663da98387aab5b29ef Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 18:26:16 +0300 Subject: [PATCH 1/7] feat(pdf): add read-only source-content classifier (#33) Inspect the original source path and list existing text and image occurrences with unrotated PDF-space bounds, an opaque locator, and a supported/unsupported reason. Does not write a dest or edit page operators. Fixes #33. --- src-tauri/src/pdf_engine/mod.rs | 3 + src-tauri/src/pdf_engine/source_content.rs | 1496 +++++++++++++++++ .../src/pdf_engine/source_content_integ.rs | 692 ++++++++ 3 files changed, 2191 insertions(+) create mode 100644 src-tauri/src/pdf_engine/source_content.rs create mode 100644 src-tauri/src/pdf_engine/source_content_integ.rs 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..568d0ac --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -0,0 +1,1496 @@ +//! 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, +} + +impl Default for GState { + fn default() -> Self { + Self { + ctm: IDENTITY, + clip_active: false, + clip_pending: false, + fill_pattern: false, + stroke_pattern: false, + } + } +} + +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], + font_name: Option>, + font_size: f64, + leading: f64, + hscale: f64, +} + +impl Default for TextState { + fn default() -> Self { + Self { + tm: IDENTITY, + tlm: IDENTITY, + font_name: None, + font_size: 0.0, + leading: 0.0, + hscale: 100.0, + } + } +} + +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())?; + 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); + let ops = match Content::decode(bytes) { + Ok(c) => c.operations, + Err(_) if inline_total > 0 => Vec::new(), + 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(m, gs.ctm); + } + } + "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 = is_pattern_name(&op.operands); + } + "CS" => { + gs.stroke_pattern = is_pattern_name(&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; + } + } + "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()) { + ts.font_name = Some(name.to_vec()); + } + if let Some(size) = op.operands.get(1).and_then(obj_f64) { + ts.font_size = size; + } + } + "Tc" | "Tw" => {} + "Tz" => { + if let Some(z) = op.operands.first().and_then(obj_f64) { + ts.hscale = z; + } + } + "TL" => { + if let Some(l) = op.operands.first().and_then(obj_f64) { + ts.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) { + ts.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 = -ts.leading; + apply_td(&mut ts, 0.0, ty); + } + "Tj" | "'" | "\"" | "TJ" => { + if op.operator == "'" || op.operator == "\"" { + let ty = -ts.leading; + apply_td(&mut ts, 0.0, ty); + } + let (pieces, adj) = text_pieces(&op.operands); + self.emit_text( + page_index, + contents_id, + op_index as u32, + resource_owners, + &gs, + &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(info.matrix, gs.ctm); + 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: &TextState, + pieces: &[Vec], + tj_adj: f64, + nested: bool, + geom_unsafe: bool, + ) -> Result<(), AppError> { + let inspect = inspect_text( + self.doc, + resource_owners, + ts.font_name.as_deref(), + pieces, + tj_adj, + ts.font_size, + ); + let effective = mul(gs.ctm, ts.tm); + let height = ts.font_size.abs().max(0.01); + let width = (inspect.width * (ts.hscale / 100.0)).abs().max(0.01); + 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, + 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, + }) + } + + 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: 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(), + 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(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(font: &Dictionary) -> Option<[f64; 256]> { + let first = font.get(b"FirstChar").ok()?.as_i64().ok()? as usize; + let last = font.get(b"LastChar").ok()?.as_i64().ok()? as usize; + let arr = font.get(b"Widths").ok()?.as_array().ok()?; + 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; + } + (b.abs() > 0.1 || c.abs() > 0.1) && (a - d).abs() < 0.25 && (b + c).abs() < 0.25 +} + +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([1.0, 0.0, 0.0, 1.0, tx, ty], ts.tlm); + 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 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 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, + }; + if name_is(dict, b"FT", b"Sig") || name_is(dict, b"Type", b"Sig") { + return true; + } + 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..1bea9bf --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -0,0 +1,692 @@ +//! 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 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)", + ); +} From 6adad22644b19d87709107116ed0f9c6d8cd3696 Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 18:55:29 +0300 Subject: [PATCH 2/7] fix(pdf): close classifier false-positives from PR 97 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat 180° text as ROTATED_TEXT, advance Tm after each show, refuse only applied signatures (not empty /FT /Sig widgets), keep operators after BI…EI, and scale text bounds by Tm×CTM. --- src-tauri/src/pdf_engine/source_content.rs | 85 +++++- .../src/pdf_engine/source_content_integ.rs | 257 ++++++++++++++++++ 2 files changed, 330 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 568d0ac..9b8fc57 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -274,9 +274,16 @@ impl Walker<'_> { geom_unsafe: bool, ) -> Result<(), AppError> { let inline_total = count_inline_images(bytes); - let ops = match Content::decode(bytes) { + let decode_bytes = if inline_total > 0 { + strip_inline_images(bytes) + } else { + bytes.to_vec() + }; + let ops = match Content::decode(&decode_bytes) { Ok(c) => c.operations, - Err(_) if inline_total > 0 => Vec::new(), + Err(_) if inline_total > 0 && decode_bytes.iter().all(|b| b.is_ascii_whitespace()) => { + Vec::new() + } Err(_) => { return Err(malformed_content( "A page content stream could not be decoded.", @@ -401,7 +408,7 @@ impl Walker<'_> { op_index as u32, resource_owners, &gs, - &ts, + &mut ts, &pieces, adj, nested, @@ -516,7 +523,7 @@ impl Walker<'_> { op_index: u32, resource_owners: &[ObjectId], gs: &GState, - ts: &TextState, + ts: &mut TextState, pieces: &[Vec], tj_adj: f64, nested: bool, @@ -531,8 +538,10 @@ impl Walker<'_> { ts.font_size, ); let effective = mul(gs.ctm, ts.tm); - let height = ts.font_size.abs().max(0.01); - let width = (inspect.width * (ts.hscale / 100.0)).abs().max(0.01); + let sx = (effective[0] * effective[0] + effective[1] * effective[1]).sqrt(); + let sy = (effective[2] * effective[2] + effective[3] * effective[3]).sqrt(); + let height = (ts.font_size.abs() * sy).max(0.01); + let width = (inspect.width * (ts.hscale / 100.0) * sx).abs().max(0.01); let flags = Flags { nested_form: nested, type3: inspect.type3, @@ -565,7 +574,11 @@ impl Walker<'_> { ), flags, paint_id: None, - }) + })?; + // PDF updates Tm (not Tlm) to the end of the shown string. + let tx = inspect.width * (ts.hscale / 100.0); + ts.tm = mul([1.0, 0.0, 0.0, 1.0, tx, 0.0], ts.tm); + Ok(()) } fn emit_image( @@ -1184,7 +1197,11 @@ fn is_rotated_tm(m: [f64; 6]) -> bool { if det.abs() < 1e-6 { return false; } - (b.abs() > 0.1 || c.abs() > 0.1) && (a - d).abs() < 0.25 && (b + c).abs() < 0.25 + let quarter = (b.abs() > 0.1 || c.abs() > 0.1) + && (a - d).abs() < 0.25 + && (b + c).abs() < 0.25; + let half = b.abs() < 0.1 && c.abs() < 0.1 && a < -0.1 && d < -0.1 && (a - d).abs() < 0.25; + quarter || half } fn is_skewed_tm(m: [f64; 6]) -> bool { @@ -1298,6 +1315,52 @@ fn collect_text_obj(obj: &Object, pieces: &mut Vec>, adj: &mut f64) { } } +fn strip_inline_images(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len()); + let mut i = 0; + let mut in_inline = false; + while i < data.len() { + if !in_inline && data[i] == b'%' { + let start = i; + while i < data.len() && data[i] != b'\n' { + i += 1; + } + if i < data.len() { + i += 1; + } + out.extend_from_slice(&data[start..i]); + continue; + } + if !in_inline && data[i] == b'(' { + let end = skip_literal(data, i); + out.extend_from_slice(&data[i..end]); + i = end; + continue; + } + if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') { + let end = skip_hex(data, i); + out.extend_from_slice(&data[i..end]); + i = end; + 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") { + in_inline = false; + i += 2; + continue; + } + if !in_inline { + out.push(data[i]); + } + i += 1; + } + out +} + fn count_inline_images(data: &[u8]) -> usize { let mut count = 0; let mut i = 0; @@ -1404,10 +1467,8 @@ fn object_is_signature(obj: &Object) -> bool { Object::Stream(s) => &s.dict, _ => return false, }; - if name_is(dict, b"FT", b"Sig") || name_is(dict, b"Type", b"Sig") { - return true; - } - dict.get(b"ByteRange").is_ok() + let is_sig = name_is(dict, b"FT", b"Sig") || name_is(dict, b"Type", b"Sig"); + is_sig && dict.get(b"ByteRange").is_ok() } fn name_is(dict: &Dictionary, key: &[u8], expect: &[u8]) -> bool { diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 1bea9bf..9537d7c 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -24,6 +24,7 @@ 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}; @@ -690,3 +691,259 @@ fn classify_oversize_sparse_file_is_file_too_large() { "CLASSIFY-BOUNDS: set_len(400MiB+1)", ); } + +// --- PR 97 review fold (R1–R5) --------------------------------------------- + +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"); +} + +#[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_unsupported(occ, "text", "ROTATED_TEXT", "R1: 180-degree Tm"); +} + +#[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[1].rect.x > texts[0].rect.x + 10.0, + "R2: second show must advance Tm; first x={} second x={}", + texts[0].rect.x, + texts[1].rect.x + ); + assert!( + (texts[0].rect.x - 72.0).abs() <= 1.0, + "R2: first origin x must be ~72; got {}", + texts[0].rect.x + ); +} + +#[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 = classify(&path, "R3"); + let occ = first_of_kind(&hits, "text", "R3"); + assert_supported_text_or_image(occ, "text", "R3: empty /FT /Sig widget must not SIGNED the file"); +} + +#[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", + ); +} + +#[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"); + assert_eq!( + kind_token(text), + "text", + "R4: operators after EI must not be dropped" + ); + assert!( + (text.rect.x - 72.0).abs() <= 1.0 && (text.rect.y - 720.0).abs() <= 1.0, + "R4: leftover text origin must be ~72,720 after restoring CTM; got {},{}", + text.rect.x, + text.rect.y + ); + assert!( + hits.iter().any(|o| kind_token(o) == "image"), + "R4: the inline image must still be listed" + ); +} + +#[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 Tf=1; got h={}", + occ.rect.h + ); + assert!( + occ.rect.w > 8.0, + "R5: width must include Tm scale (~11.34 for Hi); got w={}", + occ.rect.w + ); + assert!( + (occ.rect.x - 72.0).abs() <= 1.0 && (occ.rect.y - 720.0).abs() <= 1.0, + "R5: origin must stay ~72,720; got {},{}", + occ.rect.x, + occ.rect.y + ); +} From 18df800579881f406f812e3c0bde9367b825360c Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 19:23:04 +0300 Subject: [PATCH 3/7] fix(pdf): re-land classifier review fold via test-author/impl split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the same-agent fold with independently authored R1–R5 tests and a walker that does not edit those tests. Behavior is the same: 180° ROTATED_TEXT, Tm advance, empty Sig widgets, leftover ops after BI…EI, and Tm×CTM text bounds. --- src-tauri/src/pdf_engine/source_content.rs | 58 +++++++------ .../src/pdf_engine/source_content_integ.rs | 82 +++++++++++-------- 2 files changed, 77 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 9b8fc57..fb7b5ab 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -274,16 +274,15 @@ impl Walker<'_> { geom_unsafe: bool, ) -> Result<(), AppError> { let inline_total = count_inline_images(bytes); - let decode_bytes = if inline_total > 0 { - strip_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 { - bytes.to_vec() + None }; - let ops = match Content::decode(&decode_bytes) { + let decode_src = stripped.as_deref().unwrap_or(bytes); + let ops = match Content::decode(decode_src) { Ok(c) => c.operations, - Err(_) if inline_total > 0 && decode_bytes.iter().all(|b| b.is_ascii_whitespace()) => { - Vec::new() - } Err(_) => { return Err(malformed_content( "A page content stream could not be decoded.", @@ -541,7 +540,8 @@ impl Walker<'_> { let sx = (effective[0] * effective[0] + effective[1] * effective[1]).sqrt(); let sy = (effective[2] * effective[2] + effective[3] * effective[3]).sqrt(); let height = (ts.font_size.abs() * sy).max(0.01); - let width = (inspect.width * (ts.hscale / 100.0) * sx).abs().max(0.01); + let tx = inspect.width * (ts.hscale / 100.0); + let width = (tx * sx).abs().max(0.01); let flags = Flags { nested_form: nested, type3: inspect.type3, @@ -575,8 +575,7 @@ impl Walker<'_> { flags, paint_id: None, })?; - // PDF updates Tm (not Tlm) to the end of the shown string. - let tx = inspect.width * (ts.hscale / 100.0); + // Shown-width advance is Tm only; Tlm stays at the line origin. ts.tm = mul([1.0, 0.0, 0.0, 1.0, tx, 0.0], ts.tm); Ok(()) } @@ -1197,11 +1196,12 @@ fn is_rotated_tm(m: [f64; 6]) -> bool { if det.abs() < 1e-6 { return false; } - let quarter = (b.abs() > 0.1 || c.abs() > 0.1) - && (a - d).abs() < 0.25 - && (b + c).abs() < 0.25; - let half = b.abs() < 0.1 && c.abs() < 0.1 && a < -0.1 && d < -0.1 && (a - d).abs() < 0.25; - quarter || half + // 90° / 270°: off-axis, a≈d, b≈-c (keeps text-rotated.pdf green). + 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 { @@ -1315,32 +1315,36 @@ fn collect_text_obj(obj: &Object, pieces: &mut Vec>, adj: &mut f64) { } } -fn strip_inline_images(data: &[u8]) -> Vec { +fn strip_inline_image_payloads(data: &[u8]) -> Vec { let mut out = Vec::with_capacity(data.len()); let mut i = 0; let mut in_inline = false; while i < data.len() { + if data[i].is_ascii_whitespace() { + if !in_inline { + out.push(data[i]); + } + i += 1; + continue; + } if !in_inline && data[i] == b'%' { let start = i; - while i < data.len() && data[i] != b'\n' { - i += 1; - } - if i < data.len() { + while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { i += 1; } out.extend_from_slice(&data[start..i]); continue; } if !in_inline && data[i] == b'(' { - let end = skip_literal(data, i); - out.extend_from_slice(&data[i..end]); - i = end; + let start = i; + i = skip_literal(data, i); + out.extend_from_slice(&data[start..i]); continue; } if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') { - let end = skip_hex(data, i); - out.extend_from_slice(&data[i..end]); - i = end; + let start = i; + i = skip_hex(data, i); + out.extend_from_slice(&data[start..i]); continue; } if !in_inline && is_op_token(data, i, b"BI") { @@ -1467,7 +1471,7 @@ fn object_is_signature(obj: &Object) -> bool { Object::Stream(s) => &s.dict, _ => return false, }; - let is_sig = name_is(dict, b"FT", b"Sig") || name_is(dict, b"Type", b"Sig"); + 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() } diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 9537d7c..9eb5d9c 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -693,6 +693,7 @@ fn classify_oversize_sparse_file_is_file_too_large() { } // --- 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()) @@ -763,8 +764,7 @@ fn write_text_with_empty_sig_widget(path: &Path) { 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)); + doc.objects.insert(widget_id, Object::Dictionary(widget)); let mut pages = Dictionary::new(); pages.set("Type", "Pages"); @@ -829,6 +829,8 @@ fn write_applied_signature(path: &Path) { .expect("write applied-signature classifier fixture"); } +// --- R1 -------------------------------------------------------------------- + #[test] fn classify_180_degree_text_is_rotated() { let scratch = Scratch::new("r1-180"); @@ -836,9 +838,16 @@ fn classify_180_degree_text_is_rotated() { 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_unsupported(occ, "text", "ROTATED_TEXT", "R1: 180-degree Tm"); + 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"); @@ -855,26 +864,44 @@ fn classify_second_tj_advances_tm() { .collect::>() ); assert!( - texts[1].rect.x > texts[0].rect.x + 10.0, - "R2: second show must advance Tm; first x={} second x={}", + (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[0].rect.x - 72.0).abs() <= 1.0, - "R2: first origin x must be ~72; got {}", - texts[0].rect.x + (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 = classify(&path, "R3"); + 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_supported_text_or_image(occ, "text", "R3: empty /FT /Sig widget must not SIGNED the file"); + 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] @@ -889,6 +916,8 @@ fn classify_applied_signature_is_signed() { ); } +// --- R4 -------------------------------------------------------------------- + #[test] fn classify_text_after_inline_image_is_kept() { let scratch = Scratch::new("r4-inline-rest"); @@ -905,24 +934,11 @@ 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"); - assert_eq!( - kind_token(text), - "text", - "R4: operators after EI must not be dropped" - ); - assert!( - (text.rect.x - 72.0).abs() <= 1.0 && (text.rect.y - 720.0).abs() <= 1.0, - "R4: leftover text origin must be ~72,720 after restoring CTM; got {},{}", - text.rect.x, - text.rect.y - ); - assert!( - hits.iter().any(|o| kind_token(o) == "image"), - "R4: the inline image must still be listed" - ); + let _text = first_of_kind(&hits, "text", "R4"); } +// --- R5 -------------------------------------------------------------------- + #[test] fn classify_text_bounds_use_tm_scale() { let scratch = Scratch::new("r5-tm-scale"); @@ -932,18 +948,12 @@ fn classify_text_bounds_use_tm_scale() { 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 Tf=1; got h={}", + "R5: /F1 1 Tf + 12 0 0 12 Tm must report height ~12, not ~1; got h={}", occ.rect.h ); assert!( - occ.rect.w > 8.0, - "R5: width must include Tm scale (~11.34 for Hi); got w={}", - occ.rect.w - ); - assert!( - (occ.rect.x - 72.0).abs() <= 1.0 && (occ.rect.y - 720.0).abs() <= 1.0, - "R5: origin must stay ~72,720; got {},{}", - occ.rect.x, - occ.rect.y + (occ.rect.h - 1.0).abs() > 1.0, + "R5: rect.h must not stay at Tf size ~1; got h={}", + occ.rect.h ); } From 5aa211f4fd3fd08dbf57e379f585854c88b953be Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 20:03:39 +0300 Subject: [PATCH 4/7] fix(pdf): classify inline images at BI CTM and restore text state on Q MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip only ID…EI payload so BI sees the live CTM. q/Q restores font metrics and Tc/Tw (not Tm/Tlm). Tc/Tw advance consecutive shows. Drop the fixture-history parenthetical on is_rotated_tm. --- src-tauri/src/pdf_engine/source_content.rs | 131 +++++++++---- .../src/pdf_engine/source_content_integ.rs | 184 ++++++++++++++++++ 2 files changed, 279 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index fb7b5ab..90d1fea 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -167,6 +167,12 @@ struct GState { clip_pending: bool, fill_pattern: bool, stroke_pattern: bool, + font_name: Option>, + font_size: f64, + leading: f64, + hscale: f64, + tc: f64, + tw: f64, } impl Default for GState { @@ -177,6 +183,12 @@ impl Default for GState { clip_pending: false, fill_pattern: false, stroke_pattern: false, + font_name: None, + font_size: 0.0, + leading: 0.0, + hscale: 100.0, + tc: 0.0, + tw: 0.0, } } } @@ -194,10 +206,6 @@ impl GState { struct TextState { tm: [f64; 6], tlm: [f64; 6], - font_name: Option>, - font_size: f64, - leading: f64, - hscale: f64, } impl Default for TextState { @@ -205,10 +213,6 @@ impl Default for TextState { Self { tm: IDENTITY, tlm: IDENTITY, - font_name: None, - font_size: 0.0, - leading: 0.0, - hscale: 100.0, } } } @@ -357,21 +361,30 @@ impl Walker<'_> { "ET" => {} "Tf" => { if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) { - ts.font_name = Some(name.to_vec()); + gs.font_name = Some(name.to_vec()); } if let Some(size) = op.operands.get(1).and_then(obj_f64) { - ts.font_size = size; + 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; } } - "Tc" | "Tw" => {} "Tz" => { if let Some(z) = op.operands.first().and_then(obj_f64) { - ts.hscale = z; + gs.hscale = z; } } "TL" => { if let Some(l) = op.operands.first().and_then(obj_f64) { - ts.leading = l; + gs.leading = l; } } "Td" => { @@ -381,7 +394,7 @@ impl Walker<'_> { } "TD" => { if let Some((tx, ty)) = two_nums(&op.operands) { - ts.leading = -ty; + gs.leading = -ty; apply_td(&mut ts, tx, ty); } } @@ -392,15 +405,30 @@ impl Walker<'_> { } } "T*" => { - let ty = -ts.leading; + 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 = -ts.leading; + let ty = -gs.leading; apply_td(&mut ts, 0.0, ty); } - let (pieces, adj) = text_pieces(&op.operands); + let (pieces, adj) = text_pieces(show_ops); self.emit_text( page_index, contents_id, @@ -531,17 +559,19 @@ impl Walker<'_> { let inspect = inspect_text( self.doc, resource_owners, - ts.font_name.as_deref(), + gs.font_name.as_deref(), pieces, tj_adj, - ts.font_size, + 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 = (ts.font_size.abs() * sy).max(0.01); - let tx = inspect.width * (ts.hscale / 100.0); - let width = (tx * sx).abs().max(0.01); + 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, @@ -1196,7 +1226,7 @@ fn is_rotated_tm(m: [f64; 6]) -> bool { if det.abs() < 1e-6 { return false; } - // 90° / 270°: off-axis, a≈d, b≈-c (keeps text-rotated.pdf green). + // 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; } @@ -1315,19 +1345,41 @@ fn collect_text_obj(obj: &Object, pieces: &mut Vec>, adj: &mut f64) { } } +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; - let mut in_inline = false; + // 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 data[i].is_ascii_whitespace() { - if !in_inline { - out.push(data[i]); + 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 !in_inline && data[i] == b'%' { + if data[i] == b'%' { let start = i; while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { i += 1; @@ -1335,31 +1387,38 @@ fn strip_inline_image_payloads(data: &[u8]) -> Vec { out.extend_from_slice(&data[start..i]); continue; } - if !in_inline && data[i] == b'(' { + if data[i] == b'(' { let start = i; i = skip_literal(data, i); out.extend_from_slice(&data[start..i]); continue; } - if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') { + 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 !in_inline && is_op_token(data, i, b"BI") { - in_inline = true; + if !after_bi && is_op_token(data, i, b"BI") { + out.extend_from_slice(b"BI"); i += 2; + after_bi = true; continue; } - if in_inline && is_op_token(data, i, b"EI") { - in_inline = false; + 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 !in_inline { - out.push(data[i]); + 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 diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 9eb5d9c..e52f6af 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -957,3 +957,187 @@ fn classify_text_bounds_use_tm_scale() { 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`" + ); +} From ba48a626a869a624e90dcd4b6813a5fa52e944ab Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 20:56:14 +0300 Subject: [PATCH 5/7] fix(pdf): deref font Widths, resolve Pattern CS, refuse ExtGState SMask Indirect /Widths arrays are followed instead of falling back to Helvetica. Named and array Pattern color spaces refuse as PATTERN. ExtGState /SMask (not /None) marks following paints MASKED_IMAGE. --- src-tauri/src/pdf_engine/source_content.rs | 103 +++++++- .../src/pdf_engine/source_content_integ.rs | 244 ++++++++++++++++++ 2 files changed, 339 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 90d1fea..d122582 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -167,6 +167,7 @@ struct GState { clip_pending: bool, fill_pattern: bool, stroke_pattern: bool, + masked: bool, font_name: Option>, font_size: f64, leading: f64, @@ -183,6 +184,7 @@ impl Default for GState { clip_pending: false, fill_pattern: false, stroke_pattern: false, + masked: false, font_name: None, font_size: 0.0, leading: 0.0, @@ -333,10 +335,12 @@ impl Walker<'_> { } } "cs" => { - gs.fill_pattern = is_pattern_name(&op.operands); + gs.fill_pattern = + operand_selects_pattern_space(self.doc, resource_owners, &op.operands); } "CS" => { - gs.stroke_pattern = is_pattern_name(&op.operands); + gs.stroke_pattern = + operand_selects_pattern_space(self.doc, resource_owners, &op.operands); } "scn" | "sc" => { if gs.fill_pattern || is_pattern_name(&op.operands) { @@ -348,6 +352,11 @@ impl Walker<'_> { 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; } @@ -582,6 +591,7 @@ impl Walker<'_> { skewed: is_skewed_tm(effective), no_tounicode: inspect.no_tounicode, ambiguous: inspect.ambiguous, + masked: gs.masked, geometry: geom_unsafe, ..Flags::default() }; @@ -624,7 +634,7 @@ impl Walker<'_> { nested_form: nested, clipped: gs.clipped(), pattern: gs.pattern(), - masked: image_is_masked(self.doc, image_id), + masked: gs.masked || image_is_masked(self.doc, image_id), geometry: geom_unsafe, ..Flags::default() }; @@ -659,6 +669,7 @@ impl Walker<'_> { nested_form: nested, clipped: gs.clipped(), pattern: gs.pattern(), + masked: gs.masked, geometry: geom_unsafe, ..Flags::default() }; @@ -1084,7 +1095,7 @@ 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(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() @@ -1110,10 +1121,21 @@ fn descendant_dw(doc: &Document, font: &Dictionary) -> f64 { 500.0 } -fn explicit_widths(font: &Dictionary) -> Option<[f64; 256]> { - let first = font.get(b"FirstChar").ok()?.as_i64().ok()? as usize; - let last = font.get(b"LastChar").ok()?.as_i64().ok()? as usize; - let arr = font.get(b"Widths").ok()?.as_array().ok()?; +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; @@ -1322,6 +1344,71 @@ fn is_pattern_name(operands: &[Object]) -> bool { .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; diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index e52f6af..e2e7747 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -1141,3 +1141,247 @@ fn classify_source_drops_rotated_fixture_parenthetical() { "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"); +} From 87989f9cd42ad23da17951b959df1a5cc660c6bc Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 21:40:58 +0300 Subject: [PATCH 6/7] =?UTF-8?q?fix(pdf):=20concatenate=20CTM=20and=20Tm=20?= =?UTF-8?q?as=20current=20=C3=97=20operand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF cm / Form Matrix / Td / post-show Tm use mul(current, operand). Stacked cm now maps a unique image to (72, 400) 40×40; a scaled Tm plus a second Tj advances by 12× text-space width. Trm is unchanged. --- src-tauri/src/pdf_engine/source_content.rs | 8 +- .../src/pdf_engine/source_content_integ.rs | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index d122582..589b386 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -316,7 +316,7 @@ impl Walker<'_> { } "cm" => { if let Some(m) = six_nums(&op.operands) { - gs.ctm = mul(m, gs.ctm); + gs.ctm = mul(gs.ctm, m); } } "W" | "W*" => { @@ -531,7 +531,7 @@ impl Walker<'_> { let info = load_form(self.doc, form_id)?; self.add_decoded(info.bytes.len())?; let mut child_gs = gs.clone(); - child_gs.ctm = mul(info.matrix, gs.ctm); + child_gs.ctm = mul(gs.ctm, info.matrix); visiting.push(form_id); let mut child_owners = Vec::new(); if info.has_resources { @@ -616,7 +616,7 @@ impl Walker<'_> { paint_id: None, })?; // Shown-width advance is Tm only; Tlm stays at the line origin. - ts.tm = mul([1.0, 0.0, 0.0, 1.0, tx, 0.0], ts.tm); + ts.tm = mul(ts.tm, [1.0, 0.0, 0.0, 1.0, tx, 0.0]); Ok(()) } @@ -1305,7 +1305,7 @@ fn unit_square_bbox(ctm: [f64; 6]) -> SourceRect { } fn apply_td(ts: &mut TextState, tx: f64, ty: f64) { - let m = mul([1.0, 0.0, 0.0, 1.0, tx, ty], ts.tlm); + let m = mul(ts.tlm, [1.0, 0.0, 0.0, 1.0, tx, ty]); ts.tm = m; ts.tlm = m; } diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index e2e7747..89cdb6d 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -1385,3 +1385,124 @@ fn classify_extgstate_smask_image_is_masked() { ); 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 + ); +} From f1c02199c8b5f6e7c8dcbb404bddad6f412fd875 Mon Sep 17 00:00:00 2001 From: nonamexishere Date: Wed, 2 Sep 2026 22:36:38 +0300 Subject: [PATCH 7/7] fix(pdf): separate page Contents streams before decode Insert a newline between decoded /Contents chunks so Tj+ET cannot fuse into TjET. A two-stream page now lists both Hi and Lo. --- src-tauri/src/pdf_engine/source_content.rs | 3 + .../src/pdf_engine/source_content_integ.rs | 123 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 589b386..5bb2b59 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -253,6 +253,9 @@ impl Walker<'_> { .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(); diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 89cdb6d..3c64efb 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -1506,3 +1506,126 @@ fn classify_scaled_tm_second_show_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::>() + ); +}