M5: stamps without an appearance draw the stamp they name (ISO 32000-2 Table 184)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
mtlcl410
2026-10-24 09:15:12 +08:00
co-authored by Claude Opus 5.5
parent a2ec27b477
commit 3db28cc4e3
6 changed files with 339 additions and 11 deletions
+90 -6
View File
@@ -670,7 +670,8 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
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<Stream> {
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<Stream> {
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(
+8 -1
View File
@@ -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();
+82 -2
View File
@@ -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");