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(