From 3db28cc4e35dba7a8002bddb62e4cd91e2e4cb97 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:15:12 +0800 Subject: [PATCH] M5: stamps without an appearance draw the stamp they name (ISO 32000-2 Table 184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rubber stamp from another program with no normal appearance was not drawn at all. The display copy now gives it one: - the fourteen standard names of Table 184 draw that stamp with PdfCraft's stamp drawing (eight from the palette, six with labels of their own), fitted to /Rect, in /C or the stamp's colour; - no /Name draws Draft, the table's default, unless /IT says the stamp is an image or a snapshot; - any other name (names match byte for byte, §7.3.5) draws a thin grey frame with both diagonals, so the stamp stays visible and selectable. The drawing is display-only, like the other comments without an appearance: Save writes the file as it was, and build() still declines these stamps, so restyling and flattening never write it into the file. A stamp with its own /AP /N is never touched. stamp() now takes the label and the sign-here flag instead of a StampKind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/annot/src/appearance.rs | 96 ++++++++++++++++++-- crates/annot/src/lib.rs | 9 +- crates/annot/src/tests.rs | 84 +++++++++++++++++- crates/automation/tests/it/automation.rs | 50 +++++++++++ crates/engine/src/tests.rs | 107 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 339 insertions(+), 11 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 66a86aa30f..a8a080e768 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -670,7 +670,8 @@ pub fn build(d: &Dict) -> Option { } let kind = crate::StampKind::from_name(other)?; let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); } } if turn != 0 { @@ -816,9 +817,93 @@ pub fn build(d: &Dict) -> Option { Some(form(rect, &out, res)) } +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default), or for any other name a thin +/// grey frame with both diagonals, so the stamp stays visible and can be selected. The name is +/// never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + rg_stroke(stroke.unwrap_or(PLACEHOLDER_GREY)), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + /// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold /// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { +fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { let [x0, y0, x1, y1] = rect; let h = y1 - y0; let mut c = String::new(); @@ -827,7 +912,7 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa } let lw = (h * 0.07).clamp(1.0, 3.0); let inset = lw / 2.0 + 0.5; - if kind.group() == crate::StampGroup::SignHere { + if sign_here { // A tag pointing left, filled, with white text. let tip = h * 0.45; c.push_str(&format!( @@ -893,16 +978,15 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa n(b0) )); } - let label = kind.label(); let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if kind.group() == crate::StampGroup::SignHere { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; let mut size = title_h; let tw = text_width(label, size) * 1.12; if tw > text_area.1 - text_area.0 - 4.0 { size *= (text_area.1 - text_area.0 - 4.0) / tw; } let tw = text_width(label, size) * 1.12; - let text_col = if kind.group() == crate::StampGroup::SignHere { [1.0, 1.0, 1.0] } else { col }; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; let mut out = c.into_bytes(); out.extend( diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd80..f506def599 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1586,7 +1586,14 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); continue; } - let Some(stream) = build_appearance(doc, &d) else { continue }; + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; cache.insert(r, &stream); // Fails only when `r` isn't a dictionary, which the build just ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b106..2af04bc620 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1014,6 +1014,86 @@ fn a_note_without_an_appearance_is_shown_at_icon_size() { assert_eq!(rects(&doc), original, "the source document is unchanged"); } +/// The fourteen rubber stamp names viewers draw without an appearance (ISO 32000-2 Table 184). +const TABLE_184_STAMPS: [(&str, &str); 14] = [ + ("Approved", "APPROVED"), + ("Experimental", "EXPERIMENTAL"), + ("NotApproved", "NOT APPROVED"), + ("AsIs", "AS IS"), + ("Expired", "EXPIRED"), + ("NotForPublicRelease", "NOT FOR PUBLIC RELEASE"), + ("Confidential", "CONFIDENTIAL"), + ("Final", "FINAL"), + ("Sold", "SOLD"), + ("Departmental", "DEPARTMENTAL"), + ("ForComment", "FOR COMMENT"), + ("TopSecret", "TOP SECRET"), + ("Draft", "DRAFT"), + ("ForPublicRelease", "FOR PUBLIC RELEASE"), +]; + +fn stamp_dict(extra: &str) -> Dict { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + format!("<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] {extra} >>").as_bytes(), + ]); + list(&doc, 0).remove(0) +} + +fn drawn(d: &Dict) -> String { + String::from_utf8_lossy(&appearance::display_stamp(d).expect("a stamp is drawn").decoded().unwrap()).into_owned() +} + +/// A stamp another program wrote without an appearance draws the stamp its `/Name` names, Draft +/// when it has none, and a frame with both diagonals for any other name, never the name itself. +#[test] +fn a_stamp_without_an_appearance_draws_the_stamp_it_names() { + for (name, label) in TABLE_184_STAMPS { + let content = drawn(&stamp_dict(&format!("/Name /{name}"))); + assert!(content.contains(&format!("({label}) Tj")), "{name}: {content}"); + } + assert!(drawn(&stamp_dict("")).contains("(DRAFT) Tj"), "Table 184's default"); + assert!(drawn(&stamp_dict("/IT /Stamp")).contains("(DRAFT) Tj"), "a stamp intent keeps the default"); + // Any other name, a name in another case (names are compared byte for byte, §7.3.5), and an + // image stamp (`/IT`, which has no `/Name` to default): the frame and its diagonals. + for extra in ["/Name /MyCustomStamp", "/Name /draft", "/Name /APPROVED", "/IT /StampImage", "/Name /PCVoid"] { + let content = drawn(&stamp_dict(extra)); + assert!(!content.contains("Tj") && content.contains(" re\n") && content.matches(" l\n").count() == 2, "{extra}: {content}"); + } + // `/C` colours the stamp, as it does PdfCraft's own. + assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG")); + // A stamp's own artwork is never replaced, and the drawing is for display only: edits + // (restyling, flattening) still find nothing they may write. + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none()); + assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none()); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`"); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width"); +} + +/// The display copy draws a stamp that has no normal appearance, and keeps one that has. +#[test] +fn the_display_copy_draws_stamps_without_a_normal_appearance_only() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] /Name /TopSecret >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 140 140 200] /Name /Draft /AP << /N 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 240 140 300] /Name /Sold /AP << /D 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 340 140 400] /Name /Unheard >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 8 >>\nstream\n1 0 0 rg\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("three stamps need an appearance"); + let shown = list(&display, 0); + assert!(ap_content(&display, &shown[0]).contains("(TOP SECRET) Tj")); + assert_eq!(shown[1].get(b"AP"), list(&doc, 0)[1].get(b"AP"), "an existing appearance is kept"); + assert!(ap_content(&display, &shown[2]).contains("(SOLD) Tj"), "an /AP without /N has nothing to keep"); + assert!(!ap_content(&display, &shown[3]).contains("Tj"), "an unknown name gets the placeholder"); + assert!(list(&doc, 0)[0].get(b"AP").is_none(), "the document itself is untouched"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; @@ -1316,9 +1396,9 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { .expect("strips"); let mut cache = AppearanceCache::default(); let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds"); - assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance"); + assert_eq!((cache.builds(), cache.hits()), (2, 0), "the note and the file's stamp need an appearance"); let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses"); - assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn"); + assert_eq!((cache.builds(), cache.hits()), (2, 2), "nothing is redrawn"); let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx])); assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..6cd8f3144d 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8424,3 +8424,53 @@ fn bookmark_list_reports_colour_and_style() { let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } + +/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its +/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder. +#[test] +fn page_render_draws_stamps_without_an_appearance() { + let dir = workdir("stamps-without-appearance"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 100] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [10 20 130 80] /Name /Approved >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [170 20 290 80] /Name /MyCustomStamp >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("stamps.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "stamps.pdf" }))["doc"].as_u64().unwrap(); + let (width, _, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let pixels = pixels.as_ref(); + // Ink in columns `x0..x1` of rows 20..80, which are the stamps' rows whether counted from the top or the bottom. + let ink = |x0: u32, x1: u32| -> Vec<[u8; 3]> { + (20..80u32) + .flat_map(|y| (x0..x1).map(move |x| (y, x))) + .map(|(y, x)| { + let i = ((y * width + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .filter(|p| p.iter().any(|v| *v < 200)) + .collect() + }; + let approved = ink(10, 130); + let green = approved.iter().filter(|p| p[1] > p[0] && p[1] > p[2]).count(); + assert!(green > 300 && green * 10 > approved.len() * 9, "Approved is drawn green: {green} of {}", approved.len()); + let custom = ink(170, 290); + let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); + assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..3a5d4d500a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5884,6 +5884,113 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// A stamp's `/Rect` and the colour family expected inside it. +type StampRect = ([f64; 4], char); + +/// Rubber stamps without an appearance (ISO 32000-2 Table 184): the fourteen standard names, one +/// without `/Name`, one custom name, and last a Draft stamp with artwork of its own (magenta). +/// Each sits in a 120 × 60 `/Rect` on a 612 × 792 page; the name and the expected colour family +/// (`'r'`, `'g'`, `'b'`, or `'n'` for the neutral placeholder) of each, in page order. +fn stamps_without_appearances() -> (Arc>, Vec) { + let stamps: [(Option<&str>, char); 16] = [ + (Some("Approved"), 'g'), + (Some("Experimental"), 'b'), + (Some("NotApproved"), 'r'), + (Some("AsIs"), 'b'), + (Some("Expired"), 'r'), + (Some("NotForPublicRelease"), 'r'), + (Some("Confidential"), 'r'), + (Some("Final"), 'g'), + (Some("Sold"), 'g'), + (Some("Departmental"), 'b'), + (Some("ForComment"), 'b'), + (Some("TopSecret"), 'r'), + (Some("Draft"), 'b'), + (Some("ForPublicRelease"), 'b'), + (None, 'b'), + (Some("MyCustomStamp"), 'n'), + ]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), String::new()]; + let mut expected = Vec::new(); + for (i, (name, family)) in stamps.iter().enumerate() { + let (x, y) = (30 + (i % 4) * 145, 660 - (i / 4) * 150); + let rect = [x as f64, y as f64, x as f64 + 120.0, y as f64 + 60.0]; + let name = name.map(|n| format!("/Name /{n}")).unwrap_or_default(); + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [{} {} {} {}] {name} /F 4 >>", rect[0], rect[1], rect[2], rect[3])); + expected.push((rect, *family)); + } + let own = objs.len() + 2; + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [30 60 150 120] /Name /Draft /F 4 /AP << /N {own} 0 R >> >>")); + expected.push(([30.0, 60.0, 150.0, 120.0], 'm')); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 23 >>\nstream\n1 0 1 rg 0 0 10 10 re f\nendstream".into()); + let annots: Vec = (4..own).map(|n| format!("{n} 0 R")).collect(); + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (Arc::new(bytes), expected) +} + +/// The colour family of an RGBA pixel that isn't white: `'r'`, `'g'` or `'b'` for the dominant +/// channel, `'n'` for a grey, `'m'` for magenta. +fn colour_family(p: &[u8]) -> Option { + let (r, g, b) = (i32::from(p[0]), i32::from(p[1]), i32::from(p[2])); + if r.min(g).min(b) > 200 { + return None; + } + Some(if (r - g).abs() < 24 && (g - b).abs() < 24 { + 'n' + } else if r > 200 && b > 200 && g < 80 { + 'm' + } else if r > g && r > b { + 'r' + } else if g > r && g > b { + 'g' + } else { + 'b' + }) +} + +/// Rubber stamps without an appearance are drawn from their `/Name` (ISO 32000-2 Table 184), in +/// the colour of the stamp they name; an unknown name gets a grey placeholder; a stamp's own +/// artwork is drawn as it is. Display only: Save writes the file without new appearances. +#[test] +fn stamps_without_appearances_draw_the_stamp_they_name() { + let (original, expected) = stamps_without_appearances(); + let mut s = Session::new(); + let id = s.open("stamps.pdf", None, original.clone(), None).expect("opens"); + let p = shown(&s, id, 0); + for (i, (rect, family)) in expected.iter().enumerate() { + let mut counts = std::collections::HashMap::new(); + for y in (792.0 - rect[3]) as u32..(792.0 - rect[1]) as u32 { + for x in rect[0] as u32..rect[2] as u32 { + let px = &p.rgba[((y * p.width + x) * 4) as usize..][..4]; + if let Some(f) = colour_family(px) { + *counts.entry(f).or_insert(0) += 1; + } + } + } + let total: u32 = counts.values().sum(); + let main = counts.iter().max_by_key(|(_, n)| **n).map(|(f, _)| *f); + assert!(total > 300, "stamp {i} draws ink inside its /Rect: {counts:?}"); + assert_eq!(main, Some(*family), "stamp {i} is drawn in its colour: {counts:?}"); + } + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for n in 4..20 { + assert!(saved.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().is_some_and(|d| !d.contains(b"AP")), "{n} is saved without /AP"); + } +} + /// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371. fn free_text_without_appearance() -> Arc> { let objs: Vec<&[u8]> = vec![ diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..4d83fe6282 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation"