M6: check boxes, radio buttons and push buttons without an appearance are drawn, and comb fields show their cells (ISO 32000-2 Table 192, 12.7.5)

A form that leaves its appearances to the viewer had its text and choice
fields drawn for display (#3475), but not its buttons. Now, also for
display only, without changing the file:

- Check boxes and radio buttons with no normal appearance for their
  state show their /MK background and border (Table 192), and when on,
  the /MK /CA caption as a standard-14 ZapfDingbats character
  (12.7.5.2.3), 4 or l by default, in the /DA colour, auto-sized for
  0 Tf.
- Push buttons with none show their /MK background, their /BS border
  (beveled per Table 168) and their centred caption.
- Comb fields with a border colour draw the MaxLen - 1 lines between
  their positions (Table 228), up to 500 cells. This also applies to the
  appearances PdfCraft saves for comb fields.
- A combo box drawn for display shows a drop-down button on screen, with
  its text kept clear of it. Saved appearances and Print as Image leave
  it out (with_printed_field_appearances, printed_display_bytes).

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 11:51:00 +08:00
co-authored by Claude Opus 5.5
parent a047c6ba09
commit 5233e12281
9 changed files with 479 additions and 26 deletions
+10
View File
@@ -934,6 +934,16 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
}
}
/// The display copy as it prints ([`print_raster`]): [`display_bytes`] without what is only for the
/// screen, a drawn combo box's drop-down button. `None` when it would be the display copy itself
/// (no field is drawn for display, or the document is not open for editing) or cannot be written.
pub(crate) fn printed_display_bytes(doc: &Document) -> Option<Arc<Vec<u8>>> {
let e = doc.editor.as_ref()?;
let comments = pdfcraft_annot::with_missing_appearances(&e.cos);
let fields = pdfcraft_forms::with_printed_field_appearances(comments.as_ref().unwrap_or(&e.cos))?;
write_incremental(&fields, &SaveOptions::default()).ok().map(Arc::new)
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
+3 -1
View File
@@ -23,7 +23,9 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
// The copy below holds only what prints, so the viewer's own comment hiding plays no part.
config.hide_comments = false;
let printed = guard(|| -> Result<Vec<u8>, String> {
let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
// The display copy, less what it shows only on screen.
let shown = crate::printed_display_bytes(doc).unwrap_or_else(|| src.bytes.clone());
let mut cos = pdfcraft_cos::Document::open_with_password(shown, config.password.as_deref()).map_err(|e| e.to_string())?;
pdfcraft_print::keep_printed(&mut cos, content);
write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())
})??;
+61
View File
@@ -7438,3 +7438,64 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
assert!(pages[0].images.is_empty(), "the picture is past the budget");
assert_eq!(truncated, [0]);
}
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
/// field and a combo box the file leaves to the viewer. The page as displayed shows the check
/// box's caption symbol in its text colour, the button's grey face, the comb's cells in the border
/// colour and the combo box's drop-down button; printing leaves that button out, and saving
/// writes the file as it was.
#[test]
fn buttons_and_combs_left_to_the_viewer_are_displayed() {
let pdf = b"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R 9 0 R] >> endobj\n\
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\
8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\
9 0 obj << /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (m) /Opt [(Apple) (Banana)] /V (Apple) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [20 20 220 50] /F 4 /P 3 0 R >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
.to_vec();
let render = |bytes: Arc<Vec<u8>>| {
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
};
// Pixels of the page (drawn at scale 1, 400 x 200) in user-space box x0..x1, y0..y1 that `keep`.
let count = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32), keep: &dyn Fn(&[u8; 4]) -> bool| {
let (px, _) = page.rgba.as_chunks::<4>();
(200 - y1..200 - y0)
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(keep))
.count()
};
let red = |p: &[u8; 4]| p[0] > 200 && p[1] < 80 && p[2] < 80;
let grey = |p: &[u8; 4]| (180..=230).contains(&p[0]) && p[0] == p[1] && p[1] == p[2];
// Bluish: a 1 pt line on a pixel edge is half blue on each side.
let blue = |p: &[u8; 4]| p[2] > 200 && p[0] < 180 && p[1] < 180 && p[0] + 40 < p[2];
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf), None).unwrap();
let shown = render(s.get(id).unwrap().display.clone());
assert!(count(&shown, (24, 154, 56, 186), &red) > 60, "the check box's red cross");
assert!(count(&shown, (104, 154, 216, 186), &grey) > 1000, "the push button's grey face");
// The comb's three inner lines at x = 70, 120 and 170, between its cells.
for x in [70, 120, 170] {
assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}");
}
assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell");
// The combo box's drop-down button: a 28 pt square inside the right edge.
let button = (193, 22, 218, 48);
assert!(count(&shown, button, &grey) > 300, "the drop-down button on screen");
// Print as Image: the page as a picture, at 72 dpi so it is the size of the page here.
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
let printed = render(Arc::new(pictures));
assert_eq!(count(&printed, button, &grey), 0, "not on paper");
assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints");
// The file is saved as it was: no widget has an appearance.
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
for f in pdfcraft_forms::fields(&saved) {
assert!(saved.get(f.widgets[0].obj).as_dict().is_some_and(|d| !d.contains(b"AP")), "{} has no appearance", f.name);
}
}
+8 -3
View File
@@ -39,8 +39,9 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
- Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the
field tree; the form's `/DA` and `/Q` are the last fallback.
- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb,
multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection.
- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb
(cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK`
background and border, list-box selection.
The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined
Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold
(Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat
@@ -55,7 +56,11 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
drawn from their values, as typing them would draw them; the engine shows and prints that copy
and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
one field redraws that field, not the form.
one field redraws that field, not the form. Check boxes and radio buttons with no appearance for
their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. On
screen a drawn combo box also shows a drop-down button; `with_printed_field_appearances` leaves it
out for printing.
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
+164 -4
View File
@@ -351,7 +351,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
fn border_width(doc: &Document, wd: &Dict) -> f64 {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
let bw = line_width(doc, wd);
if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 {
return 0.0;
}
@@ -359,6 +359,56 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 {
if style == b"B" || style == b"I" { 2.0 * bw } else { bw }
}
/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it.
fn line_width(doc: &Document, wd: &Dict) -> f64 {
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
// `f64::max` turns NaN into 0, as it does a negative width.
bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0)
}
/// Comb fields stop drawing their cells past this many; they would be a solid block anyway.
const MAX_COMB_DIVIDERS: usize = 500;
/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228,
/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and
/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells.
fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let inset = border_width(doc, wd);
let lw = line_width(doc, wd);
let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() };
if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) {
return String::new();
}
let cell = w / cells as f64;
let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw));
for i in 1..cells {
let x = cell * i as f64;
c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset)));
}
c.push_str("S\n");
c
}
/// A combo box's drop-down button for the screen: a `size`-wide light grey face at the right
/// edge inside the `bw` border of a `w` × `h` widget, parted from the text by a line in the
/// border's colour, with a downward triangle.
fn drop_down_button(doc: &Document, wd: &Dict, w: f64, h: f64, bw: f64, size: f64) -> String {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let (x0, y0, x1, y1) = (w - bw - size, bw, w - bw, h - bw);
let mut c = format!("0.85 g\n{} {} {} {} re f\n", n(x0), n(y0), n(size), n(y1 - y0));
let lw = line_width(doc, wd);
if let Some(bc) = color_array(doc, mk.as_ref(), b"BC")
&& lw > 0.0
&& lw.is_finite()
{
c.push_str(&format!("{}\n{} w\n{} {} m {} {} l S\n", stroke_op(&bc), n(lw), n(x0), n(y0), n(x0), n(y1)));
}
let (cx, cy, d) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, size * 0.22);
c.push_str(&format!("0 g\n{} {} m {} {} l {} {} l h f\n", n(cx - d), n(cy + d / 2.0), n(cx + d), n(cy + d / 2.0), n(cx), n(cy - d / 2.0)));
c
}
/// Where a list box draws its options: one row per option from the top, starting with option
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
/// it, so a click picks the option drawn under the pointer (#1524).
@@ -449,6 +499,19 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
/// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't
/// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF.
pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
field_look(doc, f, w, values, format, false)
}
/// [`field_appearance_drawn`] as the page view shows a field the file leaves to the viewer: a
/// combo box also gets its drop-down button at the right edge, which the text keeps clear of.
/// The button is something to click on screen, not part of the field's printed look, so it is
/// drawn only here and never into an appearance PdfCraft saves or prints.
pub(crate) fn field_appearance_on_screen(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
field_look(doc, f, w, values, format, true)
}
/// The appearance of a text or choice field, with a combo box's drop-down button when `screen`.
fn field_look(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool, screen: bool) -> (Stream, Option<char>) {
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
let values = if format
@@ -489,7 +552,15 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
let width_of = |text: &str, size: f64| enc.width(text, size);
let (mut c, bw) = frame(doc, &wd, width, height);
let pad = 2.0 + bw;
let inner_w = (width - 2.0 * pad).max(1.0);
// A combo box's drop-down button on screen: a square as tall as the room inside the border,
// never wider than that room.
let button = if screen && f.kind == FieldKind::Combo { (height - 2.0 * bw).min(width - 2.0 * bw).max(0.0) } else { 0.0 };
if button > 0.0 {
c.push_str(&drop_down_button(doc, &wd, width, height, bw, button));
}
// The text and its clip keep to the room left of the button.
let text_w = width - button;
let inner_w = (text_w - 2.0 * pad).max(1.0);
let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding);
let mut body: Vec<u8> = Vec::new();
// The first character drawn that the font can't hold (drawn as `?`), for the caller to report.
@@ -508,7 +579,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
let tw = width_of(text, size);
match q {
1 => pad + (inner_w - tw) / 2.0,
2 => width - pad - tw,
2 => text_w - pad - tw,
_ => pad,
}
};
@@ -560,6 +631,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
if comb {
let cells = f.max_len.unwrap_or(1).max(1);
let cell = width / cells as f64;
c.push_str(&comb_dividers(doc, &wd, width, height, cells));
for (i, ch) in text.chars().take(cells).enumerate() {
let s = ch.to_string();
show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
@@ -577,7 +649,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
"/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n",
n(bw),
n(bw),
n(width - 2.0 * bw),
n(text_w - 2.0 * bw),
n(height - 2.0 * bw),
font_name,
n(size),
@@ -720,6 +792,94 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str
}
}
/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when
/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and
/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes
/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of
/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is
/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most
/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed.
pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let (frame_c, bw) = frame(doc, &wd, width, height);
let mut content = frame_c.into_bytes();
let mut res = Dict::new();
if on {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()));
let dingbats = pdfcraft_fonts::Std14::ZapfDingbats;
// The caption's first character as a ZapfDingbats code, when the font has a glyph there.
let code = caption
.and_then(|s| s.chars().next())
.and_then(|ch| u8::try_from(u32::from(ch)).ok())
.filter(|&code| dingbats.width(code) > 0.0)
.unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' });
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
// The glyph's advance and (about) its height, in ems.
let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75);
let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0);
// An automatic size leaves a margin of a tenth of the room on each side.
let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) };
let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size);
content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes());
content.extend(literal(&[code]));
content.extend_from_slice(b" Tj\nET\nQ\n");
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats"));
let mut fonts = Dict::new();
fonts.set(b"ZaDb".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &content)
}
/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK`
/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I`
/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour.
/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included.
pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
if mk.contains(b"I") {
return crate::author::button_appearance(doc, w, &f.da).0;
}
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let (frame_c, _) = frame(doc, &wd, width, height);
let mut content = frame_c.into_bytes();
let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default();
let mut fonts = Dict::new();
if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) {
let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()));
// The automatic size PdfCraft's own buttons use.
let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0);
let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto);
content.extend(ops);
fonts.set(name.into_bytes(), font);
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &content)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -775,7 +775,7 @@ fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources:
/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one).
/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to
/// mark and report.
fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option<char>) {
pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option<char>) {
let (mut c, place) = frame_only(doc, w);
let (width, height) = (place.layout_w, place.layout_h);
let wobj = doc.get(w.obj);
+70 -11
View File
@@ -1298,11 +1298,15 @@ pub fn need_appearances(doc: &Document) -> bool {
acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))
}
/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying
/// the document (`None` when no widget needs it): every such widget when the form sets
/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer),
/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value
/// would draw it (#3470). `doc` is not changed: saving writes the file as it was.
/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for
/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from
/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the
/// writer left the appearances to the viewer), and otherwise each widget without a normal
/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons
/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border
/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192); the page view
/// also shows a drawn combo box's drop-down button. `doc` is not changed: saving writes the file
/// as it was.
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
}
@@ -1310,20 +1314,60 @@ pub fn with_field_appearances(doc: &Document) -> Option<Document> {
/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are
/// the same objects as then (see [`FieldAppearanceCache`]).
pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option<Document> {
fields_shown(doc, cache, true)
}
/// [`with_field_appearances`] for printing: the same, without the drop-down buttons of combo
/// boxes, which are for clicking on screen and not part of a field's printed look.
pub fn with_printed_field_appearances(doc: &Document) -> Option<Document> {
fields_shown(doc, &mut FieldAppearanceCache::default(), false)
}
/// [`with_field_appearances_cached`]; `screen` adds the on-screen parts (a combo box's button).
/// One cache serves one of the two: the engine keeps one for the screen and prints uncached.
fn fields_shown(doc: &Document, cache: &mut FieldAppearanceCache, screen: bool) -> Option<Document> {
let all = need_appearances(doc);
let mut copy: Option<Document> = None;
for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) {
for f in fields(doc) {
for w in &f.widgets {
let widget = doc.get(w.obj);
let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
if drawn && !all {
let normal = widget
.as_dict()
.and_then(|d| d.get(b"AP"))
.and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n))));
// A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`,
// else a check box's value, else Off. An Off state may go without one (it shows
// nothing); an on state needs one.
let state = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => {
let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off");
Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into()))
}
_ => None,
};
let missing = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
FieldKind::PushButton => normal.is_none(),
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
(None, _) => true,
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
_ => false,
},
FieldKind::Signature => false,
};
if !missing {
continue;
}
let inputs = appearance_inputs(doc, w);
let stream = match cache.get(w.obj, &inputs) {
Some(stream) => stream,
None => {
let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true);
let stream = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")),
FieldKind::PushButton => appearance::push_button_shown(doc, &f, w),
_ if screen => appearance::field_appearance_on_screen(doc, &f, w, &f.value, true).0,
_ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0,
};
cache.insert(w.obj, inputs, &stream);
stream
}
@@ -1331,10 +1375,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
let target = copy.get_or_insert_with(|| doc.clone());
let ap = target.add(Object::Stream(stream));
let mut fresh = Dict::new();
fresh.set(b"N".to_vec(), Object::Ref(ap));
match &state {
// The drawn look under its state's name, and that state as the widget's own.
Some(s) => {
let mut states = Dict::new();
states.set(name_bytes(s), Object::Ref(ap));
fresh.set(b"N".to_vec(), Object::Dict(states));
}
None => fresh.set(b"N".to_vec(), Object::Ref(ap)),
}
let apd = merged_appearance(target, w.obj, &fresh);
// Fails only when the widget isn't a dictionary; that widget then keeps its own look.
target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok();
target
.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
if let Some(s) = &state {
d.set(b"AS".to_vec(), Object::Name(name_bytes(s)));
}
})
.ok();
}
}
copy
+156
View File
@@ -3097,3 +3097,159 @@ fn a_skipped_field_leaves_its_widget_to_the_next() {
let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect();
assert_eq!(widgets, [("b", 1)]);
}
/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field
/// and a combo box, for the display copy.
fn left_buttons() -> Document {
let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>");
document(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(),
"<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6
widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"),
widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"),
widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"),
widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10
widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"),
widget(
"o",
"/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]",
),
widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"),
widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"),
widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15
widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"),
widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"),
widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"),
"<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19
])
}
/// The appearance stream of widget `w` in `state` (a check box's `/AP /N /<state>`).
fn state_ap(doc: &Document, w: &Widget, state: &str) -> String {
let wd = doc.get(w.obj).as_dict().cloned().unwrap();
let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap();
let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap();
let r = n.reference(state.as_bytes()).expect("the state's appearance");
let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
/// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`.
fn saved_look(doc: &Document, name: &str) -> String {
let all = fields(doc);
let f = field(&all, name);
String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned()
}
/// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC`
/// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour,
/// sized to the box when the `/DA` size is 0.
#[test]
fn check_boxes_without_an_appearance_show_their_caption_symbol() {
let doc = left_buttons();
let copy = with_field_appearances(&doc).expect("buttons to draw");
let all = fields(&copy);
for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] {
let shown = state_ap(&copy, &field(&all, name).widgets[0], "Yes");
assert!(shown.contains(symbol), "{name}: {shown}");
assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}");
assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}");
}
// A /DA size is kept; the automatic one fills the box inside the border.
let l = state_ap(&copy, &field(&all, "l").widgets[0], "Yes");
assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}");
let i = state_ap(&copy, &field(&all, "i").widgets[0], "Yes");
let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap();
assert!((18.0..=26.0).contains(&size), "{size}");
let w = copy.get(field(&all, "i").widgets[0].obj);
let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap());
let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() };
let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb"));
assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program");
}
/// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box
/// whose file has only an Off look gets an on look, and one with its own look keeps it.
#[test]
fn button_states_without_an_appearance() {
let doc = left_buttons();
let copy = with_field_appearances(&doc).expect("buttons to draw");
let all = fields(&copy);
let off = state_ap(&copy, &field(&all, "off").widgets[0], "Off");
assert!(off.contains("re S") && !off.contains("Tj"), "{off}");
assert!(state_ap(&copy, &field(&all, "radio").widgets[0], "A").contains("(l) Tj"));
assert!(state_ap(&copy, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj"));
let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned());
assert_eq!(own(&copy), own(&doc), "a check box with its own look keeps it");
// Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic.
let bad = state_ap(&copy, &field(&all, "bad").widgets[0], "Yes");
assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}");
// The document itself is untouched.
assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP")));
}
/// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its
/// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred.
#[test]
fn push_buttons_without_an_appearance_show_their_caption_and_frame() {
let copy = with_field_appearances(&left_buttons()).expect("buttons to draw");
let shown = ap(&copy, &field(&fields(&copy), "o").widgets[0]);
for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] {
assert!(shown.contains(part), "{part}: {shown}");
}
}
/// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a
/// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too.
#[test]
fn comb_fields_show_their_cells() {
let mut doc = left_buttons();
let shown = saved_look(&doc, "d");
let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect();
assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}");
assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}");
// Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either.
let r = field(&fields(&doc), "d").widgets[0].obj;
doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap();
let huge = saved_look(&doc, "d");
assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}");
doc.update_dict(r, |w| {
w.set(b"MaxLen".to_vec(), Object::Int(6));
w.remove(b"MK");
})
.unwrap();
let plain = saved_look(&doc, "d");
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
}
/// A combo box drawn for display shows its drop-down button at the right edge and keeps its text
/// clear of it; a list box, a text field, the combo box's saved look and its printed one have none.
#[test]
fn combo_boxes_shown_on_screen_have_a_drop_down_button() {
let doc = left_buttons();
let screen = with_field_appearances(&doc).expect("fields to draw");
let printed = with_printed_field_appearances(&doc).expect("fields to draw");
let m = ap(&screen, &field(&fields(&screen), "m").widgets[0]);
// A 22 pt square inside the 1 pt border, a line in the border colour, a triangle.
assert!(m.contains("0.85 g\n97 1 22 22 re f\n0 0 0 RG\n1 w\n97 1 m 97 23 l S\n0 g\n"), "{m}");
// The text's clip stops at the button.
assert!(m.contains("1 1 96 22 re W n"), "{m}");
let printed_m = ap(&printed, &field(&fields(&printed), "m").widgets[0]);
assert!(!printed_m.contains("0.85 g") && printed_m.contains("1 1 118 22 re W n"), "{printed_m}");
let saved = saved_look(&doc, "m");
assert!(!saved.contains("0.85 g"), "{saved}");
// Right-aligned text moves left by the button's width.
let x = |s: &str| -> f64 {
s.split(" Tm ").next().and_then(|s| s.rsplit("1 0 0 1 ").next()).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap()
};
assert!((x(&saved) - x(&m) - 22.0).abs() < 0.01, "{saved} / {m}");
for other in ["n", "d"] {
let s = ap(&screen, &field(&fields(&screen), other).widgets[0]);
assert!(!s.contains("0.85 g"), "{other}: {s}");
}
}
+6 -6
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::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", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::combo_boxes_shown_on_screen_have_a_drop_down_button"]
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); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), push buttons with none from /MK and /BS with their caption, and a drawn combo box shows a drop-down button on screen only (not in Print as Image); Link borders without /AP and printing are not covered yet"
[[feature]]
id = "view.render-error-isolation"
@@ -4428,8 +4428,8 @@ milestone = "M2"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"]
notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved."
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"]
notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn."
[[feature]]
id = "form.fill-text"
@@ -4440,8 +4440,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.fields"]
tools = ["form_fill", "form_fields"]
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"]
notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced."
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/forms/src/tests.rs::comb_fields_show_their_cells"]
notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)."
[[feature]]
id = "form.fill-checkbox-radio"