mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
Merge pull request #5236 from FredApps/integration/kenm76-2026-10-29
Integrate 13 KenM76 fixes for forms, text and rendering
This commit is contained in:
98 files changed
+16310
-188
No files matched your search
Generated
+1
-2
@@ -4338,6 +4338,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
@@ -7350,8 +7351,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo 0.1.1",
|
||||
|
||||
@@ -128,6 +128,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
vello_cpu = { path = "vendor/vello_cpu" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
|
||||
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
// Where each id's last glyph ended, in user space.
|
||||
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
|
||||
let mut gs = TextState::default();
|
||||
let mut saved: Vec<TextState> = Vec::new();
|
||||
// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
// errata, issue 368), as the renderer does.
|
||||
let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new();
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
@@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)),
|
||||
b"q" => unsaved += 1,
|
||||
b"Q" if unsaved > 0 => unsaved -= 1,
|
||||
b"Q" => {
|
||||
if let Some(saved) = saved.pop() {
|
||||
gs = saved;
|
||||
(gs, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as
|
||||
/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose
|
||||
/// glyphs continue after a `Q` stays one word.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
let doc = heading_page(&[
|
||||
"BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(),
|
||||
"BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(),
|
||||
]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
|
||||
@@ -2640,6 +2640,29 @@ fn orphan_form_fields_through_tools() {
|
||||
assert_ne!(v, "Filled by an agent");
|
||||
}
|
||||
|
||||
/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat
|
||||
/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text.
|
||||
#[test]
|
||||
fn auto_sized_text_follows_a_tall_field() {
|
||||
let objs: Vec<&str> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6
|
||||
];
|
||||
let dir = workdir("auto-size-tall");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } }));
|
||||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" }));
|
||||
let r = &found["matches"][0]["rects"][0];
|
||||
let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap();
|
||||
assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_pages_through_tools() {
|
||||
let dir = workdir("reverse");
|
||||
@@ -4102,6 +4125,29 @@ fn preparing_a_form_through_tools() {
|
||||
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
|
||||
// #4949: two buttons placed separately are two groups; naming one after the other makes
|
||||
// them one group, and a clashing export value is renamed instead of shared.
|
||||
let dir = workdir("radio-join");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
|
||||
let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
|
||||
assert_eq!(r["field"], "size");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let f = fields["fields"].as_array().unwrap();
|
||||
assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["size"], "one group");
|
||||
assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
|
||||
assert_eq!(f[0]["value"], "S", "the group keeps its selection");
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_field_through_tools() {
|
||||
let dir = workdir("rotate-field");
|
||||
@@ -9360,3 +9406,54 @@ fn deleting_one_radio_button_through_tools_keeps_the_group() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
|
||||
}
|
||||
/// `/I` of the top-level field `name` in the saved file `path`.
|
||||
fn saved_selection_indices(path: &Path, name: &str) -> Option<Vec<i64>> {
|
||||
let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
let f = all
|
||||
.iter()
|
||||
.filter_map(|o| d.resolve(o).as_dict().cloned())
|
||||
.find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name))
|
||||
.unwrap();
|
||||
f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() {
|
||||
let dir = workdir("choice-index");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" }));
|
||||
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
|
||||
}
|
||||
|
||||
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
|
||||
/// even when its parent field has one it would otherwise inherit.
|
||||
#[test]
|
||||
fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
|
||||
"<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let dir = workdir("kid-default");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } }));
|
||||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
|
||||
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
|
||||
}
|
||||
@@ -8,3 +8,5 @@ mod button_caption;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod optimize_content_boundaries;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes
|
||||
//! come from the renderer's text and redaction measures the content itself, so the two must agree
|
||||
//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \
|
||||
BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q";
|
||||
const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q";
|
||||
|
||||
fn pdf() -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET"), "{after:?}");
|
||||
assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
|
||||
//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
|
||||
//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
|
||||
//! renderer's text, which places it there; redaction measures the content itself, and while it
|
||||
//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
|
||||
const CASES: [(&str, &str, &str); 3] = [
|
||||
// A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
|
||||
// the B and the B goes with it.
|
||||
("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
|
||||
// A move inside `q`/`Q` is undone.
|
||||
("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
|
||||
// The text line matrix is restored too: `T*` starts the next line from the line before `q`.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
|
||||
];
|
||||
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-2.0\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
|
||||
for (case, (content, kept, gone)) in CASES.iter().enumerate() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
|
||||
assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
|
||||
@@ -562,7 +562,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, Creat
|
||||
} else if is_tiff(bytes) {
|
||||
tiff_pages(name, bytes, wanted)
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
|
||||
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
|
||||
} else if is_gif(bytes) {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
|
||||
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
|
||||
|
||||
@@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() {
|
||||
assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..))));
|
||||
}
|
||||
|
||||
/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as
|
||||
/// the intact file, not shifted by the header bytes.
|
||||
#[test]
|
||||
fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() {
|
||||
use image::ImageEncoder;
|
||||
let mut bmp = Vec::new();
|
||||
let pixels: Vec<u8> = (0..18).map(|i| i * 13).collect();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let mut zero = bmp.clone();
|
||||
zero[10..14].copy_from_slice(&0u32.to_le_bytes());
|
||||
let samples = |data: Vec<u8>| {
|
||||
let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap());
|
||||
let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") };
|
||||
s.decoded().unwrap()
|
||||
};
|
||||
assert_eq!(samples(zero), samples(bmp));
|
||||
}
|
||||
|
||||
/// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page
|
||||
/// (3.6 GB of gray pixels) must still be refused, not allocated.
|
||||
#[test]
|
||||
|
||||
@@ -7536,3 +7536,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2
|
||||
/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the
|
||||
/// text after `Q` is found, and stays, where it is drawn.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
for (content, text, baseline) in [
|
||||
("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
// `T*` moves from the restored line, not the one set inside q/Q.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]),
|
||||
] {
|
||||
let mut doc = text_page(content);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}");
|
||||
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing");
|
||||
}
|
||||
}
|
||||
@@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) {
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
pub(super) struct Carry {
|
||||
pub(super) ts: Ts,
|
||||
pub(super) stack: Vec<Ts>,
|
||||
/// Each saved state with the text and text line matrices: inside a text object `q`/`Q`
|
||||
/// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
/// errata, issue 368), as the renderer does.
|
||||
pub(super) stack: Vec<(Ts, Matrix, Matrix)>,
|
||||
pub(super) marked: Vec<Option<usize>>,
|
||||
/// For each open marked-content sequence, its optional content group when it is `/OC`.
|
||||
pub(super) layers: Vec<Option<Vec<u8>>>,
|
||||
@@ -761,7 +764,7 @@ pub(super) fn interpret(
|
||||
let mut out: Vec<Shown> = Vec::new();
|
||||
let mut ts = carry.ts.clone();
|
||||
let mut at_bt = (0usize, ts.clone());
|
||||
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
|
||||
let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack);
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
let mut bt = 0usize;
|
||||
let mut marked =
|
||||
@@ -776,10 +779,10 @@ pub(super) fn interpret(
|
||||
b"EMC" => {
|
||||
marked.end(i, &mut out);
|
||||
}
|
||||
b"q" => stack.push(ts.clone()),
|
||||
b"q" => stack.push((ts.clone(), tm, tlm)),
|
||||
b"Q" => {
|
||||
if let Some(s) = stack.pop() {
|
||||
ts = s;
|
||||
if let Some(saved) = stack.pop() {
|
||||
(ts, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -89,7 +89,7 @@ pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
|
||||
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
|
||||
calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
|
||||
@@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box
|
||||
/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt.
|
||||
/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose
|
||||
/// wrapped lines fit the height. The page appearance and the on-screen editor share this.
|
||||
/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text
|
||||
/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is
|
||||
/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline
|
||||
/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page
|
||||
/// appearance and the on-screen editor share this.
|
||||
pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 {
|
||||
let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 };
|
||||
auto_text_size_by(
|
||||
@@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt
|
||||
)
|
||||
}
|
||||
|
||||
/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside
|
||||
/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance
|
||||
/// and the on-screen editor share this.
|
||||
pub fn comb_auto_text_size(height: f64) -> f64 {
|
||||
if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 }
|
||||
}
|
||||
|
||||
/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side
|
||||
/// ISO 32000-1 Annex C recommends.
|
||||
pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0;
|
||||
|
||||
/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on
|
||||
/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its
|
||||
/// own font, whose widths and line breaks differ from the page's.
|
||||
@@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let size = (height / 1.15).clamp(4.0, 12.0);
|
||||
let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0);
|
||||
let tw = line_width(size);
|
||||
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
|
||||
}
|
||||
@@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox {
|
||||
size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size),
|
||||
multiline,
|
||||
password: f.has(flags::PASSWORD),
|
||||
comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0),
|
||||
comb: f.is_comb(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0);
|
||||
let comb = f.is_comb();
|
||||
if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
|
||||
if size == 0.0 {
|
||||
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
|
||||
@@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
} else {
|
||||
if size == 0.0 {
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
size = if comb {
|
||||
((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0)
|
||||
comb_auto_text_size(height - 2.0 * pad)
|
||||
} else {
|
||||
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
|
||||
};
|
||||
|
||||
@@ -11,10 +11,13 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
|
||||
|
||||
mod edit;
|
||||
mod join;
|
||||
mod lifecycle;
|
||||
mod look;
|
||||
mod tree;
|
||||
|
||||
pub(crate) use tree::ancestor_sets;
|
||||
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
|
||||
+143
-22
@@ -1,5 +1,6 @@
|
||||
use super::join::{check_join, join_radio_group};
|
||||
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
|
||||
use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid};
|
||||
|
||||
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
|
||||
@@ -14,6 +15,68 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
|
||||
}
|
||||
|
||||
/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was
|
||||
/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept`
|
||||
/// are the old `/I` positions carried into the new list, which tell apart options sharing an
|
||||
/// export value; a selected value none of them covers takes its first position, by `set_value`'s
|
||||
/// rule. Returns them.
|
||||
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect();
|
||||
for v in values {
|
||||
if !indices.iter().any(|&i| exports.get(i) == Some(v)) {
|
||||
indices.extend(exports.iter().position(|e| e == v));
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
if indices.is_empty() {
|
||||
d.remove(b"I");
|
||||
} else {
|
||||
d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`.
|
||||
fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec<usize> {
|
||||
let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let positions = doc.resolve(&i);
|
||||
positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else
|
||||
/// the field's own, whose selection, `/I` included, moves with them.
|
||||
fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec<usize>) {
|
||||
match &props.options {
|
||||
Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()),
|
||||
None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a choice field's items (export value, display text) by display text, ignoring case, and
|
||||
/// write them as `/Opt`; export values stay with their items. Returns the export values in their
|
||||
/// new order and the `old` positions carried through the sort.
|
||||
fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec<String>, Vec<usize>) {
|
||||
let mut order: Vec<usize> = (0..items.len()).collect();
|
||||
order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase()));
|
||||
let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect();
|
||||
let opt = sorted
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect();
|
||||
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -70,7 +133,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if let Some(acts) = &props.actions {
|
||||
crate::set_field_actions(doc, name, acts)?;
|
||||
}
|
||||
let new_name = rename_field(doc, &all, &f, name, props)?;
|
||||
// A radio button renamed to another radio group's name joins that group (#4949). The other
|
||||
// properties are written first, to this field, and its buttons then move.
|
||||
let (new_name, join) = rename_field(doc, &all, &f, name, props)?;
|
||||
// The new default appearance when the size or the look changes.
|
||||
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
|
||||
let new_da = match &props.look {
|
||||
@@ -121,6 +186,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
return invalid("only check boxes and radio buttons have a check style");
|
||||
}
|
||||
let mut list_picked = None;
|
||||
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
|
||||
let parent_default = ancestor_sets(doc, f.obj, b"DV");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
|
||||
if let Some(t) = &props.tooltip {
|
||||
@@ -138,27 +206,23 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
|
||||
let mut new_exports: Option<Vec<String>> = None;
|
||||
let mut kept = Vec::new();
|
||||
if let Some(opts) = &props.options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
|
||||
new_exports = Some(opts.clone());
|
||||
}
|
||||
// "Sort items": the list itself is kept in order (export values stay with their items).
|
||||
if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
|
||||
let mut items: Vec<(String, String)> = match &props.options {
|
||||
Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(),
|
||||
None => f.options.clone(),
|
||||
};
|
||||
items.sort_by_key(|(_, display)| display.to_lowercase());
|
||||
let opt = items
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection);
|
||||
new_exports = Some(exports);
|
||||
kept = carried;
|
||||
}
|
||||
if let Some(exports) = &new_exports
|
||||
&& matches!(f.kind, FieldKind::Combo | FieldKind::List)
|
||||
{
|
||||
list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len()));
|
||||
}
|
||||
if let Some(q) = props.quadding {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
@@ -167,6 +231,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
// Buttons name their default state; text and choices hold a string.
|
||||
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
|
||||
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
|
||||
// `/DV` is inheritable: with an ancestor's in place, removing the field's own would
|
||||
// bring that one back, so the field keeps an empty default of its own instead.
|
||||
Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => {
|
||||
d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec()))
|
||||
}
|
||||
Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")),
|
||||
Some(None) => {
|
||||
d.remove(b"DV");
|
||||
}
|
||||
@@ -246,15 +316,20 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
})?;
|
||||
}
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
|
||||
redraw_widgets(doc, &new_name, only)?;
|
||||
scroll_list(doc, f.obj, list_picked)?;
|
||||
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
}
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
/// Apply a new name from `props`: rename the field, move it under the parents a full name
|
||||
/// names, or, for a radio button named after another radio group, pick that group to join.
|
||||
/// Returns the field's new full name and the group it joins.
|
||||
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option<Field>), FormError> {
|
||||
let mut new_name = name.to_string();
|
||||
let mut join = None;
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
// A full name under the same parents only renames the field's own part (the editor
|
||||
// shows nested fields by their full name): it isn't moved, so it keeps its place and
|
||||
@@ -263,7 +338,16 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props:
|
||||
(Some((parents, own)), Some((current, _))) if parents == current => own,
|
||||
_ => n,
|
||||
};
|
||||
if n.contains('.') {
|
||||
let full = match f.name.rsplit_once('.') {
|
||||
Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"),
|
||||
_ => n.to_string(),
|
||||
};
|
||||
let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full);
|
||||
if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) {
|
||||
check_join(doc, f, group)?;
|
||||
join = Some(group.clone());
|
||||
new_name = full;
|
||||
} else if n.contains('.') {
|
||||
// A full name: the field moves under the parent fields it names (#2696).
|
||||
let parts = name_parts(n)?;
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
|
||||
@@ -307,7 +391,7 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props:
|
||||
rename_in_calculations(doc, all, &f.name, &new_name)?;
|
||||
}
|
||||
}
|
||||
Ok(new_name)
|
||||
Ok((new_name, join))
|
||||
}
|
||||
|
||||
fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> {
|
||||
@@ -359,6 +443,43 @@ fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &F
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its
|
||||
/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the
|
||||
/// group's own buttons keep their appearances.
|
||||
fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option<usize>) -> Result<(), FormError> {
|
||||
if let Some(group) = join {
|
||||
redraw_widgets(doc, name, only)?;
|
||||
join_radio_group(doc, f, group)?;
|
||||
let moved: Vec<usize> = fields(doc)
|
||||
.into_iter()
|
||||
.find(|g| g.name == new_name)
|
||||
.map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect())
|
||||
.unwrap_or_default();
|
||||
for index in moved {
|
||||
redraw_widgets(doc, new_name, Some(index))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
redraw_widgets(doc, new_name, only)
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box scrolls, as for a new value, so its first row is still an option and the
|
||||
/// selection is in view. Its rows are measured with the edited font size and rectangle.
|
||||
fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize>, usize)>) -> Result<(), FormError> {
|
||||
if let Some((picked, count)) = list_picked
|
||||
&& let Some(g) = fields(doc).into_iter().find(|g| g.obj == field)
|
||||
{
|
||||
if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
crate::values::scroll_widgets(doc, &g, &picked, count)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_late_widget_props(
|
||||
doc: &mut Document,
|
||||
f: &Field,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Renaming a radio button to an existing radio group's name makes it a button of that group
|
||||
//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2
|
||||
//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};
|
||||
use super::tree::{detach, list_of};
|
||||
use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid};
|
||||
|
||||
/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`
|
||||
/// keeps only the widget's own triggers.
|
||||
const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];
|
||||
|
||||
/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
|
||||
fn has_opt(doc: &Document, field: ObjRef) -> bool {
|
||||
doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))
|
||||
}
|
||||
|
||||
/// Refuse what [`join_radio_group`] can't do, before anything is written.
|
||||
pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> {
|
||||
for x in [f, target] {
|
||||
if has_opt(doc, x.obj) {
|
||||
return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name));
|
||||
}
|
||||
if x.widgets.is_empty() {
|
||||
return invalid(format!("{} has no button", x.name));
|
||||
}
|
||||
}
|
||||
if target.locked() {
|
||||
return invalid(format!("{} is locked: unlock it first", target.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move every button of radio field `f` into radio field `target`. Each keeps its export value
|
||||
/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a
|
||||
/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s
|
||||
/// selected value. `f` leaves the form; its own value and field-level properties go with it,
|
||||
/// and `target`'s stay. Call [`check_join`] first.
|
||||
pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> {
|
||||
let theirs: HashSet<String> = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
|
||||
let mut taken = theirs.clone();
|
||||
taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone()));
|
||||
// The group's value counts only when one of its buttons has it.
|
||||
let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned();
|
||||
// Each clashing value of `f` gets one fresh name, shared by every button that had it. Of
|
||||
// `taken.len() + 1` names at least one is free, so the search always ends in one.
|
||||
let mut renamed: HashMap<String, String> = HashMap::new();
|
||||
for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) {
|
||||
if renamed.contains_key(old) {
|
||||
continue;
|
||||
}
|
||||
let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else {
|
||||
return invalid(format!("no free export value for {old}"));
|
||||
};
|
||||
taken.insert(fresh.clone());
|
||||
renamed.insert(old.to_owned(), fresh);
|
||||
}
|
||||
// A group that is its own one button gets a field with a separate widget first, so it can
|
||||
// hold more than one.
|
||||
if let [only] = target.widgets.as_slice()
|
||||
&& only.obj == target.obj
|
||||
{
|
||||
split_merged(doc, target, only)?;
|
||||
}
|
||||
detach(doc, f.obj)?;
|
||||
// `f` is no longer a field, even when it stays on as a widget.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj)?;
|
||||
let mut kids = Vec::with_capacity(f.widgets.len());
|
||||
for w in &f.widgets {
|
||||
let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
if w.obj == f.obj {
|
||||
for k in FIELD_ONLY_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
|
||||
match aa.as_ref().map(split_triggers) {
|
||||
Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)),
|
||||
Some((None, _)) => {
|
||||
d.remove(b"AA");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let mut state = w.on_state.clone();
|
||||
if let Some(old) = w.on_state.as_deref()
|
||||
&& let Some(fresh) = renamed.get(old)
|
||||
&& rename_state(doc, &mut d, old, fresh)
|
||||
{
|
||||
state = Some(fresh.clone());
|
||||
}
|
||||
let on = state.is_some() && state == selected;
|
||||
let shown = match (&state, on) {
|
||||
(Some(s), true) => Object::Name(crate::name_bytes(s)),
|
||||
_ => Object::name("Off"),
|
||||
};
|
||||
d.set(b"AS".to_vec(), shown);
|
||||
d.set(b"Parent".to_vec(), Object::Ref(target.obj));
|
||||
doc.set(w.obj, Object::Dict(d));
|
||||
kids.push(Object::Ref(w.obj));
|
||||
}
|
||||
// `/Kids` may be an indirect array: read through it so the group's own buttons stay.
|
||||
let mut list = list_of(doc, target.obj, b"Kids");
|
||||
list.extend(kids);
|
||||
doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`,
|
||||
/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other
|
||||
/// users. Whether `/N` had the state: when it didn't, nothing is changed.
|
||||
fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {
|
||||
let (old, new) = (crate::name_bytes(old), crate::name_bytes(new));
|
||||
let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false };
|
||||
let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned());
|
||||
if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) {
|
||||
return false;
|
||||
}
|
||||
for key in [&b"N"[..], b"D", b"R"] {
|
||||
let Some(mut sub) = states(&ap, key) else { continue };
|
||||
if let Some(v) = sub.remove(&old) {
|
||||
sub.set(new.clone(), v);
|
||||
ap.set(key.to_vec(), Object::Dict(sub));
|
||||
}
|
||||
}
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
true
|
||||
}
|
||||
@@ -285,7 +285,7 @@ fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet<ObjRe
|
||||
|
||||
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
|
||||
/// by reference.
|
||||
fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
|
||||
pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
|
||||
let Some(root) = doc.root() else { return Ok(()) };
|
||||
let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
|
||||
let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
|
||||
@@ -307,7 +307,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool
|
||||
}
|
||||
|
||||
/// Widget-only keys: they stay with each widget when a merged field is split.
|
||||
const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];
|
||||
const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"];
|
||||
|
||||
/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`,
|
||||
/// `F`, `V`, `C`, Table 199) belong to the field.
|
||||
const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"];
|
||||
|
||||
/// Split additional-actions dictionary `aa` into the widget's triggers and the field's.
|
||||
/// An empty half is `None`.
|
||||
pub(super) fn split_triggers(aa: &Dict) -> (Option<Dict>, Option<Dict>) {
|
||||
let (mut widget, mut field) = (Dict::new(), Dict::new());
|
||||
for (k, v) in aa.iter() {
|
||||
let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field };
|
||||
to.set(k.clone(), v.clone());
|
||||
}
|
||||
let some = |d: Dict| (!d.is_empty()).then_some(d);
|
||||
(some(widget), some(field))
|
||||
}
|
||||
|
||||
/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page
|
||||
/// gets a widget at the same place, belonging to the same field, so they share one value.
|
||||
@@ -328,40 +344,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
|
||||
return Ok(0);
|
||||
}
|
||||
// A merged field/widget becomes a field with one widget kid.
|
||||
let mut model_ref = model.obj;
|
||||
if model.obj == f.obj {
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut kid = Dict::new();
|
||||
for k in WIDGET_KEYS {
|
||||
if let Some(v) = d.get(k) {
|
||||
kid.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
|
||||
let kr = doc.add(Object::Dict(kid));
|
||||
doc.update_dict(f.obj, |d| {
|
||||
for k in WIDGET_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
|
||||
})?;
|
||||
// The page lists the widget now, not the field.
|
||||
if let Some(p) = model.page {
|
||||
let pr = refs[p];
|
||||
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
|
||||
let swap =
|
||||
|a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
|
||||
match annots {
|
||||
Some(Object::Ref(ar)) => {
|
||||
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
|
||||
doc.set(ar, Object::Array(swap(a)));
|
||||
}
|
||||
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
model_ref = kr;
|
||||
}
|
||||
let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj };
|
||||
let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();
|
||||
let mut added = Vec::new();
|
||||
for p in &targets {
|
||||
@@ -394,3 +377,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
|
||||
redraw_field(doc, name)?;
|
||||
Ok(added.len())
|
||||
}
|
||||
|
||||
/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that
|
||||
/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid.
|
||||
pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result<ObjRef, FormError> {
|
||||
let refs = page_refs(doc);
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut kid = Dict::new();
|
||||
for k in WIDGET_KEYS {
|
||||
if let Some(v) = d.get(k) {
|
||||
kid.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
// The widget's own triggers go with it; the field's stay.
|
||||
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
|
||||
let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default();
|
||||
if let Some(w) = widget_aa {
|
||||
kid.set(b"AA".to_vec(), Object::Dict(w));
|
||||
}
|
||||
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
|
||||
let kr = doc.add(Object::Dict(kid));
|
||||
doc.update_dict(f.obj, |d| {
|
||||
for k in WIDGET_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
if aa.is_some() {
|
||||
match field_aa {
|
||||
Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)),
|
||||
None => {
|
||||
d.remove(b"AA");
|
||||
}
|
||||
}
|
||||
}
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
|
||||
})?;
|
||||
// The page lists the widget now, not the field.
|
||||
if let Some(&pr) = model.page.and_then(|p| refs.get(p)) {
|
||||
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
|
||||
let swap = |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
|
||||
match annots {
|
||||
Some(Object::Ref(ar)) => {
|
||||
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
|
||||
doc.set(ar, Object::Array(swap(a)));
|
||||
}
|
||||
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(kr)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).
|
||||
fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
|
||||
pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
|
||||
let d = doc.get(holder).as_dict().cloned().unwrap_or_default();
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
@@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict {
|
||||
add
|
||||
}
|
||||
|
||||
/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without
|
||||
/// its own entry.
|
||||
pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
while let Some(p) = parent {
|
||||
if seen.len() > 64 || !seen.insert(p) {
|
||||
return false;
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { return false };
|
||||
if d.contains(key) {
|
||||
return true;
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves.
|
||||
pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> {
|
||||
if add.iter().next().is_some() {
|
||||
|
||||
@@ -176,6 +176,12 @@ impl Field {
|
||||
self.flags & flag != 0
|
||||
}
|
||||
|
||||
/// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO
|
||||
/// 32000-2 Table 228). Each cell holds one character.
|
||||
pub fn is_comb(&self) -> bool {
|
||||
self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0)
|
||||
}
|
||||
|
||||
pub fn read_only(&self) -> bool {
|
||||
self.has(flags::READ_ONLY)
|
||||
}
|
||||
|
||||
+526
-4
@@ -1796,7 +1796,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
|
||||
#[test]
|
||||
fn new_text_fields_fit_their_box() {
|
||||
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
|
||||
// Auto size: 12 pt where there is room, smaller where there isn't.
|
||||
// Auto size: the size the box's height gives, smaller where the text is too wide.
|
||||
let mut doc = one_page();
|
||||
let tf_size = |ap: &str| {
|
||||
ap.split("/Helv ")
|
||||
@@ -1815,7 +1815,9 @@ fn new_text_fields_fit_their_box() {
|
||||
}
|
||||
add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap();
|
||||
set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look");
|
||||
// 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15.
|
||||
let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0]));
|
||||
assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}");
|
||||
// An explicit size still wins.
|
||||
set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0);
|
||||
@@ -3225,8 +3227,12 @@ fn auto_text_size_fits_single_and_multiline_text() {
|
||||
assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15);
|
||||
let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width);
|
||||
assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}");
|
||||
// Never past 12 pt or under 4 pt.
|
||||
assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0);
|
||||
// A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there),
|
||||
// bounded by the largest page; never under 4 pt.
|
||||
assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15);
|
||||
assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15);
|
||||
let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width);
|
||||
assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}");
|
||||
assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0);
|
||||
// Multiline: the largest size whose wrapped lines fit the height.
|
||||
let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width);
|
||||
@@ -4834,3 +4840,519 @@ fn deleting_a_widget_that_is_not_there_changes_nothing() {
|
||||
assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX))));
|
||||
assert_eq!(field(&fields(&doc), "size").widgets.len(), 3);
|
||||
}
|
||||
/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`).
|
||||
fn two_radio_groups(a: &str, b: &str) -> Document {
|
||||
let mut doc = one_page();
|
||||
let radio = |export: &str| NewField::Radio { group: None, export: export.into() };
|
||||
assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1");
|
||||
assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2");
|
||||
doc
|
||||
}
|
||||
|
||||
fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result<String, FormError> {
|
||||
set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() })
|
||||
}
|
||||
|
||||
fn page_widgets(doc: &Document) -> Vec<ObjRef> {
|
||||
let page = page_refs(doc)[0];
|
||||
let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap();
|
||||
doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect()
|
||||
}
|
||||
|
||||
/// #4949: a radio button given its group's name is one of the group's buttons, so the group can
|
||||
/// be built up by naming buttons alike, as in Acrobat.
|
||||
#[test]
|
||||
fn a_radio_button_renamed_to_a_groups_name_joins_the_group() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap();
|
||||
let before = page_widgets(&doc);
|
||||
assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1");
|
||||
assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were");
|
||||
let mut doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Group1"]);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
|
||||
assert_eq!(group.value, ["Yes"], "the group keeps its value");
|
||||
assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Yes".into()), Some("Off".into())]);
|
||||
assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::<Vec<_>>());
|
||||
// Choosing the moved button turns the other off: they are one group.
|
||||
set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap();
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Off".into()), Some("No".into())]);
|
||||
}
|
||||
|
||||
/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may
|
||||
/// be custom ones a redraw would replace.
|
||||
#[test]
|
||||
fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
let own = field(&fields(&doc), "Group1").widgets[0].obj;
|
||||
let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned());
|
||||
let before = look(&doc);
|
||||
assert!(before.is_some());
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
assert_eq!(look(&doc), before);
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states");
|
||||
}
|
||||
|
||||
/// A joining button whose export value a button of the group already has takes the next free
|
||||
/// "ChoiceN", in its appearance states too, so the two can be chosen apart.
|
||||
#[test]
|
||||
fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2"]);
|
||||
let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
|
||||
let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap();
|
||||
let normal = doc.resolve(&normal).as_dict().unwrap().clone();
|
||||
assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1"));
|
||||
}
|
||||
|
||||
/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it.
|
||||
#[test]
|
||||
fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
let group = field(&fields(&doc), "Group1").obj;
|
||||
let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap();
|
||||
let held = doc.add(kids);
|
||||
doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap();
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
|
||||
}
|
||||
|
||||
/// Buttons of the joining group that share an export value (a button repeated on another page,
|
||||
/// on together) still share it after the join, under one fresh name.
|
||||
#[test]
|
||||
fn joining_radio_buttons_that_share_a_value_keep_sharing_it() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let mut p = pdfcraft_cos::Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
let r = doc.add(Object::Dict(p));
|
||||
doc.update_dict(pages, |d| {
|
||||
let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();
|
||||
kids.push(Object::Ref(r));
|
||||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1);
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2", "Choice2"]);
|
||||
}
|
||||
|
||||
/// One page holding radio fields that are each their own only widget, as other writers make
|
||||
/// one-button groups: `(name, export value, x)`.
|
||||
fn merged_radios(specs: &[(&str, &str, f64)]) -> Document {
|
||||
let mut doc = one_page();
|
||||
let page = page_refs(&doc)[0];
|
||||
let af = crate::author::ensure_form(&mut doc).unwrap();
|
||||
let mut made = Vec::new();
|
||||
for &(name, state, x) in specs {
|
||||
let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
|
||||
let mut n = pdfcraft_cos::Dict::new();
|
||||
n.set(state.as_bytes().to_vec(), Object::Ref(look));
|
||||
n.set(b"Off".to_vec(), Object::Ref(look));
|
||||
let mut ap = pdfcraft_cos::Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO)));
|
||||
d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()]));
|
||||
d.set(b"P".to_vec(), Object::Ref(page));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
d.set(b"AS".to_vec(), Object::name("Off"));
|
||||
let r = doc.add(Object::Dict(d));
|
||||
doc.update_dict(af, |a| {
|
||||
let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default();
|
||||
list.push(Object::Ref(r));
|
||||
a.set(b"Fields".to_vec(), Object::Array(list));
|
||||
})
|
||||
.unwrap();
|
||||
made.push(r);
|
||||
}
|
||||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// A radio field that is its own (only) widget, as other writers make one-button groups, can
|
||||
/// both join a group and be joined.
|
||||
#[test]
|
||||
fn radio_fields_that_are_their_own_widget_join_and_are_joined() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo");
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["solo"]);
|
||||
let group = field(&all, "solo");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["A", "B"]);
|
||||
// The joined field's widget became a kid; the moved one shed its field entries.
|
||||
let kids: Vec<ObjRef> = group.widgets.iter().map(|w| w.obj).collect();
|
||||
assert!(!kids.contains(&group.obj));
|
||||
let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
|
||||
assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj));
|
||||
let mut on_page = page_widgets(&doc);
|
||||
on_page.sort();
|
||||
let mut kids = kids;
|
||||
kids.sort();
|
||||
assert_eq!(on_page, kids, "the page lists the widgets, not the field");
|
||||
}
|
||||
|
||||
/// A group that is its own only widget keeps its button's actions when another button joins
|
||||
/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own.
|
||||
#[test]
|
||||
fn a_joined_merged_groups_button_keeps_its_actions() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let solo = field(&fields(&doc), "solo").obj;
|
||||
let js = |code: &str| {
|
||||
let mut a = pdfcraft_cos::Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
|
||||
Object::Dict(a)
|
||||
};
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"Fo".to_vec(), js("focus();"));
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
let aa = doc.add(Object::Dict(aa));
|
||||
doc.update_dict(solo, |d| {
|
||||
d.set(b"A".to_vec(), js("up();"));
|
||||
d.set(b"AA".to_vec(), Object::Ref(aa));
|
||||
})
|
||||
.unwrap();
|
||||
rename_to(&mut doc, "other", "solo").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(
|
||||
field_actions(&doc, "solo").unwrap(),
|
||||
[(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))]
|
||||
);
|
||||
let group = field(&fields(&doc), "solo").clone();
|
||||
let fd = doc.get(group.obj).as_dict().unwrap().clone();
|
||||
let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger");
|
||||
let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
|
||||
let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V"));
|
||||
}
|
||||
|
||||
/// A joining button that was its own field keeps its widget triggers and drops the field's.
|
||||
#[test]
|
||||
fn a_joining_merged_button_sheds_its_fields_triggers() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let other = field(&fields(&doc), "other").obj;
|
||||
let js = |code: &str| {
|
||||
let mut a = pdfcraft_cos::Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
|
||||
Object::Dict(a)
|
||||
};
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"U".to_vec(), js("up();"));
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
aa.set(b"C".to_vec(), js("calc();"));
|
||||
let aa = doc.add(Object::Dict(aa));
|
||||
doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap();
|
||||
rename_to(&mut doc, "other", "solo").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let group = field(&fields(&doc), "solo").clone();
|
||||
let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
|
||||
let wd = doc.get(moved.obj).as_dict().unwrap().clone();
|
||||
let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays");
|
||||
let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let other = field(&fields(&once), "other").obj;
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();
|
||||
rename_to(&mut once, "other", "solo").unwrap();
|
||||
let once = reopen(&once);
|
||||
let group = field(&fields(&once), "solo").clone();
|
||||
let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
|
||||
let wd = once.get(moved.obj).as_dict().unwrap().clone();
|
||||
assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA");
|
||||
}
|
||||
|
||||
/// However many "ChoiceN" names a group already uses, a joining button with a clashing export
|
||||
/// value gets a free one, so the two buttons can still be chosen apart.
|
||||
#[test]
|
||||
fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
|
||||
let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
|
||||
let mut kids = vec![Object::Ref(group.widgets[0].obj)];
|
||||
for i in 2..=10_000 {
|
||||
let mut n = pdfcraft_cos::Dict::new();
|
||||
n.set(format!("Choice{i}").into_bytes(), Object::Ref(look));
|
||||
n.set(b"Off".to_vec(), Object::Ref(look));
|
||||
let mut ap = pdfcraft_cos::Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut w = model.clone();
|
||||
w.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
w.remove(b"P");
|
||||
kids.push(Object::Ref(doc.add(Object::Dict(w))));
|
||||
}
|
||||
doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
let states: Vec<String> = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
|
||||
assert_eq!(states.len(), 10_001);
|
||||
assert_eq!(states.last().map(String::as_str), Some("Choice10001"));
|
||||
}
|
||||
|
||||
/// Only a radio button joins by name: other clashes are refused as before, and a locked group
|
||||
/// isn't changed.
|
||||
#[test]
|
||||
fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap();
|
||||
assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group");
|
||||
assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field");
|
||||
set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
|
||||
assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group");
|
||||
assert_eq!(fields(&doc).len(), 3);
|
||||
}
|
||||
|
||||
/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent.
|
||||
fn selection_indices(doc: &Document, name: &str) -> Option<Vec<i64>> {
|
||||
let f = field(&fields(doc), name).clone();
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap();
|
||||
d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_repoints_its_selected_indices() {
|
||||
let mut doc = one_page();
|
||||
let list =
|
||||
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2]));
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap();
|
||||
// Removing A moves B and C up one place.
|
||||
let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() };
|
||||
set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1]));
|
||||
// Removing B leaves only C selected; the top row would point past the end.
|
||||
set_props(&mut doc, &list, &opts(&["C"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0]));
|
||||
assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row");
|
||||
// No selected value is offered any more.
|
||||
set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
}
|
||||
|
||||
fn top_row(doc: &Document, obj: ObjRef) -> Option<i64> {
|
||||
doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /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 [7 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let widget = ObjRef::new(7, 0);
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(selection_indices(&doc, "pick"), Some(vec![0]));
|
||||
// The widget's own /TI is read before the field's; row 5 no longer exists.
|
||||
assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0");
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(top_row(&doc, widget), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_widget_with_its_own_top_row_scrolls_by_itself() {
|
||||
// Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4.
|
||||
let objs = |v: &str| -> Vec<String> {
|
||||
vec![
|
||||
"<< /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 [7 0 R 8 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(),
|
||||
]
|
||||
};
|
||||
let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0));
|
||||
// The options shrink: the first widget already shows A, the other's row 4 is gone.
|
||||
let mut doc = document(&objs("/V (A) /I [0]"));
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(top_row(&doc, first), None);
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A");
|
||||
// A new value the first widget already shows, below the other's view.
|
||||
let mut doc = document(&objs("/V (B) /I [1]"));
|
||||
set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap();
|
||||
assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move");
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A");
|
||||
}
|
||||
|
||||
/// A list box's top row is chosen with the font size set in the same edit: at the larger size
|
||||
/// fewer rows fit, so it scrolls further to keep the selection in view.
|
||||
#[test]
|
||||
fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() {
|
||||
let mut doc = 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] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
]);
|
||||
let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec();
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let g = field(&fields(&doc), "pick").clone();
|
||||
let w = &g.widgets[0];
|
||||
let rows = w.list.unwrap();
|
||||
let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]);
|
||||
assert!(shown.contains(&3), "C is row 3; rows {shown:?} show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_a_combo_boxs_items_repoints_its_selected_index() {
|
||||
let mut doc = one_page();
|
||||
let combo = add_field(
|
||||
&mut doc,
|
||||
0,
|
||||
[50.0, 600.0, 250.0, 620.0],
|
||||
&NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![0]));
|
||||
set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// apple, Fig, Pear: the selection follows Pear to the end.
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![2]));
|
||||
assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
|
||||
let mut doc = one_page();
|
||||
let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap();
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]);
|
||||
// Zeta and Alpha both export "x"; `/I` says Zeta is the one selected.
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")]));
|
||||
d.set(b"V".to_vec(), PdfString::text("x"));
|
||||
d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)]));
|
||||
})
|
||||
.unwrap();
|
||||
set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x".
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
|
||||
let mut doc = fixture();
|
||||
let all = fields(&doc);
|
||||
let city = field(&all, "address.city").clone();
|
||||
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap();
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default);
|
||||
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own");
|
||||
set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap();
|
||||
reset(&mut doc, None).unwrap();
|
||||
assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it");
|
||||
// Without a parent default, clearing still removes the key.
|
||||
set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let name = field(&fields(&doc), "name").clone();
|
||||
assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_value_does_not_bring_back_its_parents() {
|
||||
// A text kid, then a list box kid, under a parent with both a value and a default.
|
||||
for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] {
|
||||
let mut doc = 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] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(),
|
||||
]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}");
|
||||
reset(&mut doc, None).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value);
|
||||
let mut doc = doc;
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) };
|
||||
set_value(&mut doc, "address.city", &clear).unwrap();
|
||||
let f = field(&fields(&reopen(&doc)), "address.city").clone();
|
||||
if ft.contains("Ch") {
|
||||
assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value);
|
||||
} else {
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() {
|
||||
// A check box, then a radio button (flags 49152, inherited from the parent).
|
||||
for flags in ["", "/Ff 49152"] {
|
||||
let mut doc = 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] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(),
|
||||
"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream".into(),
|
||||
]);
|
||||
let kind = field(&fields(&doc), "opts.a").kind;
|
||||
assert_eq!(kind == FieldKind::Radio, !flags.is_empty());
|
||||
set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}");
|
||||
let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) };
|
||||
set_value(&mut doc, "opts.a", &on).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]);
|
||||
reset(&mut doc, None).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
+39
-21
@@ -1,8 +1,8 @@
|
||||
use super::appearances::{declares_pdfa, need_appearances};
|
||||
use super::read::{acroform, field_flags, fields, page_refs};
|
||||
use super::{
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author,
|
||||
calc_order, flags, invalid, name_obj, scripting,
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance,
|
||||
author, calc_order, flags, invalid, name_obj, scripting,
|
||||
};
|
||||
|
||||
// ── writing ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -210,8 +210,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
|
||||
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
|
||||
let t = &scripting::accept(doc, f, t, scripts)?;
|
||||
let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if t.is_empty() {
|
||||
if t.is_empty() && !shadow {
|
||||
d.remove(b"V");
|
||||
} else {
|
||||
d.set(b"V".to_vec(), PdfString::text(t));
|
||||
@@ -291,16 +292,13 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
indices.sort_unstable_by_key(Object::as_int);
|
||||
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let top = match (f.kind, f.widgets.first()) {
|
||||
(FieldKind::List, Some(w)) => w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
let top = list_scroll(f, &picked, f.options.len());
|
||||
// An empty value of its own keeps a parent field's /V from showing through.
|
||||
let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] if shadow => d.set(b"V".to_vec(), PdfString::text("")),
|
||||
[] => {
|
||||
d.remove(b"V");
|
||||
}
|
||||
@@ -318,16 +316,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
}
|
||||
})?;
|
||||
// A widget with its own `/TI` (read before the field's) scrolls too.
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
scroll_widgets(doc, f, &picked, f.options.len())?;
|
||||
redraw(doc, f, &exports, scripts)
|
||||
}
|
||||
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
|
||||
@@ -336,6 +325,35 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
|
||||
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
|
||||
/// visible row would otherwise not show (#1524). `None` for any other field.
|
||||
pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count))
|
||||
}
|
||||
|
||||
/// A list box widget's first row before and after scrolling so `picked` shows.
|
||||
fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count))
|
||||
})
|
||||
}
|
||||
|
||||
/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and
|
||||
/// size, so `picked` shows in each.
|
||||
pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> {
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI"))
|
||||
&& let Some((old, new)) = widget_scroll(w, picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
|
||||
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
|
||||
let v = on.unwrap_or("Off");
|
||||
|
||||
@@ -326,7 +326,10 @@ struct Piece {
|
||||
/// The interpreter state carried across the operators of a scope.
|
||||
struct Walk {
|
||||
gs: Gs,
|
||||
stack: Vec<Gs>,
|
||||
/// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
/// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's
|
||||
/// errata, issue 368), as the renderer does.
|
||||
stack: Vec<(Gs, Matrix, Matrix)>,
|
||||
tm: Matrix,
|
||||
tlm: Matrix,
|
||||
fonts_res: Dict,
|
||||
@@ -437,10 +440,10 @@ impl Walk {
|
||||
|
||||
fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => self.stack.push(self.gs.clone()),
|
||||
b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)),
|
||||
b"Q" => {
|
||||
if let Some(g) = self.stack.pop() {
|
||||
self.gs = g;
|
||||
if let Some((g, m, lm)) = self.stack.pop() {
|
||||
(self.gs, self.tm, self.tlm) = (g, m, lm);
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -7004,6 +7004,10 @@ mod default_colour_spaces;
|
||||
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
|
||||
mod devicen_inks;
|
||||
|
||||
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
|
||||
// patch 27).
|
||||
mod isolated_cmyk_groups;
|
||||
|
||||
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||
/// but draws no pixels itself.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2
|
||||
//! §11.3.4, §11.3.5.3).
|
||||
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A print test file's patch of blend modes in isolated groups whose colour space is
|
||||
/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the
|
||||
/// group's colour space, and a subtractive space's components are complemented before the
|
||||
/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes,
|
||||
/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop
|
||||
/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so
|
||||
/// magenta under black with Difference came out magenta instead of green. Vendored
|
||||
/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own
|
||||
/// transparent backdrop) blending in DeviceCMYK is blended in CMYK.
|
||||
///
|
||||
/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated
|
||||
/// group on it whose two elements are tint A and then tint B with the cell's blend mode:
|
||||
/// drawn right, the group is the colour of the square around it.
|
||||
#[test]
|
||||
fn isolated_groups_blend_in_device_cmyk() {
|
||||
// (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand)
|
||||
type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]);
|
||||
let cells: [Cell; 7] = [
|
||||
// |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green.
|
||||
("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
// Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855);
|
||||
// K is the backdrop's.
|
||||
("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]),
|
||||
// (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's.
|
||||
("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]),
|
||||
];
|
||||
let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]);
|
||||
// `group`: the cell group's /Group entries; `b`: what the second element paints.
|
||||
let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| {
|
||||
let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new());
|
||||
for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() {
|
||||
let x = 10 + i * 50;
|
||||
page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected));
|
||||
let rect = format!("{} 20 20 20 re f", x + 10);
|
||||
let n = 10 + i * 3;
|
||||
for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] {
|
||||
let body = format!("{paint} {rect}");
|
||||
objects += &format!(
|
||||
"{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
x + 40,
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
let body = "/E1 Do /BM gs /E2 Do";
|
||||
objects += &format!(
|
||||
"{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
n + 2,
|
||||
x + 40,
|
||||
n + 1,
|
||||
body.len()
|
||||
);
|
||||
names += &format!("/G{i} {} 0 R ", n + 2);
|
||||
}
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
{objects}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{group}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec();
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
let p = render("/CS /DeviceCMYK /I true", &tint);
|
||||
for (i, (mode, ..)) in cells.iter().enumerate() {
|
||||
let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30));
|
||||
// The square is a solid CMYK fill; the group lands within rounding of it.
|
||||
assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}");
|
||||
}
|
||||
|
||||
// A group whose contents have no ink tints (here the second element is RGB) is
|
||||
// composited in RGB exactly as before: the same as a group blending in DeviceRGB.
|
||||
let rgb = |_: [f32; 4]| "1 0 0 rg".to_string();
|
||||
let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb);
|
||||
let rgb_group = render("/CS /DeviceRGB /I true", &rgb);
|
||||
assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before");
|
||||
}
|
||||
|
||||
/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost
|
||||
/// its blend mode in the tiles a paint covered completely, which drew black over magenta at
|
||||
/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested
|
||||
/// isolated group that names another blending space (here DeviceRGB) blends in that space
|
||||
/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated
|
||||
/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145).
|
||||
#[test]
|
||||
fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() {
|
||||
let render = |inner_cs: Option<&str>, scale: f32| {
|
||||
// `inner_cs`: the nested group's /CS and /I entries.
|
||||
let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f";
|
||||
let (outer_body, extra) = match inner_cs {
|
||||
None => (group.to_string(), String::new()),
|
||||
Some(cs) => (
|
||||
"/In Do".to_string(),
|
||||
format!(
|
||||
"6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n",
|
||||
group.len()
|
||||
),
|
||||
),
|
||||
};
|
||||
let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream
|
||||
{outer_body}
|
||||
endstream endobj
|
||||
{extra}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len(),
|
||||
outer_body.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| {
|
||||
let (x, y) = ((x * scale) as u32, (y * scale) as u32);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
// 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely.
|
||||
for scale in [1.0, 4.0] {
|
||||
let p = render(None, scale);
|
||||
let strip = px(&p, 110.0, 245.0, scale);
|
||||
for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] {
|
||||
let group = px(&p, x, y, scale);
|
||||
assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly
|
||||
// as when the enclosing group is not offered to the CMYK path at all.
|
||||
let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0);
|
||||
let green = px(&nested_rgb, 110.0, 245.0, 1.0);
|
||||
assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK");
|
||||
// A nested DeviceCMYK group stays in the CMYK path.
|
||||
let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0);
|
||||
assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3);
|
||||
// A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the
|
||||
// enclosing group's CMYK, even when it names DeviceRGB.
|
||||
let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0);
|
||||
assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK");
|
||||
}
|
||||
|
||||
/// An element of a knockout group is first drawn in erase mode, which clears what the group
|
||||
/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must
|
||||
/// clear too: when the erase pass drew it through the CMYK path, it painted over the red
|
||||
/// beneath instead of clearing it. The page must match the same element with its nested group
|
||||
/// blending in `DeviceRGB`, which never takes the CMYK path.
|
||||
#[test]
|
||||
fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() {
|
||||
let render = |inner_cs: &str| {
|
||||
let knockout = "1 0 0 rg 20 20 160 160 re f /El Do";
|
||||
let element = "/Cm Do";
|
||||
let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 7 >> stream
|
||||
/Ko Do
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream
|
||||
{knockout}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream
|
||||
{element}
|
||||
endstream endobj
|
||||
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream
|
||||
{inner}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
knockout.len(),
|
||||
element.len(),
|
||||
inner.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error);
|
||||
// The centre of the inner square, (100, 100), is also the centre of the page.
|
||||
p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let rgb = render("/DeviceRGB");
|
||||
let cmyk = render("/DeviceCMYK");
|
||||
assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}");
|
||||
assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}");
|
||||
}
|
||||
|
||||
/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated
|
||||
/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`).
|
||||
fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec<u8> {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream
|
||||
{group}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
media[0],
|
||||
media[1],
|
||||
page.len(),
|
||||
bbox[0],
|
||||
bbox[1],
|
||||
bbox[2],
|
||||
bbox[3],
|
||||
group.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
}
|
||||
|
||||
/// From the review of #5228: a shading pattern painted before the group left its pattern
|
||||
/// transform on the context, and the finished group was drawn through it, so the cyan half
|
||||
/// of the group showed magenta. The group must look the same whether or not a pattern was
|
||||
/// painted first.
|
||||
#[test]
|
||||
fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() {
|
||||
let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f";
|
||||
let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50));
|
||||
let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do");
|
||||
let alone = render("/G Do");
|
||||
assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}");
|
||||
assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}");
|
||||
}
|
||||
|
||||
/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop
|
||||
/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass
|
||||
/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to
|
||||
/// near black. Every 8-bit K level is checked against a plain fill of the same ink.
|
||||
#[test]
|
||||
fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() {
|
||||
let mut wrong = Vec::new();
|
||||
for level in 0..=255u8 {
|
||||
let k = format!("0 0 0 {} k", f32::from(level) / 255.0);
|
||||
let page = format!("{k} 0 0 20 20 re f /G Do");
|
||||
let group = format!("/B gs {k} 20 0 20 20 re f");
|
||||
let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10));
|
||||
let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10));
|
||||
if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) {
|
||||
wrong.push((level, plain, blended));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}");
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! #4886: a non-isolated knockout group (`/I false /K true`) starts from the backdrop under it
|
||||
//! (ISO 32000-2 §11.4.7, §11.4.8), so each element blends with the page there, in its own blend
|
||||
//! mode, and replaces the elements before it under its shape. White with `/BM /Difference` over
|
||||
//! magenta is green; drawn into a layer of its own starting transparent, it stayed white.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
const MAGENTA: [u8; 3] = [255, 0, 255];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
/// Renders a 200 x 200 page: magenta, then the group `/Fk` with group attributes `group` and
|
||||
/// content `content`. Its resources have `/D` (`/BM /Difference`), `/N` (`/BM /Normal`), `/H`
|
||||
/// (`/ca 0.5`), `/W`, a white square from 40 to 160 in a group of its own, `/Nest`, a group
|
||||
/// holding a non-isolated knockout group with a white Difference square from 80 to 120, and
|
||||
/// `/Img`, a white 1 x 1 image, `/Img2`, a blue 2 x 1 image whose soft mask is opaque on the
|
||||
/// left and transparent on the right, `/St`, a 2 x 1 stencil mask painting its left pixel, and
|
||||
/// the shading `/Sh0`, a mesh whose one triangle lies off the page, and `/Sh1`, a red mesh
|
||||
/// triangle from (90, 90) to (110, 90) and (100, 110).
|
||||
/// Returns a lookup of the colour at PDF point (x, y).
|
||||
fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] {
|
||||
let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>";
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Fk 5 0 R >> >> >>"
|
||||
.to_string(),
|
||||
stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R /Sh1 14 0 R >> >>"),
|
||||
content,
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /K2 8 0 R >> >>", "/K2 Do"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I false /K true >> /Resources << {gs} >>"),
|
||||
"/D gs 1 1 1 rg 80 80 40 40 re f",
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FFFFFF>"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode",
|
||||
"0000FF0000FF>",
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"),
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true /BitsPerComponent 1 /Filter /ASCIIHexDecode", "40>"),
|
||||
stream(
|
||||
"/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [300 310 300 310 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
"000000FF0000 00FF00FF0000 0000FFFF0000>",
|
||||
),
|
||||
stream(
|
||||
"/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
"005A5AFF0000 006E5AFF0000 00646EFF0000>",
|
||||
),
|
||||
];
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{content}: {:?}", page.error);
|
||||
let (rgba, width) = (page.rgba.to_vec(), page.width);
|
||||
move |x, y| {
|
||||
// Pixel rows run top down.
|
||||
let offset = usize::try_from(((200 - y) * width + x) * 4).unwrap();
|
||||
[rgba[offset], rgba[offset + 1], rgba[offset + 2]]
|
||||
}
|
||||
}
|
||||
|
||||
fn check(at: &impl Fn(u32, u32) -> [u8; 3], x: u32, y: u32, want: [u8; 3], what: &str) {
|
||||
let got = at(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what} at ({x}, {y}): {got:?}, expected {want:?}");
|
||||
}
|
||||
|
||||
const NON_ISOLATED: &str = "/I false /K true";
|
||||
const SQUARES: &str = "/D gs 1 1 1 rg 20 20 110 110 re f 70 70 110 110 re f";
|
||||
|
||||
#[test]
|
||||
fn a_non_isolated_knockout_group_blends_each_element_with_the_page() {
|
||||
let squares = render(NON_ISOLATED, SQUARES);
|
||||
check(&squares, 40, 40, GREEN, "first square");
|
||||
check(&squares, 160, 160, GREEN, "second square");
|
||||
// The second square blends with the page, not with the first: green, not magenta again.
|
||||
check(&squares, 100, 100, GREEN, "overlap");
|
||||
check(&squares, 190, 10, MAGENTA, "page outside the group's elements");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_element_over_a_point_decides_its_blend_mode() {
|
||||
// Normal blue at 50 % after a Difference square: blue over the page where they overlap.
|
||||
let blue_last = render(NON_ISOLATED, "/D gs 1 1 1 rg 20 20 110 110 re f /N gs 0 0 1 rg q /H gs 70 70 110 110 re f Q");
|
||||
check(&blue_last, 40, 40, GREEN, "Difference alone");
|
||||
check(&blue_last, 100, 100, [128, 0, 255], "Normal blue last");
|
||||
check(&blue_last, 160, 160, [128, 0, 255], "Normal blue alone");
|
||||
// The other way round: the Difference square replaces the blue and blends with the page.
|
||||
let white_last = render(NON_ISOLATED, "q /H gs 0 0 1 rg 20 20 110 110 re f Q /D gs 1 1 1 rg 70 70 110 110 re f");
|
||||
check(&white_last, 40, 40, [128, 0, 255], "Normal blue alone");
|
||||
check(&white_last, 100, 100, GREEN, "Difference last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_group_in_a_non_isolated_knockout_group_blends_with_the_page() {
|
||||
let nested = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /W Do");
|
||||
check(&nested, 100, 100, GREEN, "nested group drawn with Difference over the knocked-out blue");
|
||||
check(&nested, 30, 30, [0, 0, 255], "blue beside the nested group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_isolated_knockout_group_still_starts_transparent() {
|
||||
let isolated = render("/I true /K true", SQUARES);
|
||||
check(&isolated, 40, 40, WHITE, "isolated: nothing to blend with");
|
||||
check(&isolated, 100, 100, WHITE, "isolated overlap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_and_a_partly_transparent_element_blend_with_the_page() {
|
||||
let image = render(NON_ISOLATED, "/D gs q 120 0 0 120 40 40 cm /Img Do Q");
|
||||
check(&image, 100, 100, GREEN, "white image drawn with Difference");
|
||||
check(&image, 20, 20, MAGENTA, "page beside the image");
|
||||
// Difference at 50 %: half the page, half the blend result (green).
|
||||
let half = render(NON_ISOLATED, "/D gs /H gs 1 1 1 rg 40 40 120 120 re f");
|
||||
check(&half, 100, 100, [128, 128, 128], "Difference at 50 %");
|
||||
}
|
||||
|
||||
/// A knockout group nested in another one's element is drawn first in that element's erase
|
||||
/// pass, which must leave the outer group's passes alone (review of #4886's fix).
|
||||
#[test]
|
||||
fn a_knockout_group_inside_an_element_keeps_the_outer_groups_passes() {
|
||||
let nested = render(NON_ISOLATED, "/D gs 1 1 1 rg 10 10 50 50 re f /N gs /Nest Do /D gs 1 1 1 rg 140 140 50 50 re f");
|
||||
check(&nested, 30, 30, GREEN, "Difference square before the nested groups");
|
||||
check(&nested, 165, 165, GREEN, "Difference square after the nested groups");
|
||||
}
|
||||
|
||||
/// A masked image (or, below, a stencil) in another pass than its own replaces what is under its mask
|
||||
/// there too: a blue image over a white Difference square stays blue where its mask is opaque,
|
||||
/// and leaves the square to blend with the page where it is transparent (review of #4886's fix).
|
||||
#[test]
|
||||
fn a_masked_image_replaces_what_is_under_its_mask_in_every_pass() {
|
||||
let masked = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs q 120 0 0 120 40 40 cm /Img2 Do Q");
|
||||
check(&masked, 60, 100, [0, 0, 255], "image over the square, mask opaque");
|
||||
check(&masked, 140, 100, GREEN, "square under the transparent half of the mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stencil_replaces_what_is_under_it_in_every_pass() {
|
||||
let stencil = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs 0 0 1 rg q 120 0 0 120 40 40 cm /St Do Q");
|
||||
check(&stencil, 60, 100, [0, 0, 255], "stencil over the square");
|
||||
check(&stencil, 140, 100, GREEN, "square beside the stencil");
|
||||
}
|
||||
|
||||
/// A shading that paints nothing (a mesh off the page) replaces nothing in another pass either.
|
||||
#[test]
|
||||
fn a_shading_that_paints_nothing_clears_nothing_in_another_pass() {
|
||||
let mesh = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /Sh0 sh");
|
||||
check(&mesh, 100, 100, [0, 0, 255], "blue under an off-page Difference mesh");
|
||||
}
|
||||
|
||||
/// A shading replaces only what is under the part it paints: a small mesh triangle over a
|
||||
/// square leaves the rest of the square, in its own pass, in another pass and in an isolated
|
||||
/// knockout group.
|
||||
#[test]
|
||||
fn a_small_mesh_replaces_only_what_is_under_its_triangle() {
|
||||
let square = "0 0 1 rg 20 20 160 160 re f";
|
||||
for (group, mode) in [(NON_ISOLATED, ""), (NON_ISOLATED, "/D gs"), ("/K true", "")] {
|
||||
let content = format!("{square} {mode} /Sh1 sh");
|
||||
let mesh = render(group, &content);
|
||||
check(&mesh, 40, 40, [0, 0, 255], &format!("{group} {mode}: square beside the triangle"));
|
||||
if mode.is_empty() {
|
||||
check(&mesh, 100, 97, [255, 0, 0], &format!("{group}: triangle"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@
|
||||
mod cid_fallback_query;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod knockout_backdrop;
|
||||
mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod nonseparable_gray;
|
||||
mod open_type_cff_simple_font;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod stray_q_in_a_form;
|
||||
mod stroke_degenerate_cubic;
|
||||
mod substitute_widths;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's
|
||||
//! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black
|
||||
//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going wrong; that
|
||||
//! case already renders correctly here, so its test guards rather than reproduces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
/// Renders an isolated group holding a 16 × 16 grid of 6 pt cells, one per gray level, each
|
||||
/// drawn by `cell(level, "x y 6 6 re")` with `/B` (`/BM /{mode} /ca {alpha}`) available, and
|
||||
/// returns each level's centre pixel.
|
||||
fn gray_ramp(mode: &str, alpha: f32, cell: impl Fn(u8, &str) -> String) -> Vec<(u8, [u8; 3])> {
|
||||
let mut content = String::new();
|
||||
for level in 0..=255_u8 {
|
||||
let (x, y) = (2 + u32::from(level % 16) * 6, 2 + u32::from(level / 16) * 6);
|
||||
content.push_str(&cell(level, &format!("{x} {y} 6 6 re")));
|
||||
content.push('\n');
|
||||
}
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>".to_string(),
|
||||
stream("", "/Fm0 Do"),
|
||||
stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Resources << /ExtGState << /B << /BM /{mode} /ca {alpha} >> >> >>"
|
||||
),
|
||||
&content,
|
||||
),
|
||||
];
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
(0..=255_u8)
|
||||
.map(|level| {
|
||||
let (x, y) = (5 + u32::from(level % 16) * 6, 100 - 5 - u32::from(level / 16) * 6);
|
||||
let offset = usize::try_from((y * page.width + x) * 4).unwrap();
|
||||
(level, [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gray(level: u8) -> f32 {
|
||||
f32::from(level) / 255.0
|
||||
}
|
||||
|
||||
fn assert_ramp(what: &str, got: &[(u8, [u8; 3])], expected: impl Fn(u8) -> f32) {
|
||||
let wrong: Vec<_> = got.iter().filter(|(level, rgb)| rgb.iter().any(|&c| (f32::from(c) - expected(*level)).abs() > 2.0)).collect();
|
||||
assert!(wrong.is_empty(), "{what}: {} levels off, (level, pixel): {wrong:?}", wrong.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_gray_under_color_over_nothing_keeps_its_lightness() {
|
||||
// With nothing beneath it the blend result is the source (§11.4.7): each gray at half
|
||||
// opacity, over the white page.
|
||||
let got = gray_ramp("Color", 0.5, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
|
||||
assert_ramp("Color", &got, |level| (f32::from(level) + 255.0) / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_under_luminosity_halves_every_opaque_gray() {
|
||||
// B(Cb, Cs) = SetLum(Cb, Lum(Cs)) is black for a black source, so at half opacity the
|
||||
// result is half the backdrop's gray.
|
||||
let got = gray_ramp("Luminosity", 0.5, |level, rect| format!("q {} g {rect} f /B gs 0 g {rect} f Q", gray(level)));
|
||||
assert_ramp("Luminosity", &got, |level| f32::from(level) / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode() {
|
||||
for mode in ["Color", "Luminosity", "Hue", "Saturation"] {
|
||||
let got = gray_ramp(mode, 1.0, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
|
||||
assert_ramp(mode, &got, f32::from);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! A `Type1` font whose `FontFile3` has subtype `OpenType` (ISO 32000-2 §9.9) keeps its CFF
|
||||
//! program inside an OpenType table directory; it is drawn with that program, not with a
|
||||
//! standard-font stand-in (hayro-interpret patch (69)).
|
||||
//!
|
||||
//! The program is the renderer's own bundled fixed-pitch CFF face, so no font file is added.
|
||||
//! Drawn right, the bare program and the same program in an OpenType wrapper give the same
|
||||
//! pixels; the stand-in a serif font falls back to is a different face.
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
const CFF: &[u8] = include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb");
|
||||
|
||||
/// `cff` in the smallest OpenType wrapper: an `OTTO` table directory with one `CFF ` table.
|
||||
fn open_type(cff: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"OTTO".to_vec();
|
||||
out.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
|
||||
out.extend_from_slice(b"CFF ");
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(&28u32.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(cff.len()).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(cff);
|
||||
out
|
||||
}
|
||||
|
||||
/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program` of
|
||||
/// `subtype`, or which embeds no program when `None`.
|
||||
fn page(program: Option<(&[u8], &str)>) -> Vec<u8> {
|
||||
let file = if program.is_some() { " /FontFile3 7 0 R" } else { "" };
|
||||
let body = "BT /F1 60 Tf 10 30 Td (AgW) Tj ET";
|
||||
let mut objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Synthetic-Semibold /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 103 /Widths [600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600] /FontDescriptor 6 0 R >>".to_vec(),
|
||||
format!("<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120{file} >>").into_bytes(),
|
||||
];
|
||||
if let Some((program, subtype)) = program {
|
||||
let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes();
|
||||
stream.extend_from_slice(program);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objects.push(stream);
|
||||
}
|
||||
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pixels(bytes: Vec<u8>) -> Vec<u8> {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(image.error.is_none(), "{:?}", image.error);
|
||||
image.rgba.to_vec()
|
||||
}
|
||||
|
||||
fn inked(rgba: &[u8]) -> usize {
|
||||
rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_type_wrapped_cff_program_draws_like_the_bare_program() {
|
||||
let bare = pixels(page(Some((CFF, "Type1C"))));
|
||||
assert!(inked(&bare) > 200, "the bare CFF program drew no text");
|
||||
let wrapped = pixels(page(Some((&open_type(CFF), "OpenType"))));
|
||||
assert!(wrapped == bare, "the OpenType-wrapped program drew a different face: the font fell back");
|
||||
}
|
||||
|
||||
/// A wrapper with no `CFF ` table still draws, with the stand-in a font without a program
|
||||
/// gets, rather than failing the page or reading the program anyway.
|
||||
#[test]
|
||||
fn a_wrapper_without_a_cff_table_falls_back() {
|
||||
let mut broken = open_type(CFF);
|
||||
broken.get_mut(12..16).unwrap().copy_from_slice(b"glyf");
|
||||
let rgba = pixels(page(Some((&broken, "OpenType"))));
|
||||
assert!(inked(&rgba) > 200, "the stand-in drew no text");
|
||||
assert!(rgba == pixels(page(None)), "the broken wrapper drew another face than the stand-in");
|
||||
assert!(rgba != pixels(page(Some((CFF, "Type1C")))), "the broken wrapper drew the embedded program");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Contributor-original synthetic pages: a form XObject whose content has more `Q` than `q`.
|
||||
//! A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2, §8.10.1), so a `Q` with
|
||||
//! nothing of its own stream to restore is ignored and cannot pop the states of the page that
|
||||
//! drew the form (vendored hayro-interpret patch 68). Each case is paired with the same page
|
||||
//! drawing a balanced form, which must come out the same.
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Forms /B (balanced), /S2 (two stray `Q`s) and /S3 (three), each moving its own pen.
|
||||
const FORMS: [(&str, &[u8]); 3] =
|
||||
[("B", b"q BT /F1 12 Tf 200 0 Td ET Q"), ("S2", b"BT /F1 12 Tf 200 0 Td ET Q Q"), ("S3", b"BT /F1 12 Tf 200 0 Td ET Q Q Q")];
|
||||
|
||||
/// A page that shifts and clips inside `q … Q`, draws form `form` there, then text and a fill
|
||||
/// that depend on the shift and the clip, and after its `Q` text that depends on neither.
|
||||
fn page(form: &str, in_text: bool) -> String {
|
||||
let draw = if in_text {
|
||||
format!("BT /F1 12 Tf 72 300 Td (AA) Tj /{form} Do (SECRET) Tj ET")
|
||||
} else {
|
||||
format!("BT /F1 12 Tf 72 300 Td (AA) Tj ET /{form} Do BT /F1 12 Tf 72 280 Td (SECRET) Tj ET")
|
||||
};
|
||||
format!("q 1 0 0 1 20 -100 cm 0 0 200 400 re W n {draw} 0 0 1 rg 0 0 400 50 re f Q BT /F1 12 Tf 72 360 Td (TAIL) Tj ET")
|
||||
}
|
||||
|
||||
/// One 400 × 400 page per entry of `contents`, with Helvetica as /F1 and the forms of `FORMS`.
|
||||
fn pdf(contents: &[String]) -> Arc<Vec<u8>> {
|
||||
let first_page = 4 + FORMS.len();
|
||||
let n = contents.len();
|
||||
let kids: String = (0..n).map(|i| format!("{} 0 R ", first_page + 2 * i)).collect();
|
||||
let xobjects: String = FORMS.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 4 + i)).collect();
|
||||
let mut objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(),
|
||||
format!("<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << {xobjects}>> >>").into_bytes(),
|
||||
];
|
||||
for (_, body) in FORMS {
|
||||
objects.push(stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", body));
|
||||
}
|
||||
for (i, content) in contents.iter().enumerate() {
|
||||
objects.push(
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", first_page + 1 + 2 * i).into_bytes(),
|
||||
);
|
||||
objects.push(stream(b"", content.as_bytes()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(object);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Arc::new(out)
|
||||
}
|
||||
|
||||
fn stream(dict: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut s = b"<< ".to_vec();
|
||||
s.extend_from_slice(dict);
|
||||
s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes());
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
fn request(page: usize, kind: RequestKind) -> RenderRequest {
|
||||
RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false }
|
||||
}
|
||||
|
||||
fn texts(contents: &[String]) -> Vec<Arc<PageText>> {
|
||||
let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default());
|
||||
(0..contents.len())
|
||||
.map(|page| {
|
||||
let out = renderer.render(request(page, RequestKind::Text));
|
||||
assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error);
|
||||
out.text.expect("page text")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boxes of the glyphs of `word`'s one match on the page.
|
||||
fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> {
|
||||
let hits = text.find(word);
|
||||
assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text());
|
||||
hits[0].clone().map(|i| text.glyphs[i].rect).collect()
|
||||
}
|
||||
|
||||
fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_text_like_the_balanced_form(in_text: bool) {
|
||||
let pages = texts(&[page("B", in_text), page("S2", in_text), page("S3", in_text)]);
|
||||
for (stray, name) in pages[1..].iter().zip(["S2", "S3"]) {
|
||||
for word in ["SECRET", "TAIL"] {
|
||||
let (got, want) = (boxes(stray, word), boxes(&pages[0], word));
|
||||
assert_eq!(got.len(), want.len(), "{name}: {word}");
|
||||
assert_same_boxes(&got, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_does_not_undo_the_pages_cm() {
|
||||
assert_text_like_the_balanced_form(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_inside_a_text_object_does_not_undo_the_pages_cm() {
|
||||
assert_text_like_the_balanced_form(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_does_not_undo_the_pages_clip() {
|
||||
let contents = [page("B", false), page("S2", false), page("S3", false)];
|
||||
let mut renderer = PageRenderer::new(pdf(&contents), RenderConfig::default());
|
||||
let balanced = renderer.render(request(0, RequestKind::Pixels));
|
||||
assert!(balanced.error.is_none());
|
||||
for i in 1..contents.len() {
|
||||
let stray = renderer.render(request(i, RequestKind::Pixels));
|
||||
assert!(stray.error.is_none());
|
||||
assert_eq!((stray.width, stray.height), (balanced.width, balanced.height));
|
||||
assert!(stray.rgba == balanced.rgba, "page {} draws differently from the balanced form", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_balanced_q_inside_a_form_still_restores() {
|
||||
// The balanced form's pen move stays inside its own `q … Q`, so SECRET sits where the page
|
||||
// put it: 72 + 20 from the page's `cm`, and to the right of AA.
|
||||
let pages = texts(&[page("B", false)]);
|
||||
let secret = boxes(&pages[0], "SECRET");
|
||||
assert!((secret[0][0] - 92.0).abs() < 0.5, "SECRET starts at {}", secret[0][0]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! A contributor-original synthetic page reproducing #4907: an outline that ends in a cubic whose
|
||||
//! control points all sit 0.001 pt from its start was not drawn at all, because the stroker put
|
||||
//! NaN into its outline and the rasterizer dropped the whole path.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A one-page 100 × 20 pt PDF drawing `content`, with a cross-reference table.
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 20] /Contents 4 0 R >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×.
|
||||
fn red_at(content: &str, x: usize, y: usize) -> u8 {
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let at = (y * out.width as usize + x) * 4;
|
||||
*out.rgba.get(at).expect("the pixel lies on the page")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_ending_in_a_tiny_degenerate_cubic_is_drawn() {
|
||||
// The line runs from (10, 10) to about (84, 9.5) in page space; (50, 10) from the top left is
|
||||
// inside its 2 pt width. The cubic's exact coordinates are what trigger the NaN. The control,
|
||||
// without the cubic, is the baseline: it draws with or without the fix.
|
||||
let path = "0 0 m 73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c S";
|
||||
let control = "0 0 m 73.763 -0.536 l S";
|
||||
for content in [control, path] {
|
||||
let content = format!("0 G 2 w 1 0 0 1 10 10 cm {content}");
|
||||
let red = red_at(&content, 50, 10);
|
||||
assert!(red < 64, "`{content}` left the line's pixel at red {red}, want black");
|
||||
}
|
||||
}
|
||||
@@ -1730,6 +1730,9 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = props.name.as_ref().and_then(|_| form(app));
|
||||
// The widget being edited, by object: a radio button that joins a group by its new name
|
||||
// is appended after the group's own buttons, so its index changes.
|
||||
let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));
|
||||
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
@@ -1738,7 +1741,10 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
let typed = d.name.trim();
|
||||
let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
let name = format!("{prefix}{typed}");
|
||||
if let (Some(before), Some(after)) = (before, form(app)) {
|
||||
let after = form(app);
|
||||
let index =
|
||||
widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget);
|
||||
if let (Some(before), Some(after)) = (before, after) {
|
||||
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
|
||||
if n > 0 {
|
||||
app.notify(crate::i18n::trn(
|
||||
@@ -1749,7 +1755,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
));
|
||||
}
|
||||
}
|
||||
app.views[i].prepare.selected = Some((name, d.widget));
|
||||
app.views[i].prepare.selected = Some((name, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_size(rect.size());
|
||||
let Some(fx) = view.forms.focus.as_mut() else { return };
|
||||
let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
|
||||
let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom);
|
||||
// The box is pale yellow in every theme, as a text field's is (#655).
|
||||
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
|
||||
let te = egui::TextEdit::singleline(&mut fx.text)
|
||||
@@ -510,17 +510,39 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t
|
||||
// The appearance's padding: 2 pt inside a 1 pt border.
|
||||
let pad = 3.0;
|
||||
let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad);
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
let comb = f.has(field_flags::COMB) && f.max_len.is_some_and(|m| m > 0);
|
||||
let fit = if comb { "" } else { text };
|
||||
// Comb cells hold one character each, so only the height limits them, as on the page.
|
||||
if f.is_comb() {
|
||||
return pdfcraft_engine::field_comb_auto_text_size(height);
|
||||
}
|
||||
let font = |size: f64| egui::FontId::proportional(size as f32);
|
||||
let line_width = |size: f64| f64::from(painter.layout_no_wrap(fit.to_string(), font(size), Color32::BLACK).size().x);
|
||||
let line_width = |size: f64| {
|
||||
bounded_line_width(size, |at| painter.layout_no_wrap(text.to_string(), egui::FontId::proportional(at), Color32::BLACK).size().x)
|
||||
};
|
||||
// Wrapped as the editor and the page preview wrap it.
|
||||
let wrap = width.max(1.0) as f32;
|
||||
let wrapped_height = |size: f64| f64::from(painter.layout(fit.to_string(), font(size), Color32::BLACK, wrap).size().y);
|
||||
let wrapped_height = |size: f64| f64::from(painter.layout(text.to_string(), font(size), Color32::BLACK, wrap).size().y);
|
||||
pdfcraft_engine::field_auto_text_size_by(width, height, f.has(field_flags::MULTILINE), line_width, wrapped_height)
|
||||
});
|
||||
size.min(300.0) as f32
|
||||
size as f32
|
||||
}
|
||||
|
||||
/// The width of a line at `size` points from `measure`, the width at a given size, called at no
|
||||
/// more than [`MAX_SCREEN_FONT`] and scaled: egui rasterizes every glyph it lays out, and a tall
|
||||
/// field's automatic size can be thousands of points.
|
||||
fn bounded_line_width(size: f64, measure: impl Fn(f32) -> f32) -> f64 {
|
||||
let at = size.min(f64::from(MAX_SCREEN_FONT));
|
||||
if at > 0.0 { f64::from(measure(at as f32)) * size / at } else { f64::from(measure(at as f32)) }
|
||||
}
|
||||
|
||||
/// The largest font, in screen points, the on-page field editor draws with: a bound that keeps
|
||||
/// one rasterized glyph (font size times pixels per point) well inside egui's font atlas.
|
||||
pub const MAX_SCREEN_FONT: f32 = 256.0;
|
||||
|
||||
/// The font, in screen points, a field's editor shows `text` at, `zoom` screen points per page
|
||||
/// point: [`field_text_size`] scaled, within 3 and [`MAX_SCREEN_FONT`].
|
||||
pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, text: &str, zoom: f32) -> f32 {
|
||||
let font = field_text_size(painter, f, w, text) * zoom;
|
||||
if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) }
|
||||
}
|
||||
|
||||
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
|
||||
@@ -532,7 +554,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField,
|
||||
if !text.is_empty() {
|
||||
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
|
||||
let zoom = xf.rect.width() / unrotated_w.max(1.0);
|
||||
let font_size = (field_text_size(painter, f, w, text) * zoom).clamp(3.0, 64.0);
|
||||
let font_size = field_screen_font(painter, f, w, text, zoom);
|
||||
let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() };
|
||||
let p = painter.with_clip_rect(r);
|
||||
let (align, pos) = match f.quadding {
|
||||
@@ -766,7 +788,7 @@ pub(crate) fn overlay(
|
||||
ui.set_min_size(rect.size());
|
||||
let Some(fx) = view.forms.focus.as_mut() else { return };
|
||||
// Sized from the text being typed, so an auto-sized field shrinks to keep it in view.
|
||||
let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0);
|
||||
let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom);
|
||||
// The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
|
||||
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL);
|
||||
// A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228).
|
||||
@@ -959,3 +981,22 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget:
|
||||
let xf = view.page_xform(page)?;
|
||||
Some(widget_rect(&xf, info, page, w.rect))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_SCREEN_FONT, bounded_line_width};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn a_line_is_never_laid_out_above_the_screen_font_bound() {
|
||||
let largest = Cell::new(0.0f32);
|
||||
let measure = |at: f32| {
|
||||
largest.set(largest.get().max(at));
|
||||
at * 3.0
|
||||
};
|
||||
assert_eq!(bounded_line_width(12_522.0, measure), 12_522.0 * 3.0, "the width scales");
|
||||
assert_eq!(largest.get(), MAX_SCREEN_FONT, "laid out at the bound");
|
||||
assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself");
|
||||
assert_eq!(largest.get(), MAX_SCREEN_FONT);
|
||||
}
|
||||
}
|
||||
@@ -406,8 +406,8 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() {
|
||||
let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen");
|
||||
(rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize)))
|
||||
};
|
||||
// From inside the first word to past the end of the text.
|
||||
let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y));
|
||||
// From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text.
|
||||
let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
@@ -1363,6 +1363,54 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
|
||||
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
|
||||
}
|
||||
|
||||
/// Auto-size fields 500 pt wide: a four-cell comb 30 pt tall, and single-line fields 100 and 400 pt tall.
|
||||
fn tall_auto_size_form() -> Vec<u8> {
|
||||
pdf_from_objects(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (comb) /Ff 16777216 /MaxLen 4 /Rect [50 740 550 770] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [50 600 550 700] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (huge) /Rect [50 100 550 500] /P 3 0 R /F 4 >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// The editor shows auto-size text at the size the page appearance draws it: a comb field keeps
|
||||
/// the appearance's 12 pt cap, and a tall single-line field follows its height, at 100% zoom
|
||||
/// too, up to the editor's screen-font bound.
|
||||
#[test]
|
||||
fn the_field_editor_sizes_auto_size_text_as_the_page_does() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("tall.pdf", None, tall_auto_size_form()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let field = |name: &str| doc.form.iter().find(|f| f.name == name).unwrap().clone();
|
||||
let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING);
|
||||
let screen = |name: &str, zoom: f32| {
|
||||
let f = field(name);
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_font(&painter, &f, &f.widgets[0], "Hi", zoom)
|
||||
};
|
||||
// 100%: 96 screen points per 72 page points.
|
||||
let full = 96.0 / 72.0;
|
||||
|
||||
let comb = field("comb");
|
||||
let page = pdfcraft_engine::field_comb_auto_text_size(30.0 - 6.0);
|
||||
assert_eq!(page, 12.0);
|
||||
assert_eq!(pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &comb, &comb.widgets[0], ""), 12.0, "a comb field");
|
||||
assert_eq!(screen("comb", full), 12.0 * full);
|
||||
|
||||
let tall = (100.0 - 6.0) / 1.15;
|
||||
assert!((screen("tall", full) - tall as f32 * full).abs() < 0.01, "a 100 pt field at 100%: {}", screen("tall", full));
|
||||
assert_eq!(screen("huge", full), pdfcraft_ui_egui::forms_ui::MAX_SCREEN_FONT, "past the screen-font bound");
|
||||
assert_eq!(screen("huge", 0.5), ((400.0 - 6.0) / 1.15) as f32 * 0.5, "below it at 50%");
|
||||
}
|
||||
|
||||
/// Where a mode bar tab is, by its label.
|
||||
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
@@ -775,14 +775,18 @@ fn actions_tab_edits_the_script_of_an_existing_action() {
|
||||
/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur
|
||||
/// runs another script.
|
||||
fn chained_action_fixture() -> Vec<u8> {
|
||||
let objs = [
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>",
|
||||
"<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>",
|
||||
"<< /S /URI /URI (https://example.org) >>",
|
||||
];
|
||||
])
|
||||
}
|
||||
|
||||
/// A PDF of `objs`, numbered from 1, with the first as its catalog.
|
||||
fn pdf_of(objs: &[&str]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
@@ -798,6 +802,65 @@ fn chained_action_fixture() -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Two one-button radio groups placed apart on page 1: "Group1" (export `A`) and "Group2" (`B`).
|
||||
fn two_radio_groups_fixture() -> Vec<u8> {
|
||||
let widget = |parent: u32, x: u32, state: &str| {
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [{x} 300 {} 314] /P 3 0 R /AS /Off /AP << /N << /{state} 8 0 R /Off 8 0 R >> >> >>",
|
||||
x + 14
|
||||
)
|
||||
};
|
||||
let (a, b) = (widget(4, 40, "A"), widget(6, 120, "B"));
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R] >>",
|
||||
"<< /FT /Btn /Ff 49152 /T (Group1) /Kids [5 0 R] >>",
|
||||
&a,
|
||||
"<< /FT /Btn /Ff 49152 /T (Group2) /Kids [7 0 R] >>",
|
||||
&b,
|
||||
"<< /Length 3 >>\nstream\n0 g\nendstream",
|
||||
])
|
||||
}
|
||||
|
||||
/// #4949: renaming a radio button to another group's name moves it into that group, after the
|
||||
/// group's own buttons. The moved button stays the selected one, so a nudge moves it and not the
|
||||
/// group's first button, and undo and redo take the rename and the nudge back and forth.
|
||||
#[test]
|
||||
fn a_radio_button_joining_a_group_by_name_stays_selected() {
|
||||
let mut h = harness_with("radios.pdf", two_radio_groups_fixture());
|
||||
h.state_mut().execute("form.prepare");
|
||||
h.run_steps(2);
|
||||
let rects = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
let form = &s.session.get(s.views[0].id).unwrap().form;
|
||||
form.iter().flat_map(|f| f.widgets.iter().map(|w| w.rect)).collect::<Vec<_>>()
|
||||
};
|
||||
let start = rects(&h);
|
||||
h.state_mut().open_field_props("Group2", 0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().field_props.as_mut().expect("open").name = "Group1".into();
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(names(&h), ["Group1"]);
|
||||
assert_eq!(h.state().views[0].prepare.selected, Some(("Group1".to_string(), 1)), "the moved button");
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
let nudged = rects(&h);
|
||||
assert_eq!(nudged[0], start[0], "the group's own button stays");
|
||||
assert!((nudged[1][0] - start[1][0] - 1.0).abs() < 0.01, "{:?} → {:?}", start[1], nudged[1]);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), ["Group1", "Group2"]);
|
||||
assert_eq!(rects(&h), start);
|
||||
h.state_mut().execute("edit.redo");
|
||||
h.state_mut().execute("edit.redo");
|
||||
h.run_steps(2);
|
||||
assert_eq!(names(&h), ["Group1"]);
|
||||
assert_eq!(rects(&h), nudged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
|
||||
use egui_kittest::kittest::NodeT;
|
||||
|
||||
+59
-2
@@ -41,6 +41,62 @@ fn is_bmp(data: &[u8]) -> bool {
|
||||
&& data.get(14..18).is_some_and(|b| matches!(u32::from_le_bytes([b[0], b[1], b[2], b[3]]), 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124))
|
||||
}
|
||||
|
||||
/// `data` with a BMP's pixel-data offset (`bfOffBits`) corrected when it points into the file or
|
||||
/// info header or at its end with masks or a colour table still to come, as some writers leave it at 0: the rows then start after the headers, the colour
|
||||
/// masks and the colour table. The `image` decoder seeks to the declared offset unchecked, so
|
||||
/// such a picture would be read from the wrong place, shifted and wrapped. Anything else,
|
||||
/// including a header too short to measure, is returned as it is.
|
||||
pub fn bmp_with_pixel_offset(data: &[u8]) -> std::borrow::Cow<'_, [u8]> {
|
||||
match bmp_pixel_offset_repair(data) {
|
||||
Some(offset) => {
|
||||
let mut fixed = data.to_vec();
|
||||
if let Some(field) = fixed.get_mut(10..14) {
|
||||
field.copy_from_slice(&offset.to_le_bytes());
|
||||
}
|
||||
std::borrow::Cow::Owned(fixed)
|
||||
}
|
||||
None => std::borrow::Cow::Borrowed(data),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a BMP's rows start when its declared offset lies inside its headers, else `None`.
|
||||
fn bmp_pixel_offset_repair(data: &[u8]) -> Option<u32> {
|
||||
let le16 = |at: usize| data.get(at..at + 2).map(|b| u32::from(u16::from_le_bytes([b[0], b[1]])));
|
||||
let le32 = |at: usize| data.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
if !is_bmp(data) {
|
||||
return None;
|
||||
}
|
||||
let (declared, info_size) = (le32(10)?, le32(14)?);
|
||||
let headers_end = info_size.checked_add(14)?;
|
||||
// At the end of the headers is right only when no masks or colour table follow.
|
||||
if declared > headers_end {
|
||||
return None;
|
||||
}
|
||||
let (bpp, masks, used, entry) = if info_size == 12 {
|
||||
// BITMAPCOREHEADER: 16-bit fields, no colour count, 3-byte table entries.
|
||||
(le16(24)?, 0, 0, 3)
|
||||
} else if info_size < 40 {
|
||||
// OS/2's 16-byte header ends before the colour count; leave it to the decoder.
|
||||
return None;
|
||||
} else {
|
||||
// BI_BITFIELDS and BI_ALPHABITFIELDS put their masks after a 40-byte header; the larger
|
||||
// headers hold them inside.
|
||||
let masks = match (info_size, le32(30)?) {
|
||||
(40, 3) => 12,
|
||||
(40, 6) => 16,
|
||||
_ => 0,
|
||||
};
|
||||
(le16(28)?, masks, le32(46)?, 4)
|
||||
};
|
||||
let entries = match (used, bpp) {
|
||||
(0, 1..=8) => 1 << bpp,
|
||||
(0, _) => 0,
|
||||
(n, _) => n,
|
||||
};
|
||||
let offset = entries.checked_mul(entry)?.checked_add(masks)?.checked_add(headers_end)?;
|
||||
(offset != declared && usize::try_from(offset).ok()? <= data.len()).then_some(offset)
|
||||
}
|
||||
|
||||
/// The pixel size, from the header alone; `None` when it can't be read or the image is bigger
|
||||
/// than can be decoded (so layout gives such a picture its box, not a page-high strip).
|
||||
pub fn size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
@@ -70,7 +126,7 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
(w > 0 && h > 0).then_some((w, h))
|
||||
}
|
||||
// The decoders read only the headers (TIFF: the first IFD) until asked for pixels.
|
||||
Kind::Bmp => reader(data, image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
|
||||
Kind::Bmp => reader(&bmp_with_pixel_offset(data), image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
|
||||
Kind::Tiff => {
|
||||
use image::{ImageDecoder, metadata::Orientation};
|
||||
let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder().ok()?;
|
||||
@@ -174,7 +230,8 @@ pub fn decode(data: &[u8]) -> Result<Decoded, String> {
|
||||
Kind::Bmp => image::ImageFormat::Bmp,
|
||||
_ => image::ImageFormat::Tiff,
|
||||
};
|
||||
let img = if k == Kind::Tiff { decode_tiff(data) } else { reader(data, format).decode() }
|
||||
let data = if k == Kind::Bmp { bmp_with_pixel_offset(data) } else { std::borrow::Cow::Borrowed(data) };
|
||||
let img = if k == Kind::Tiff { decode_tiff(&data) } else { reader(&data, format).decode() }
|
||||
.map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
|
||||
let rgba = img.into_rgba8();
|
||||
let (width, height) = rgba.dimensions();
|
||||
|
||||
@@ -1801,6 +1801,62 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte
|
||||
assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000));
|
||||
}
|
||||
|
||||
/// #4937: LiveCycle wrote BMPs whose header gives the pixel data's offset as 0. Read literally,
|
||||
/// the pixels start at the file header, so the image is drawn shifted and wrapped around.
|
||||
#[test]
|
||||
fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() {
|
||||
use crate::image::{bmp_with_pixel_offset, decode, size};
|
||||
for palette in [false, true] {
|
||||
let intact = tiny_bmp(palette);
|
||||
assert!(matches!(bmp_with_pixel_offset(&intact), std::borrow::Cow::Borrowed(_)), "an intact file is not copied");
|
||||
for bad in [0u32, 14, 53] {
|
||||
let mut bmp = intact.clone();
|
||||
bmp[10..14].copy_from_slice(&bad.to_le_bytes());
|
||||
let d = decode(&bmp).unwrap();
|
||||
assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "offset {bad}, palette: {palette}");
|
||||
assert_eq!(size(&bmp), Some((2, 1)));
|
||||
assert_eq!(bmp_with_pixel_offset(&bmp).as_ref(), &intact[..], "offset {bad}, palette: {palette}");
|
||||
}
|
||||
}
|
||||
// Too short to hold the pixels after the headers: left alone, not grown.
|
||||
let mut cut = tiny_bmp(true);
|
||||
cut[10..14].copy_from_slice(&0u32.to_le_bytes());
|
||||
cut.truncate(60);
|
||||
assert_eq!(bmp_with_pixel_offset(&cut).as_ref(), &cut[..]);
|
||||
// A truncated header, and bytes that are not a BMP: left alone.
|
||||
assert_eq!(bmp_with_pixel_offset(b"BM | ||||