mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 18:56:25 +00:00
M6: a field whose normal appearance gives no stream is drawn from its value, /MK and /BS (view.render-annotation-appearances)
A widget's /AP /N that is neither a stream nor a state subdictionary with a stream for /AS draws nothing (ISO 32000-2 12.5.5, Table 170), yet the display copy treated any /N as the file's own appearance. Text, choice and push-button widgets whose /N is null, a form XObject dictionary without its stream data, or a subdictionary without /AS now get the drawn appearance a widget without /N gets, and check boxes and radio buttons whose state entry is not a stream are drawn from /MK. The test is the same one the renderer uses to pick the stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
adbf1dc01f
commit
ccc2cd0dee
@@ -10,12 +10,12 @@ pub fn need_appearances(doc: &Document) -> bool {
|
||||
/// 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 with `/NeedAppearances` every one that has an `/MK` is, over its own
|
||||
/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is
|
||||
/// not changed: saving writes the file as it was.
|
||||
/// writer left the appearances to the viewer), and otherwise each widget whose normal appearance
|
||||
/// (`/AP /N`) gives no stream to draw, as typing the value would draw it (#3470). Check boxes and
|
||||
/// radio buttons with no normal appearance stream for their state (`/AS`; an Off state may have
|
||||
/// none) are drawn from their `/MK` background, border and caption, and with `/NeedAppearances`
|
||||
/// every one that has an `/MK` is, over its own appearance; push buttons with none are drawn from
|
||||
/// their `/MK` and `/BS` (Table 192). `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())
|
||||
}
|
||||
@@ -48,13 +48,14 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
|
||||
// drawn from it (Table 192), its own appearance or not; one without /MK keeps its own.
|
||||
let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some());
|
||||
let missing = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
|
||||
FieldKind::PushButton => normal.is_none(),
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => all || !shows_stream(doc, normal.as_deref(), w.state.as_deref()),
|
||||
FieldKind::PushButton => !shows_stream(doc, normal.as_deref(), w.state.as_deref()),
|
||||
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
|
||||
_ if all && described() => true,
|
||||
(None, _) => true,
|
||||
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
|
||||
_ => false,
|
||||
(Some(Object::Stream(_)), _) => false,
|
||||
// An Off state may go without an appearance; an on state needs a stream.
|
||||
(Some(Object::Dict(_)), Some("Off")) => false,
|
||||
(n, s) => !shows_stream(doc, n, s),
|
||||
},
|
||||
FieldKind::Signature => false,
|
||||
};
|
||||
@@ -101,6 +102,19 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
|
||||
copy
|
||||
}
|
||||
|
||||
/// Whether a widget's normal appearance `normal` (`/AP /N`, resolved) gives a stream to draw: the
|
||||
/// entry itself when it is a stream, or the stream it names for the appearance state `state`
|
||||
/// (`/AS`) when it is a subdictionary of states (ISO 32000-2 12.5.5, Table 170), as the renderer
|
||||
/// picks it. Anything else (no entry, null, a subdictionary without `/AS` or without a stream for
|
||||
/// that state, a dictionary that only looks like a form XObject) draws nothing.
|
||||
fn shows_stream(doc: &Document, normal: Option<&Object>, state: Option<&str>) -> bool {
|
||||
match (normal, state) {
|
||||
(Some(Object::Stream(_)), _) => true,
|
||||
(Some(Object::Dict(states)), Some(s)) => states.get(&name_bytes(s)).is_some_and(|a| matches!(doc.resolve(a).as_ref(), Object::Stream(_))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a widget's drawn appearance is built from, as the document holds them: the widget
|
||||
/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the
|
||||
/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to
|
||||
|
||||
@@ -3395,6 +3395,55 @@ fn without_need_appearances_only_missing_appearances_are_drawn() {
|
||||
assert!(with_field_appearances(©).is_none(), "every widget has one now");
|
||||
}
|
||||
|
||||
/// ISO 32000-2 12.5.5 and Table 170: a normal appearance is a stream or a subdictionary of
|
||||
/// streams chosen by `/AS`. A form XObject's dictionary without its stream (`x`: object 6 says
|
||||
/// it is one, but 7.5.7 keeps streams out of object streams, so a writer that packed it there
|
||||
/// lost the data), a subdictionary with no `/AS` (`s`), or a state whose entry is no stream
|
||||
/// (`u`, the push button `p`, the check box `k`) draws nothing, so these are drawn like widgets
|
||||
/// without one: `x` with its `/MK /BC` border at its `/BS /W` of 3, given by reference. A
|
||||
/// subdictionary whose `/AS` names a stream (`t`) is the file's own.
|
||||
fn appearances_without_a_stream() -> Document {
|
||||
let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>");
|
||||
let tx = "/FT /Tx /V (v) /DA (/Helv 12 Tf 0 g)";
|
||||
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 [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
|
||||
"<< /Fields [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 106 14] >>".into(), // 6
|
||||
"<< /W 3 >>".into(),
|
||||
"<< /Length 18 >>\nstream\nBT (old T) Tj ET\nendstream".into(), // 8
|
||||
widget("x", &format!("{tx} /Rect [54 705 160 719] /MK << /BC [0 0 0] /BG [0.88 0.88 0.88] >> /BS 7 0 R /AP << /N 6 0 R >>")),
|
||||
widget("s", &format!("{tx} /Rect [54 680 160 694] /AP << /N << /On 8 0 R >> >>")), // 10
|
||||
widget("t", &format!("{tx} /Rect [54 655 160 669] /AS /On /AP << /N << /On 8 0 R >> >>")),
|
||||
widget("u", &format!("{tx} /Rect [54 630 160 644] /AS /On /AP << /N << /On 6 0 R >> >>")),
|
||||
widget("p", "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Go) /BC [0 0 0] >> /Rect [54 600 160 620] /AP << /N 6 0 R >>"),
|
||||
widget(
|
||||
"k",
|
||||
"/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [200 700 224 724] /AP << /N << /Yes 6 0 R /Off 8 0 R >> >>",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_appearances_that_give_no_stream_are_drawn() {
|
||||
let doc = appearances_without_a_stream();
|
||||
let copy = with_field_appearances(&doc).expect("x, s, u, p and k draw nothing of their own");
|
||||
let all = fields(©);
|
||||
let x = ap(©, &field(&all, "x").widgets[0]);
|
||||
assert!(x.contains("0.88 0.88 0.88 rg\n0 0 106 14 re f") && x.contains("0 0 0 RG\n3 w\n1.5 1.5 103 11 re S"), "{x}");
|
||||
assert!(x.contains("(v) Tj"), "{x}");
|
||||
for name in ["s", "u"] {
|
||||
assert!(ap(©, &field(&all, name).widgets[0]).contains("(v) Tj"), "{name}");
|
||||
}
|
||||
assert!(ap(©, &field(&all, "p").widgets[0]).contains("(Go) Tj"));
|
||||
assert!(state_ap(©, &field(&all, "k").widgets[0], "Yes").contains("(4) Tj"));
|
||||
let t = |d: &Document| d.get(field(&fields(d), "t").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned());
|
||||
assert_eq!(t(©), t(&doc), "a state with a stream is the file's own");
|
||||
assert!(with_field_appearances(©).is_none(), "every widget has a stream now");
|
||||
}
|
||||
|
||||
/// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in
|
||||
/// one field of a form that leaves every appearance to the viewer otherwise redrew all of them.
|
||||
#[test]
|
||||
|
||||
@@ -818,8 +818,8 @@ milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.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::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "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"]
|
||||
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); 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), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
|
||||
evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.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::normal_appearances_that_give_no_stream_are_drawn", "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::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "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"]
|
||||
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's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #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), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-error-isolation"
|
||||
|
||||
Reference in New Issue
Block a user