diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f6d6add12..6b14dfaab 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1265,7 +1265,11 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { } } let ap = doc.add(Object::Stream(stream)); - let mut apd = Dict::new(); + // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover + // appearances would show the previous look on press or hover, so they go with the old /N. + let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + apd.remove(b"D"); + apd.remove(b"R"); apd.set(b"N".to_vec(), Object::Ref(ap)); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index de75b3232..5990e0d03 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -60,7 +60,7 @@ fn list(doc: &Document, page: usize) -> Vec { } 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() @@ -852,3 +852,70 @@ fn every_line_ending_is_drawn() { assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected"); assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced"); } + +#[test] +fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { + 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, 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")); + // The restyled annotation keeps unknown entries, but not down/rollover looks in its + // old colour. + assert_eq!(first_ap.len(), 2); + assert_eq!(other_ap.len(), 4); + for (key, bytes) in alternates { + assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key)); + let object = doc.resolve(other_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")); + } + } +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index edf5294c5..53af944aa 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -574,7 +574,8 @@ 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 existing = crate::merged_appearance(doc, w.obj, &ap); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?; } Ok(()) } @@ -616,8 +617,8 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream { let (width, height) = (place.layout_w, place.layout_h); 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) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 041c24808..0fadb2360 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -952,6 +952,49 @@ 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() +} + +/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly +/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover +/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so +/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), +/// the states the new `/N` still draws under the same names. +pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { + let mut apd = appearance_dict(doc, widget); + // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). + let states_of = |o: &Object| match &*doc.resolve(o) { + Object::Dict(d) => Some(d.clone()), + _ => None, + }; + let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); + for key in [&b"D"[..], &b"R"[..]] { + if fresh.contains(key) { + continue; + } + let kept = states.as_ref().and_then(|names| { + let old = apd.get(key).and_then(states_of)?; + let mut d = Dict::new(); + for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { + d.set(k.clone(), v.clone()); + } + (!d.is_empty()).then_some(d) + }); + match kept { + Some(d) => apd.set(key.to_vec(), Object::Dict(d)), + None => { + apd.remove(key); + } + } + } + for (k, v) in fresh.iter() { + apd.set(k.clone(), v.clone()); + } + apd +} + /// 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 { @@ -964,8 +1007,9 @@ 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(); - apd.set(b"N".to_vec(), Object::Ref(ap)); + let mut fresh = Dict::new(); + fresh.set(b"N".to_vec(), Object::Ref(ap)); + let apd = merged_appearance(doc, w.obj, &fresh); doc.update_dict(w.obj, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); d.remove(b"AS"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index af1a8529f..94255eab4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1062,3 +1062,209 @@ fn a_rotation_refused_as_too_small_changes_nothing_else() { assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused"); assert_eq!(f.widgets[0].rotation, 0); } + +#[test] +fn filling_and_authoring_keep_shared_appearances_and_drop_stale_alternates() { + 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); + // The redrawn widget keeps unknown entries, but not down/rollover looks that + // would show its old value or caption on press or hover. + assert_eq!( + first_ap.len(), + 2, + "{kind:?} fill={fill} indirect={indirect} full={full}: {:?}", + first_ap.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + ); + for (key, bytes) in alternates { + assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key)); + let object = doc.resolve(other_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); + } + } + } + } +} + +/// A check box's down appearances survive a redraw for the states it still draws (same names), +/// and a down appearance for a state it no longer has is dropped rather than left stale. +#[test] +fn check_box_redraw_keeps_down_states_it_still_draws() { + let mut doc = one_page(); + let name = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("agree")).unwrap(); + let widget = field(&fields(&doc), &name).widgets[0].obj; + let on = field(&fields(&doc), &name).widgets[0].on_state.clone().unwrap_or_else(|| "Yes".into()); + let mut ap = doc.get(widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let mut down = Dict::new(); + for state in [on.as_bytes(), b"Off".as_slice(), b"Gone".as_slice()] { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 20.into()])); + let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"0 0 20 20 re f\n".to_vec()))); + down.set(state.to_vec(), Object::Ref(r)); + } + ap.set(b"D".to_vec(), Object::Dict(down)); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap))).unwrap(); + set_props(&mut doc, &name, &FieldProps { tooltip: Some("Tick to agree".into()), ..FieldProps::default() }).unwrap(); + let ap = doc.get(widget).as_dict().unwrap().get(b"AP").map(|a| doc.resolve(a)).unwrap(); + let d = ap.as_dict().unwrap().get(b"D").map(|d| doc.resolve(d)).expect("down states kept"); + let d = d.as_dict().unwrap(); + assert!(d.contains(on.as_bytes()) && d.contains(b"Off")); + assert!(!d.contains(b"Gone"), "a state the box no longer draws"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 479c58b7e..222df48ad 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -92,9 +92,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)); @@ -103,7 +119,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 });