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");
+50
View File
@@ -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());
}
+107
View File
@@ -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<u8>>, Vec<StampRect>) {
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<String> =
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<String> = (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<char> {
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<Vec<u8>> {
let objs: Vec<&[u8]> = vec![
+2 -2
View File
@@ -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"