mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a2ec27b477
commit
3db28cc4e3
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user