Preserve alternate appearances and indirect push-button values

Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
sanyamsingh001andCodex committed 2026-10-09 13:31:49 +10:00
1 parent dc356f0dfc
commit 0668d3e3d5
6 files changed
+274 -8

No files matched your search

+1 -1
View File
@@ -1085,7 +1085,7 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
apd.set(b"N".to_vec(), Object::Ref(ap));
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
+66 -1
View File
@@ -60,7 +60,7 @@ fn list(doc: &Document, page: usize) -> Vec<Dict> {
}
fn ap_content(doc: &Document, d: &Dict) -> String {
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("has /AP /N");
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).expect("has /AP /N");
let obj = doc.get(n);
let Object::Stream(s) = &*obj else { panic!("AP is not a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
@@ -735,3 +735,68 @@ fn comments_without_appearances_get_one_for_display_only() {
]);
assert!(with_missing_appearances(&complete).is_none());
}
#[test]
fn redraw_preserves_alternate_and_shared_annotation_appearance_entries() {
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
for indirect in [false, true] {
let mut doc = fixture();
let first = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("first")).unwrap();
let other = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [120.0, 10.0, 220.0, 60.0] }), &meta("other")).unwrap();
let (_, first_ref) = annot_ref(&mut doc, 0, first).unwrap();
let (_, other_ref) = annot_ref(&mut doc, 0, other).unwrap();
let mut entries = annot_dict(&doc, first_ref).get(b"AP").unwrap().as_dict().unwrap().clone();
for (key, bytes) in alternates {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 20.0, 10.0]));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
entries.set(key.to_vec(), Object::Ref(r));
}
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
for r in [first_ref, other_ref] {
doc.update_dict(r, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
}
let mut doc = reopen(&doc);
let other_dict = list(&doc, 0)[other].clone();
let source = other_dict.get(b"AP").unwrap();
let before = doc.resolve(source);
set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap();
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
for full in [false, true] {
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let all = list(&doc, 0);
let first_ap = doc.resolve(all[first].get(b"AP").unwrap());
let other_ap = doc.resolve(all[other].get(b"AP").unwrap());
let first_ap = first_ap.as_dict().unwrap();
let other_ap = other_ap.as_dict().unwrap();
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"));
assert_eq!(first_ap.len(), 4);
assert_eq!(other_ap.len(), 4);
for (key, bytes) in alternates {
assert_eq!(first_ap.get(key), other_ap.get(key));
let object = doc.resolve(first_ap.get(key).unwrap());
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
assert_eq!(stream.decoded().unwrap(), bytes);
}
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert!(ap_content(&doc, &all[first]).contains("1 0 0 RG"));
assert!(ap_content(&doc, &all[other]).contains("0 0.4 1 RG"));
}
}
}
+7 -3
View File
@@ -569,7 +569,11 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
d
}
};
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
let mut existing = crate::appearance_dict(doc, w.obj);
for (key, value) in ap.iter() {
existing.set(key.clone(), value.clone());
}
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?;
}
Ok(())
}
@@ -608,8 +612,8 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
let (mut c, width, height) = frame_only(doc, w);
let wobj = doc.get(w.obj);
let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default();
let icon_only = mk.int(b"TP") == Some(1);
let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default();
let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1);
let mut content = std::mem::take(&mut c).into_bytes();
let mut xobjects = Dict::new();
if let Some(icon) = mk.get(b"I").and_then(Object::as_ref)
+6 -1
View File
@@ -880,6 +880,11 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For
Ok(())
}
/// Copy an existing appearance dictionary before replacing its normal appearance.
fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict {
doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default()
}
/// Regenerate the normal appearance of every widget of a text or choice field.
fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> {
let shown = match values {
@@ -892,7 +897,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
None => appearance::field_appearance(doc, f, w, values),
};
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
let mut apd = appearance_dict(doc, w.obj);
apd.set(b"N".to_vec(), Object::Ref(ap));
doc.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
+172
View File
@@ -827,3 +827,175 @@ fn page_shapes_reads_boxes_and_rules() {
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
}
#[test]
fn filling_and_authoring_preserve_alternate_and_shared_appearance_entries() {
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
let cases = [
(NewField::Text { multiline: false }, true),
(NewField::Text { multiline: false }, false),
(NewField::CheckBox, false),
(NewField::Button { caption: "Button".into() }, false),
(NewField::Signature, false),
];
for (kind, fill) in cases {
for indirect in [false, true] {
let mut doc = one_page();
let first = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 630.0], &kind, Some("First")).unwrap();
let other = add_field(&mut doc, 0, [50.0, 550.0, 250.0, 580.0], &kind, Some("Other")).unwrap();
let first_widget = field(&fields(&doc), &first).widgets[0].obj;
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
let mut entries = doc.get(first_widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
for (key, bytes) in alternates {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 10.into()]));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
entries.set(key.to_vec(), Object::Ref(r));
}
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
for widget in [first_widget, other_widget] {
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
}
let original = reopen(&doc);
for full in [false, true] {
let mut doc = original.clone();
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
let source = doc.get(other_widget).as_dict().unwrap().get(b"AP").unwrap().clone();
let before = doc.resolve(&source);
if fill {
set_value(&mut doc, &first, &FieldValue::Text("Updated value".into())).unwrap();
} else {
set_props(&mut doc, &first, &FieldProps { tooltip: Some("Updated help".into()), ..FieldProps::default() }).unwrap();
}
assert_eq!(doc.resolve(&source), before, "a shared AP must not change");
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let all = fields(&doc);
let first = field(&all, &first);
let other = field(&all, &other);
let first_ap = doc.resolve(doc.get(first.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
let other_ap = doc.resolve(doc.get(other.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
let first_ap = first_ap.as_dict().unwrap();
let other_ap = other_ap.as_dict().unwrap();
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"), "normal appearance was regenerated");
assert_eq!(other_ap.len(), 4);
assert_eq!(first_ap.len(), 4);
for (key, bytes) in alternates {
assert_eq!(first_ap.get(key), other_ap.get(key));
let object = doc.resolve(first_ap.get(key).unwrap());
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
assert_eq!(stream.decoded().unwrap(), bytes);
}
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
if fill {
assert_eq!(first.value, ["Updated value"]);
assert!(ap(&doc, &first.widgets[0]).contains("(Updated value) Tj"));
assert!(other.value.is_empty());
} else {
assert_eq!(first.tooltip.as_deref(), Some("Updated help"));
assert_eq!(other.tooltip, None);
}
}
}
}
}
#[test]
fn pushbutton_redraw_resolves_caption_and_layout_after_save_and_reopen() {
fn saved(doc: &Document, full: bool) -> Document {
let bytes = if full {
pdfcraft_cos::write_full(doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
Document::open(Arc::new(bytes)).unwrap()
}
for indirect_caption in [false, true] {
for indirect_layout in [false, true] {
for icon_only in [false, true] {
let mut doc = one_page();
let name =
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 650.0], &NewField::Button { caption: "Original caption".into() }, Some("Picture"))
.unwrap();
let widget = field(&fields(&doc), &name).widgets[0].obj;
let caption = Object::String(pdfcraft_cos::PdfString::literal(b"Original caption".to_vec()));
let caption = if indirect_caption { Object::Ref(doc.add(caption)) } else { caption };
let layout = Object::Int(i64::from(icon_only));
let layout = if indirect_layout { Object::Ref(doc.add(layout)) } else { layout };
let mut icon_dict = Dict::new();
icon_dict.set(b"Type".to_vec(), Object::name("XObject"));
icon_dict.set(b"Subtype".to_vec(), Object::name("Form"));
icon_dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 4.into(), 2.into()]));
let icon_bytes = b"0 0 4 2 re f\n";
let icon = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(icon_dict, icon_bytes.to_vec())));
let mut mk = Dict::new();
mk.set(b"CA".to_vec(), caption);
mk.set(b"TP".to_vec(), layout);
mk.set(b"I".to_vec(), Object::Ref(icon));
mk.set(b"VendorNote".to_vec(), Object::name("Retained"));
let mk = doc.add(Object::Dict(mk));
doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk))).unwrap();
// Import the operands from a real saved file before an unrelated public edit.
let original = saved(&doc, true);
for full in [false, true] {
let mut doc = original.clone();
let widget = field(&fields(&doc), &name).widgets[0].obj;
let mk_before = doc.get(widget).as_dict().unwrap().get(b"MK").unwrap().clone();
let original_mk = doc.resolve(&mk_before);
set_props(&mut doc, &name, &FieldProps { tooltip: Some("Picture help".into()), ..FieldProps::default() }).unwrap();
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"MK"), Some(&mk_before));
assert_eq!(doc.resolve(&mk_before), original_mk, "redraw must not rewrite the source MK");
let doc = saved(&doc, full);
let f = field(&fields(&doc), &name).clone();
assert_eq!(f.tooltip.as_deref(), Some("Picture help"));
let w = &f.widgets[0];
let wd = doc.get(w.obj);
let mk_object = doc.resolve(wd.as_dict().unwrap().get(b"MK").unwrap());
let mk = mk_object.as_dict().unwrap();
assert_eq!(mk.get(b"CA").unwrap().as_ref().is_some(), indirect_caption);
assert_eq!(doc.resolve(mk.get(b"CA").unwrap()).as_string().unwrap().bytes, b"Original caption");
assert_eq!(mk.get(b"TP").unwrap().as_ref().is_some(), indirect_layout);
assert_eq!(doc.resolve(mk.get(b"TP").unwrap()).as_int(), Some(i64::from(icon_only)));
assert_eq!(mk.name(b"VendorNote"), Some(&b"Retained"[..]));
let appearance = ap(&doc, w);
assert_eq!(appearance.contains("(Original caption) Tj"), !icon_only, "{appearance}");
assert_eq!(appearance.matches("/Icon Do").count(), 1, "{appearance}");
assert!(appearance.contains("24.000000 0 0 24.000000 52.000 1.000 cm"), "{appearance}");
let n = wd.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let appearance_object = doc.get(n);
let Object::Stream(stream) = &*appearance_object else { panic!("appearance") };
let resource_icon =
stream.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().get(b"Icon").unwrap();
assert_eq!(resource_icon, mk.get(b"I").unwrap());
let icon_object = doc.resolve(resource_icon);
let Object::Stream(icon) = &*icon_object else { panic!("icon") };
assert_eq!(icon.decoded().unwrap(), icon_bytes);
}
}
}
}
}
+22 -2
View File
@@ -90,9 +90,25 @@ fn middle_button_scrolling_keeps_page_colours_in_both_themes() {
let mut h = harness(&[("zoom", "50")]);
h.state_mut().set_option("theme", theme).unwrap();
h.state_mut().set_option("organize", if organize { "on" } else { "off" }).unwrap();
for _ in 0..100 {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
assert!(
std::time::Instant::now() < deadline,
"render readiness timed out: theme={theme}, organize={organize}, errors={:?}",
h.state().views[0].page_errors()
);
h.run_steps(2);
if !h.state().render_pending() {
// Paint one fresh frame after all requested rasters have arrived.
h.run_steps(1);
if h.state().render_pending() {
continue;
}
assert!(
h.state().views[0].page_errors().is_empty(),
"page render errors: theme={theme}, organize={organize}, errors={:?}",
h.state().views[0].page_errors()
);
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
@@ -101,7 +117,11 @@ fn middle_button_scrolling_keeps_page_colours_in_both_themes() {
let at = page.center();
let ppp = h.ctx.pixels_per_point();
let before = *h.render().unwrap().get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32);
assert_eq!(before, image::Rgba([255, 255, 255, 255]), "the synthetic page is white");
assert_eq!(
before,
image::Rgba([255, 255, 255, 255]),
"the synthetic page is white: theme={theme}, organize={organize}, page={page:?}, ppp={ppp}"
);
let anchor = h.state().views[0].viewport_rect().center();
h.event(egui::Event::PointerMoved(anchor));
h.event(egui::Event::PointerButton { pos: anchor, button: egui::PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE });