mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Preserve alternate appearances and indirect push-button values on redraw (#417)
* D5: README for cos and engine, and an honest cos status line Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets. Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes. Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true. Co-Authored-By: Claude Opus 5 <[email protected]> * 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 <[email protected]> * M14: let neutral Windows locales fall through to display language Co-authored-by: Codex <[email protected]> * pdfcraft-cli --help lists `mcp` only when the build has it Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed `pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command". USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead of calling it an unknown command. - tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and mcp_without_the_feature_says_it_was_left_out (without the feature) Co-Authored-By: Claude Opus 5.5 <[email protected]> * engine README: the execution plan is local-only, so don't link a path that doesn't exist --------- Co-authored-by: Tanish Manku <[email protected]> Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: sanyamsingh001 <[email protected]> Co-authored-by: Codex <[email protected]> Co-authored-by: Streetblock <[email protected]> Co-authored-by: lloyd <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
6 files changed
+351
-9
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+46
-2
@@ -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<Vec<Vec<u8>>> = 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");
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user