Merge upstream/main into fix/xfa-open-scripts-complete

Main's DataOp::Value now replaces a multi-select list's value-group picks in the
packet and in the data copy, so the reset writes through it and the separate
DataOp::Reset (and Want::picks) is dropped. The reset keeps the PR's indexed
targets and its pass over fields without widgets, beside main's floating-field
pass (which the unshown pass leaves embedded fields to), and finish() lays out
again for either an owed relayout or a changed embedded value. Parity notes and
evidence take both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-09 10:39:06 +08:00
commit f996aef12f
654 files changed
+99220 -5792

No files matched your search

+4 -1
View File
@@ -45,7 +45,10 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
(cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK`
background and border, list-box selection.
The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined
Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold
Unicode CMap; otherwise Helvetica (WinAnsi). Thai in a text field or combo box that the font
can't hold is drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`), beside the field's font, keeping `/DA`; a build without a Thai
face refuses it. Any other value or button caption the font can't hold
(Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat
keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is
marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn)
+99 -11
View File
@@ -97,21 +97,13 @@ impl FieldAction {
}
}
fn texts(doc: &Document, o: &Object) -> Vec<String> {
match &*doc.resolve(o) {
Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(),
Object::String(s) => vec![s.to_text()],
_ => Vec::new(),
}
}
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
let a = doc.resolve(o);
let d = a.as_dict()?;
Some(match d.name(b"S")? {
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()),
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| crate::read::action_fields(doc, f)).unwrap_or_default()),
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
b"GoTo" => {
let dest = d.get(b"D").map(|x| doc.resolve(x))?;
@@ -119,8 +111,8 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu
FieldAction::GoTo(pages.iter().position(|p| *p == target)?)
}
b"Hide" => FieldAction::ShowHide {
fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(),
hide: !matches!(d.get(b"H"), Some(Object::Bool(false))),
fields: d.get(b"T").map(|t| crate::read::action_fields(doc, t)).unwrap_or_default(),
hide: !d.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))),
},
b"SubmitForm" => FieldAction::Submit(
d.get(b"F")
@@ -344,3 +336,99 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reset_and_hide_actions_resolve_hierarchical_widget_and_indirect_field_targets() {
let mut doc = Document::new_empty();
let rect = || Object::Array(vec![0.into(), 0.into(), 50.into(), 20.into()]);
// 1. Hierarchical child field `address.city` (merged field + widget).
let address = doc.add(Object::Null);
let mut city = Dict::new();
city.set(b"Parent".to_vec(), Object::Ref(address));
city.set(b"FT".to_vec(), Object::name("Tx"));
city.set(b"T".to_vec(), PdfString::text("city"));
city.set(b"Subtype".to_vec(), Object::name("Widget"));
city.set(b"Rect".to_vec(), rect());
let city = doc.add(Object::Dict(city));
let mut addr_dict = Dict::new();
addr_dict.set(b"T".to_vec(), PdfString::text("address"));
addr_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(city)]));
doc.set(address, Object::Dict(addr_dict));
// 2. Separated field `phone` whose widget dictionary has `/Parent` instead of `/T`.
let phone = doc.add(Object::Null);
let mut phone_widget = Dict::new();
phone_widget.set(b"Subtype".to_vec(), Object::name("Widget"));
phone_widget.set(b"Parent".to_vec(), Object::Ref(phone));
phone_widget.set(b"Rect".to_vec(), rect());
let phone_widget = doc.add(Object::Dict(phone_widget));
let mut phone_dict = Dict::new();
phone_dict.set(b"FT".to_vec(), Object::name("Tx"));
phone_dict.set(b"T".to_vec(), PdfString::text("phone"));
phone_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(phone_widget)]));
doc.set(phone, Object::Dict(phone_dict));
// 3. Indirect string target `"email"`.
let email_str = doc.add(Object::String(PdfString::text("email")));
let targets = Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(email_str)]);
let mut reset_action = Dict::new();
reset_action.set(b"S".to_vec(), Object::name("ResetForm"));
reset_action.set(b"Fields".to_vec(), targets.clone());
let reset_action = doc.add(Object::Dict(reset_action));
let indirect_false = doc.add(Object::Bool(false));
let mut hide_action = Dict::new();
hide_action.set(b"S".to_vec(), Object::name("Hide"));
hide_action.set(b"T".to_vec(), targets);
hide_action.set(b"H".to_vec(), Object::Ref(indirect_false));
let hide_action = doc.add(Object::Dict(hide_action));
let mut aa = Dict::new();
aa.set(b"D".to_vec(), Object::Ref(hide_action));
let mut btn = Dict::new();
btn.set(b"FT".to_vec(), Object::name("Btn"));
btn.set(b"Ff".to_vec(), Object::Int(1 << 16));
btn.set(b"T".to_vec(), PdfString::text("btn"));
btn.set(b"Subtype".to_vec(), Object::name("Widget"));
btn.set(b"Rect".to_vec(), rect());
btn.set(b"A".to_vec(), Object::Ref(reset_action));
btn.set(b"AA".to_vec(), Object::Dict(aa));
let btn = doc.add(Object::Dict(btn));
let root = doc.root().unwrap();
let pages_ref = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).unwrap();
let mut page_dict = Dict::new();
page_dict.set(b"Type".to_vec(), Object::name("Page"));
page_dict.set(b"Parent".to_vec(), Object::Ref(pages_ref));
page_dict.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
page_dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(btn)]));
let page = doc.add(Object::Dict(page_dict));
doc.update_dict(pages_ref, |p| {
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
p.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
let mut form = Dict::new();
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(address), Object::Ref(phone), Object::Ref(btn)]));
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
let expected = vec!["address.city".to_string(), "phone".to_string(), "email".to_string()];
let btn_field = fields(&doc).into_iter().find(|f| f.name == "btn").unwrap();
assert_eq!(btn_field.button, Some(crate::af::ButtonAction::Reset { fields: expected.clone(), exclude: false }));
let acts = field_actions(&doc, "btn").unwrap();
assert_eq!(
acts,
vec![
(Trigger::MouseUp, FieldAction::Reset(expected.clone())),
(Trigger::MouseDown, FieldAction::ShowHide { fields: expected, hide: false }),
]
);
}
}
+4 -1
View File
@@ -20,7 +20,10 @@ mod script;
#[cfg(test)]
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
pub use format::{
DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in,
js_number, keystroke, make_number, parse_date, set_date_names, validate,
};
use script::{Arg, call};
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
+4
View File
@@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<
let num = |s: &String| make_number(s).unwrap_or(0.0);
match c {
Calculate::None => None,
// A product of no values is 0, as an empty sum, average, minimum or maximum is, not the
// empty product 1: a blank quantity × price row reads 0, and a total over the rows
// doesn't count it as 1. (The form's `values` leaves out a field that has no value.)
Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()),
Calculate::Simple { op, fields } => {
let xs: Vec<f64> = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect();
let v = match op {
+191 -11
View File
@@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as
/// a localized Acrobat writes them in its own language (#5303).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateNames {
pub months: [&'static str; 12],
pub months_short: [&'static str; 12],
pub days: [&'static str; 7],
pub days_short: [&'static str; 7],
}
/// English, the names until the app sets others.
pub const ENGLISH_DATE_NAMES: DateNames = DateNames {
months: MONTHS,
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
days: DAYS,
days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
};
static DATE_NAMES: std::sync::RwLock<DateNames> = std::sync::RwLock::new(ENGLISH_DATE_NAMES);
/// Sets the names date formats write from now on (the app's date language); typed dates are read
/// in them and in English.
pub fn set_date_names(names: DateNames) {
*DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names;
}
/// The names date formats write.
pub fn date_names() -> DateNames {
*DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// `text` with the month and weekday names of `lists` (in the order months, short months,
/// weekdays, short weekdays) written as their English names, so a parser that reads English
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
/// localized one does.
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
// No date is this long: hostile text is left as it is rather than scanned name by name.
if text.len() > MAX_DATE_TEXT_BYTES {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short];
// Each name as folded chars, whether it is a month, and its English name.
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
for (name, english) in list.iter().zip(en.iter()) {
// A short name ending in a dot ("mar.") is also read typed without it, as the same
// kind of name, so that where it equals another ("Mar", March) the format decides.
let bare = name.trim_end_matches('.');
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
let n: Vec<String> = name.chars().map(fold).collect();
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
names.push((n, k % 4 < 2, english));
}
}
}
}
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
// The format's name fields in order: true for a month, false for a weekday.
let f: Vec<char> = fmt.chars().collect();
let mut expected = Vec::new();
let mut j = 0;
while let Some(&c) = f.get(j) {
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
if run >= 3 && (c == 'm' || c == 'd') {
expected.push(c == 'm');
}
j = j.saturating_add(run);
}
let mut slot = 0;
// A name's first letter may not continue a word; its last may not run on into a letter, nor
// (a digit) into a number.
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
let mut out = String::with_capacity(text.len());
let mut i = 0;
while let Some(&c) = chars.get(i) {
let fits = |n: &Vec<String>| {
let end = i.saturating_add(n.len());
let first = n.first().and_then(|e| e.chars().next());
let last = n.last().and_then(|e| e.chars().next());
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
&& !match last {
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
_ => false,
}
};
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
let hit = longest.and_then(|len| {
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
let want = expected.get(slot).copied();
let all: Vec<_> = same.by_ref().collect();
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
});
match hit {
Some((n, month, english)) => {
if expected.get(slot) == Some(month) {
slot += 1;
}
// Apart from what touches it ("10月18"), but no space added at either end.
if out.ends_with(char::is_alphanumeric) {
out.push(' ');
}
out.push_str(english);
i = i.saturating_add(n.len());
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
out.push(' ');
}
}
None => {
out.push(c);
i += 1;
}
}
}
out
}
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
/// positions in the text stay aligned.
fn fold(c: char) -> String {
match c {
'I' | 'ı' | 'İ' => "i".to_string(),
'ς' => "σ".to_string(),
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
}
}
/// `s` [`fold`]ed char by char.
fn folded(s: &str) -> String {
s.chars().map(fold).collect()
}
/// Typed dates longer than this are not searched for names.
const MAX_DATE_TEXT_BYTES: usize = 1024;
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
const MAX_NAME_CHARS: usize = 32;
/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name,
/// or its whole short name, in `names` or in English.
fn month_named(word: &str, names: &DateNames) -> Option<u32> {
let w = folded(word);
let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w));
let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w);
starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1)
}
/// A date and time read from text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateTime {
@@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec<Part> {
/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any
/// separators, month names, two-digit years). `None` when it isn't a valid date.
pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
let t = text.trim();
let names = date_names();
let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short];
// Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a
// name the language shares between a weekday and a month (Italian "mar") is the month, as
// the names are read again with no weekday expected (#5303).
read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| {
let no_weekday = fmt.replace("dddd", " ").replace("ddd", " ");
(no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten()
})
}
/// [`parse_date`] of `english`, typed text whose month and weekday names are English.
fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option<DateTime> {
let t = english.trim();
if t.is_empty() {
return None;
}
@@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
}
if cur.starts_with(|c: char| c.is_ascii_digit()) {
nums.push_back(std::mem::take(cur));
} else if cur.len() >= 3
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
{
*month = Some(i as u32 + 1);
} else if let Some(m) = month_named(cur, names) {
*month = Some(m);
}
cur.clear();
};
@@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
/// Format a date/time with Acrobat's date format letters.
pub fn format_date(dt: DateTime, fmt: &str) -> String {
format_date_in(dt, fmt, &date_names())
}
/// [`format_date`] with month and weekday names from `names`.
pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String {
let chars: Vec<char> = fmt.chars().collect();
let mut out = String::new();
let mut i = 0;
@@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
('y', _) => {
let _ = write!(out, "{:02}", dt.y.rem_euclid(100));
}
('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) {
Some(name) if n >= 4 => out.push_str(name),
Some(name) => out.push_str(name.get(..3).unwrap_or(name)),
('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) {
Some((name, _)) if n >= 4 => out.push_str(name),
Some((_, short)) => out.push_str(short),
None => {
let _ = write!(out, "{}", dt.m);
}
@@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
let _ = write!(out, "{}", dt.m);
}
('d', 3..) => {
let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default();
out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) });
let list = if n >= 4 { &names.days } else { &names.days_short };
out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default());
}
('d', 2) => {
let _ = write!(out, "{:02}", dt.d);
@@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option<String> {
/// The text shown for a value (the Format event).
pub fn format_value(f: &Format, value: &str) -> String {
format_value_in(f, value, &date_names())
}
/// [`format_value`] with month and weekday names from `names`.
pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String {
if value.is_empty() {
return String::new();
}
@@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String {
),
None => value.to_string(),
},
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)),
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)),
Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()),
Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()),
}
+29
View File
@@ -360,6 +360,35 @@ fn dates_times_and_masks() {
assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err());
}
#[test]
fn a_calculation_over_fields_without_values_is_0_for_every_operation() {
// A blank row: neither field has a value, so the form's lookup returns none.
let vals = |name: &str| -> Vec<String> { if name == "a" { vec!["2".into()] } else { vec![] } };
let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals);
for op in CalcOp::ALL {
assert_eq!(simple(op, &["qty", "price"]), Some("0".into()), "{op:?} of empty fields");
assert_eq!(simple(op, &[]), Some("0".into()), "{op:?} of no fields");
}
assert_eq!(simple(CalcOp::Product, &["a", "qty"]), Some("2".into()), "a field with no value is left out");
}
/// A drop-down's blank option is often a single space, the value it starts with. That is a value
/// worth 0, so a row whose quantity is still blank reads 0 × price = 0. Read as "no value", it
/// would be left out and the row would read the price alone.
#[test]
fn a_blank_option_of_a_single_space_is_a_value_worth_0() {
let vals = |name: &str| -> Vec<String> {
match name {
"qty" => vec![" ".into()],
"price" => vec!["605".into()],
_ => vec![],
}
};
let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals);
assert_eq!(simple(CalcOp::Product, &["qty", "price"]), Some("0".into()));
assert_eq!(simple(CalcOp::Product, &["qty", "none"]), Some("0".into()), "a blank quantity on a row without a price");
}
#[test]
fn validation_and_calculation() {
let r = Validate::Range { min: Some(0.0), max: Some(100.0) };
+111 -31
View File
@@ -23,6 +23,7 @@
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::shaped_text::{self, Piece, ShapedFonts};
use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with};
use crate::{Field, FieldKind, Widget, acroform, flags};
@@ -398,9 +399,17 @@ fn helvetica() -> Object {
Object::Dict(f)
}
/// An `/MK /BC` or `/BG` colour (ISO 32000-2 Table 192) as a fill operator. A four-entry array
/// with a non-number in it, such as `[1 0 0 null]`, is CMYK with that entry 0, as annotation `/C`
/// colours are read.
fn color_array(doc: &Document, mk: Option<&Dict>, key: &[u8]) -> Option<String> {
let a = doc.resolve(mk?.get(key)?);
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
let a = a.as_array()?;
let v: Vec<f64> = if a.len() == 4 {
a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0)).collect()
} else {
a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()
};
match v.as_slice() {
[g] => Some(format!("{} g", n(*g))),
[r, g, b] => Some(format!("{} {} {} rg", n(*r), n(*g), n(*b))),
@@ -739,8 +748,33 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option<Overflow>
field_appearance_fit(doc, f, w, &f.value, true).2
}
/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that
/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in.
fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool {
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi);
enc.first_unencodable(text).is_none()
}
/// Why text field `f` can't take `text`: it has Thai that a widget's font can't hold and this
/// build can't draw (it has no Thai face, or the face lacks a character), which would come out
/// as `?`. `None` for text it can draw, and for a password field, which shows `*`.
pub(crate) fn thai_refusal(doc: &Document, f: &Field, text: &str) -> Option<String> {
if f.has(flags::PASSWORD) || !text.chars().any(shaped_text::is_thai) {
return None;
}
let held = |w: &Widget| {
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
dr_font(doc, &da.font).is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && text.split(['\n', '\r']).all(|l| enc.can_encode(l)))
};
if f.widgets.iter().all(held) { None } else { shaped_text::thai_refusal(text) }
}
/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box.
fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>, Option<Overflow>) {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
let values = if format
@@ -748,13 +782,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
&& values.len() == 1
&& f.actions.format != crate::af::Format::None
{
formatted = vec![crate::af::format_value(&f.actions.format, &values[0])];
let shown = crate::af::format_value(&f.actions.format, &values[0]);
// Month and weekday names in the date language that no font here can draw ("окт." in
// Helvetica) are drawn in English rather than as `?` (#5303).
let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES);
let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_));
formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }];
&formatted[..]
} else {
values
};
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
@@ -765,36 +802,43 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
values.first().cloned().unwrap_or_default()
};
let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text };
let can_show = |enc: &FontText| {
let can_show = |enc: &FontText, text: &str| {
if f.kind == FieldKind::List {
f.options.iter().all(|(_, display)| enc.can_encode(display))
} else if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
text.split(['\n', '\r']).all(|line| enc.can_encode(line))
} else {
enc.can_encode(&text)
enc.can_encode(text)
}
};
let (font_name, font_obj, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| can_show(enc)) {
let dr = dr_font(doc, &da.font);
// Thai the form's font can't hold (it isn't a simple font encoding it) is drawn from the Thai
// face, as Add text draws it: Type 3 glyphs beside the form's font, which draws the rest. One
// line of a text field or combo box at a time; the `/DA` is kept.
let thai = matches!(f.kind, FieldKind::Text | FieldKind::Combo)
&& !f.is_comb()
&& text.chars().any(shaped_text::is_thai)
&& !dr.as_ref().is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && can_show(enc, &text))
&& shaped_text::thai_refusal(&text).is_none();
let latin_text: String = if thai { text.chars().filter(|c| !shaped_text::is_thai(*c)).collect() } else { text.clone() };
let (font_name, font_obj, enc) = match dr.filter(|(_, enc)| can_show(enc, &latin_text)) {
Some((o, enc)) => (da.font.clone(), o, enc),
None => ("Helv".to_string(), helvetica(), FontText::WinAnsi),
};
let width_of = |text: &str, size: f64| enc.width(text, size);
let width_of =
|text: &str, size: f64| if thai { shaped_text::thai_width(text, size, |s, size| enc.width(s, size)) } else { enc.width(text, size) };
// How far a line's ink reaches below and above its baseline (em), where Thai stacks or
// descenders reach past the form font's (Helvetica's) -0.207 and 0.718; `None` otherwise.
let thai_ink = |line: &str| thai.then(|| shaped_text::thai_reach(line)).flatten().map(|[lo, hi]| [lo.min(-0.207), hi.max(0.718)]);
let (mut c, bw) = frame(doc, &wd, width, height);
let pad = 2.0 + bw;
let inner_w = (width - 2.0 * pad).max(1.0);
let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding);
let mut body: Vec<u8> = Vec::new();
// The first character drawn that the font can't hold (drawn as `?`), for the caller to report.
let mut unencodable: Option<char> = None;
let mut show = |body: &mut Vec<u8>, x: f64, y: f64, text: &str| {
// The lines to draw (where, and what), drawn once all are known: their Thai shares fonts.
let mut shown: Vec<(f64, f64, String)> = Vec::new();
let mut show = |x: f64, y: f64, text: &str| {
// A line break in a single-line value is a space, as in viewers (multiline text is split).
let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect();
if unencodable.is_none() {
unencodable = enc.first_unencodable(&text);
}
body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
body.extend(literal(&enc.encode(&text)));
body.extend_from_slice(b" Tj\n");
shown.push((x, y, text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect()));
};
let x_for = |text: &str, size: f64| -> f64 {
let tw = width_of(text, size);
@@ -821,7 +865,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
if values.contains(export) {
c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line)));
}
show(&mut body, pad, y - line + size * 0.25, display);
show(pad, y - line + size * 0.25, display);
y -= line;
}
}
@@ -831,18 +875,20 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
if size == 0.0 {
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
}
let mut y = height - pad - size * 0.85;
let lines = wrap_with(&text, size, inner_w, width_of);
// The first line hangs from the top by its ascent: higher for stacked Thai marks.
let mut y = height - pad - size * lines.first().and_then(|l| thai_ink(l)).map_or(0.85, |[_, hi]| hi.max(0.85));
// The lines whose descent stays inside the border; the rest are cut off.
let mut room = 0;
for line in &lines {
if y < -size {
break;
}
if y - 0.207 * size >= bw - 0.01 {
let descent = thai_ink(line).map_or(0.207, |[lo, _]| -lo);
if y - descent * size >= bw - 0.01 {
room += 1;
}
show(&mut body, x_for(line, size), y, line);
show(x_for(line, size), y, line);
y -= size * 1.15;
}
if room < lines.len() {
@@ -859,12 +905,22 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
};
}
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically.
let y = (height - 0.925 * size) / 2.0 + 0.207 * size;
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically, or the reach of
// Thai stacks and descenders, which an automatic size also keeps inside the padding.
let reach = thai_ink(&text);
if let Some([lo, hi]) = reach
&& da.size == 0.0
{
size = size.min((height - 2.0 * pad) / (hi - lo)).max(4.0);
}
let (y, ink) = match reach {
Some([lo, hi]) => ((height - (hi - lo) * size) / 2.0 - lo * size, (hi - lo) * size),
None => ((height - 0.925 * size) / 2.0 + 0.207 * size, 0.925 * size),
};
// The clip box is inside the border; a line taller than it is cut at the top and bottom.
let room_h = height - 2.0 * bw;
if 0.925 * size > room_h + 0.01 {
overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size });
if ink > room_h + 0.01 {
overflow = Some(Overflow::High { height: ink, room: room_h, size });
}
if comb {
let cells = f.max_len.unwrap_or(1).max(1);
@@ -872,18 +928,19 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
c.push_str(&comb_dividers(doc, &wd, width, height, cells));
for (i, ch) in text.chars().take(cells).enumerate() {
let s = ch.to_string();
show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
show(cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
}
} else {
let tw = width_of(&text.replace(['\n', '\r'], " "), size);
if overflow.is_none() && tw > inner_w + 0.01 {
overflow = Some(Overflow::Wide { width: tw, room: inner_w, size });
}
show(&mut body, x_for(&text, size), y, &text);
show(x_for(&text, size), y, &text);
}
}
}
}
let (body, fonts, unencodable) = draw_lines(&shown, size, &font_name, font_obj, &enc, thai);
// Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus
// changes), discarding what came before it (issues #1613, #633). The background and border
// therefore go inside the marked-content block too, so a redraw keeps them with the text.
@@ -900,8 +957,6 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
.into_bytes();
content.extend(body);
content.extend_from_slice(b"ET\nQ\nEMC\n");
let mut fonts = Dict::new();
fonts.set(font_name.into_bytes(), font_obj);
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut d = Dict::new();
@@ -914,6 +969,31 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
(Stream::flate(d, &content), unencodable, overflow)
}
/// The text operators for the `shown` lines (where each starts, and what it says) at `size`, in
/// the field's font `font_name` (`font`, written with `enc`) and, with `thai`, their Thai from the
/// Thai face; the font resources they use, and the first character drawn as `?` because the
/// font can't hold it, for the caller to report.
fn draw_lines(shown: &[(f64, f64, String)], size: f64, font_name: &str, font: Object, enc: &FontText, thai: bool) -> (Vec<u8>, Dict, Option<char>) {
let mut fonts = Dict::new();
fonts.set(font_name.as_bytes().to_vec(), font);
let lines: Vec<String> = shown.iter().map(|(_, _, text)| text.clone()).collect();
let (pieces, shaped) = if thai {
shaped_text::appearance_lines(&lines, &mut fonts)
} else {
(lines.into_iter().map(|l| vec![Piece::Latin(l)]).collect(), ShapedFonts::default())
};
let mut body = Vec::new();
let mut current = None;
for ((x, y, _), pieces) in shown.iter().zip(&pieces) {
shaped.show_line(&mut body, pieces, *x, *y, size, font_name, |s| enc.encode(s), |s| enc.width(s, size), &mut current);
}
let unencodable = pieces.iter().flatten().find_map(|p| match p {
Piece::Latin(s) => enc.first_unencodable(s),
Piece::Shaped(_) => None,
});
(body, fonts, unencodable)
}
/// On/Off appearances for a check box or radio button that has none (a check mark or a dot,
/// drawn as paths, so no symbol font is needed).
/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the
+3
View File
@@ -75,6 +75,9 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
}
};
let target = copy.get_or_insert_with(|| doc.clone());
let mut stream = stream;
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(target, &mut stream.dict);
let ap = target.add(Object::Stream(stream));
let mut fresh = Dict::new();
match &state {
+3
View File
@@ -234,6 +234,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
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");
let parent_limit = ancestor_sets(doc, f.obj, b"MaxLen");
doc.update_dict(f.obj, |d| {
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
if let Some(t) = &props.tooltip {
@@ -246,6 +247,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(m) = props.max_len {
match m {
Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)),
// An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`).
None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)),
None => {
d.remove(b"MaxLen");
}
+3 -1
View File
@@ -323,8 +323,10 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
missing = m;
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict);
let r = doc.add(Object::Stream(s));
let mut d = Dict::new();
d.set(b"N".to_vec(), Object::Ref(r));
+79 -36
View File
@@ -20,17 +20,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec
let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v)));
Some(match a.name(b"S")? {
b"ResetForm" => {
let fields = a
.get(b"Fields")
.map(|f| doc.resolve(f))
.and_then(|f| f.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|o| match o {
Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))),
other => text_of(&doc.resolve(other)),
})
.collect();
let fields = a.get(b"Fields").map(|f| action_fields(doc, f)).unwrap_or_default();
let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0;
af::ButtonAction::Reset { fields, exclude }
}
@@ -48,18 +38,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec
af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?)
}
b"Hide" => {
// /T: a field name, an annotation (or field) reference, or an array of them.
let target = |o: &Object| match o {
Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))),
other => text_of(other),
};
let fields = match a.get(b"T") {
Some(t) => match &*doc.resolve(t) {
Object::Array(items) => items.iter().filter_map(target).collect(),
_ => target(t).into_iter().collect(),
},
None => Vec::new(),
};
let fields = a.get(b"T").map(|t| action_fields(doc, t)).unwrap_or_default();
let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false)));
af::ButtonAction::ShowHide { fields, hide }
}
@@ -91,6 +70,19 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec
})
}
/// The field names targeted by a `ResetForm` (`/Fields`) or `Hide` (`/T`) action entry: a field
/// name string, an indirect field or widget dictionary reference, or an array of them.
pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec<String> {
let target = |item: &Object| match item {
Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(item))),
other => text_of(other),
};
match &*doc.resolve(o) {
Object::Array(items) => items.iter().filter_map(target).collect(),
_ => target(o).into_iter().collect(),
}
}
/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to,
/// from the partial names (`/T`) up its `/Parent` chain.
fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
@@ -106,12 +98,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) {
parts.push(t);
}
cur = d.get(b"Parent").and_then(|p| p.as_ref());
cur = parent_of(doc, d);
}
parts.reverse();
(!parts.is_empty()).then(|| parts.join("."))
}
/// The field `d` names as its `/Parent`, when that is a dictionary. A reference to an object the
/// file doesn't hold reads as `null`, and a `null` entry is the same as an absent one (ISO
/// 32000-2 §7.3.9): the field is then a root, as if it named no parent.
fn parent_of(doc: &Document, d: &Dict) -> Option<ObjRef> {
d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some())
}
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
/// in all, however many actions name the same stream.
pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20;
@@ -331,13 +330,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
let af = acroform(doc);
let mut page_of = std::collections::HashMap::new();
let mut annot_index = std::collections::HashMap::new();
let mut by_parent: std::collections::HashMap<ObjRef, Vec<ObjRef>> = std::collections::HashMap::new();
let pages = page_refs(doc);
for (i, p) in pages.iter().enumerate() {
if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) {
for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() {
if let Some(r) = e.as_ref() {
page_of.entry(r).or_insert(i);
annot_index.entry(r).or_insert(k);
if let Some(r) = e.as_ref()
&& let std::collections::hash_map::Entry::Vacant(slot) = page_of.entry(r)
{
slot.insert(i);
annot_index.insert(r, k);
if let Some(parent) = widget_parent(doc, r) {
by_parent.entry(parent).or_default().push(r);
}
}
}
}
@@ -351,18 +356,24 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let listed_roots: Vec<ObjRef> = af
.as_ref()
.and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()))
.unwrap_or_default()
.iter()
.filter_map(Object::as_ref)
.collect();
let mut seen = Seen {
fields: std::collections::HashSet::new(),
widgets: std::collections::HashSet::new(),
roots: listed_roots.iter().copied().collect(),
path: Vec::new(),
by_parent,
scripts: script_budget(),
values: DecodeBudget::new(MAX_VALUE_BYTES, 0),
};
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
}
}
for r in listed_roots {
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
}
let listed = out.len();
adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out);
@@ -413,7 +424,7 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<Obj
let mut top = r;
let mut visited = std::collections::HashSet::from([r]);
loop {
let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent"));
let parent = doc.get(top).as_dict().and_then(|d| parent_of(doc, d));
let Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
@@ -433,10 +444,36 @@ struct Seen {
fields: std::collections::HashSet<ObjRef>,
/// A widget belongs to the first field that lists it, however often it is listed.
widgets: std::collections::HashSet<ObjRef>,
/// The fields `/Fields` lists: root fields, with no ancestors (ISO 32000-2 §12.7.3, Table 224).
roots: std::collections::HashSet<ObjRef>,
/// The fields being walked, outermost first, whose `/Kids` are being read.
path: Vec<ObjRef>,
/// The page widgets (no `/T`) that name each field as their `/Parent`, in page-annotation order.
by_parent: std::collections::HashMap<ObjRef, Vec<ObjRef>>,
scripts: DecodeBudget,
values: DecodeBudget,
}
/// The field a page widget `r` names as its `/Parent`, when `r` is a widget and not a field of its
/// own (no `/T`).
fn widget_parent(doc: &Document, r: ObjRef) -> Option<ObjRef> {
let obj = doc.get(r);
let d = obj.as_dict()?;
if d.name(b"Subtype") != Some(b"Widget") || d.contains(b"T") {
return None;
}
d.reference(b"Parent")
}
/// Whether `k`, listed in the `/Kids` of field `r`, is not a child of `r` after all: `r` itself or
/// a field `r` descends from (a loop), or a root field (one `/Fields` lists) that doesn't name `r`
/// as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), and a
/// child names its parent (§12.7.4.1, Table 226): only the `/Kids` entry says otherwise, so the
/// field is read as the root it is, and `r` keeps its other kids.
fn not_a_child(doc: &Document, seen: &Seen, r: ObjRef, k: ObjRef) -> bool {
k == r || seen.path.contains(&k) || (seen.roots.contains(&k) && doc.get(k).as_dict().and_then(|d| d.reference(b"Parent")) != Some(r))
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
fn walk(
@@ -483,11 +520,14 @@ fn walk(
let kids: Vec<ObjRef> =
d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
// Kids with /T are fields; kids without are this field's widgets.
let field_kids: Vec<ObjRef> = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect();
let (field_kids, kids): (Vec<ObjRef>, Vec<ObjRef>) = kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T")));
let field_kids: Vec<ObjRef> = field_kids.into_iter().filter(|k| !not_a_child(doc, seen, r, *k)).collect();
if !field_kids.is_empty() {
seen.path.push(r);
for k in field_kids {
walk(doc, k, &inh, page_of, seen, out, depth + 1);
}
seen.path.pop();
return;
}
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
@@ -503,7 +543,10 @@ fn walk(
Some(b"Sig") => FieldKind::Signature,
_ => return,
};
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
// A child names its parent (ISO 32000-2 §12.7.4.1, Table 226): a page widget naming this field
// is one of its widgets even when its `/Kids` leave it out, as MuPDF and PDFium read it.
let widget_refs: Vec<ObjRef> =
if d.contains(b"Rect") { vec![r] } else { kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() };
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
+82 -21
View File
@@ -42,6 +42,8 @@ pub struct FieldChange {
pub required: Option<bool>,
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
pub display: Option<i32>,
/// Move/resize the first widget, in PDF user space.
pub rect: Option<[f64; 4]>,
}
/// What a script did.
@@ -51,6 +53,8 @@ pub struct ScriptResult {
pub rc: bool,
/// `event.value` afterwards.
pub value: String,
/// Whether the script assigned `event.value`, even the value it already had.
pub value_set: bool,
pub changes: Vec<FieldChange>,
/// Why it rejected the value (its alert), for the error shown.
pub message: Option<String>,
@@ -60,6 +64,21 @@ pub struct ScriptResult {
pub trait Scripts {
/// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form.
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult;
/// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`)
/// because no font of the field can draw the date language's (#5303); what it logs was
/// already logged by the first run. `None` when it can't: no English dates here, or the
/// script failed. What it returns is what the script made, even when that is the value.
fn rerun_with_english_dates(
&mut self,
_event: FieldEvent,
_script: &str,
_target: &Field,
_value: &str,
_fields: &[Field],
) -> Option<ScriptResult> {
None
}
}
/// No script engine (or JavaScript turned off): scripts change nothing.
@@ -67,7 +86,7 @@ pub struct NoScripts;
impl Scripts for NoScripts {
fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None }
}
}
@@ -98,6 +117,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy
Some(r.value)
}
/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date
/// language's; `None` when the field has no Format script or the English run didn't happen.
pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
let js = f.actions.scripts.format.as_ref()?;
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value)
}
/// Apply script changes to other fields (values written without re-running their scripts;
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
/// the event sets.
@@ -151,6 +177,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except:
}
}
}
if let Some(rect) = c.rect {
crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?;
}
}
Ok(())
}
@@ -169,6 +198,9 @@ pub fn document_scripts(doc: &Document) -> Vec<String> {
document_scripts_named(doc).into_iter().map(|(_, js)| js).collect()
}
/// Most `/Kids` entries the document JavaScript name tree walk reads in all.
const MAX_QUEUED_KIDS: usize = 1 << 20;
/// [`document_scripts`] with their names.
pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(root) = doc.root() else { return Vec::new() };
@@ -177,43 +209,72 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() };
let mut out: Vec<(String, String)> = Vec::new();
let mut scripts = crate::script_budget();
// Kids stay unresolved on the stack, and each node is read once: a node listed many times,
// or among its own kids, must not multiply the work.
// Kids are indirect references (ISO 32000-2 §7.9.6), queued unresolved, each the first time
// it is listed: a node listed many times, or among its own kids, must not multiply the work,
// and the stack holds only references (a direct object there would be copied whole, once
// for every node listing it). Only the root may be direct. Nodes are told apart by object
// number, as `Document::get` finds them, so `5 0 R` and `5 1 R` are one node.
let mut seen: HashSet<u32> = tree.as_ref().map(|r| r.num).into_iter().collect();
// The `/Names` arrays read, by object number: nodes sharing one would otherwise each list
// all its scripts again.
let mut names_read: HashSet<u32> = HashSet::new();
let mut stack = vec![tree];
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut visited = 0;
// Every `/Kids` entry read, over the whole walk. Nodes sharing one long `/Kids` array would
// otherwise read all of it once per node.
let mut queued = 0usize;
while let Some(raw) = stack.pop() {
if let Object::Ref(r) = raw
&& !seen.insert(r)
{
continue;
}
visited += 1;
if visited > 10_000 {
break;
}
let node = doc.resolve(&raw);
let Some(d) = node.as_dict() else { continue };
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
stack.extend(kids);
if let Some(kids) = d.get(b"Kids") {
in_array(doc, kids, |kids| {
// Past the limit nothing more is read, but the nodes already queued still are.
// Pushed in reverse, so the kids are read in the order listed.
let room = MAX_QUEUED_KIDS.saturating_sub(queued);
queued = queued.saturating_add(kids.len().min(room));
let new: Vec<ObjRef> = kids.iter().take(room).filter_map(Object::as_ref).filter(|r| seen.insert(r.num)).collect();
stack.extend(new.into_iter().rev().map(Object::Ref));
});
}
if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
for pair in pairs.chunks(2) {
let [k, v] = pair else { continue };
let key = match &*doc.resolve(k) {
Object::String(s) => s.to_text(),
_ => continue,
};
if let Some(js) = crate::script(doc, v, &mut scripts) {
out.push((key, js));
if let Some(pairs) = d.get(b"Names")
&& pairs.as_ref().is_none_or(|r| names_read.insert(r.num))
{
in_array(doc, pairs, |pairs| {
for pair in pairs.chunks(2) {
let [k, v] = pair else { continue };
let key = match &*doc.resolve(k) {
Object::String(s) => s.to_text(),
_ => continue,
};
if let Some(js) = crate::script(doc, v, &mut scripts) {
out.push((key, js));
}
}
}
});
}
}
out.sort();
out
}
/// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would
/// copy it.
fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) {
match o {
Object::Ref(r) => {
if let Some(a) = doc.get(*r).as_array() {
f(a);
}
}
Object::Array(a) => f(a),
_ => {}
}
}
/// Add (or replace) a document-level JavaScript named `name`.
pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> {
let root = doc.root().ok_or(FormError::NoForm)?;
+424 -3
View File
@@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() {
}
}
/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root
/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3,
/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons.
#[test]
fn kids_that_loop_back_or_are_roots_are_not_children() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3
"<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6
"<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8
"<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7
"<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9
"<< /T (group) /Kids [13 0 R] >>".into(), // 10
"<< /Length 0 >>
stream
endstream"
.into(), // 11
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12
"<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13
"<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent
"<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15
];
let doc = document(&objs);
let all = fields(&doc);
let listed: Vec<(&str, FieldKind, Vec<ObjRef>)> =
all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(
listed,
[
("size", FieldKind::Radio, vec![r(6)]),
("left", FieldKind::Radio, vec![r(9)]),
("right", FieldKind::Radio, vec![r(12)]),
("group.inner.deep", FieldKind::Text, vec![r(15)]),
]
);
}
/// A page widget whose `/Parent` names a field is one of that field's widgets even when the
/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here
/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots,
/// so not children) and none of their widgets. Each field gets every widget naming it, once.
#[test]
fn widgets_naming_a_field_that_does_not_list_them_join_it() {
let w = |parent: u32, state: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>",
y + 15
)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3
w(5, "A", 700), // 4: not in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5
"<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6
w(5, "B", 650), // 7: in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9
"<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
w(8, "C", 600), // 11: in 8's /Kids
w(8, "D", 550), // 12: not in 8's /Kids
w(9, "E", 500), // 13: not in 9's /Kids
];
let doc = document(&objs);
let (all, adopted) = enumerate(&doc);
let listed: Vec<(&str, Vec<ObjRef>)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]);
assert_eq!(adopted, 0, "no widget is read as a field of its own");
let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
assert_eq!(states, ["B", "A"]);
}
/// Some writers leave every field out of `/Fields` and put the widgets only in the page
/// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway.
/// The pages' widgets are adopted as fields, after the listed tree fields.
@@ -353,6 +432,37 @@ fn fields_listed_only_on_the_pages_are_adopted() {
assert_eq!(adopted_page_fields(&doc), 4);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// has no parent: the reference reads as `null`, the same as an absent entry (ISO
/// 32000-2 §7.3.9). It is adopted as a field of its own, under its own name, and can be filled.
#[test]
fn a_widget_whose_parent_is_missing_is_a_field_of_its_own() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary
"<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field
"[1 2 3]".into(), // 9
];
let mut doc = document(&objs);
assert_combined_matches_legacy(&doc, 2);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["city", "zip"]);
let city = field(&all, "city");
assert_eq!(
(city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page),
(FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0))
);
set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap();
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "zip").value, ["0150"]);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
@@ -967,6 +1077,61 @@ fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() {
assert!(need_appearances(&doc));
}
/// Thai typed into a field whose `/DA` font (Helvetica, WinAnsi) can't hold it is drawn from the
/// Thai face as Add text draws it: Type 3 glyphs (stream objects of their own, with a ToUnicode
/// map) beside the field's font, which draws the rest, so every viewer shows the same glyphs. The
/// `/DA` is kept, nothing is marked and other viewers aren't asked to redraw. The ink of stacked
/// marks and descenders stays inside the border, an automatic size shrinking to keep it there.
/// A build without a Thai face refuses the text, saying why, and keeps the old value.
#[test]
fn thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face() {
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 [6 0 R 7 0 R 8 0 R] >>".into(),
"<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 0 Tf 0 g) /V (Ada) /Rect [50 700 250 720] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (big) /DA (/Helv 16 Tf 0 g) /Rect [50 600 350 640] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Ff 4096 /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 500] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
let thai = "ชื่อ: บุญฤทธิ์ น้ำฎู";
if pdfcraft_fonts::document_thai_font().is_none() {
let err = set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap_err();
assert!(matches!(&err, FormError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err}");
assert_eq!(field(&fields(&doc), "name").value, ["Ada"]);
assert!(!need_appearances(&doc) && undrawn_fields(&doc).is_empty());
return;
}
set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "big", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "note", &FieldValue::Text(format!("น้ำฎู {thai}\nปั้น ฎู"))).unwrap();
let doc = reopen(&doc);
assert!(undrawn_fields(&doc).is_empty() && !need_appearances(&doc));
let (lo, hi) = (pdfcraft_fonts::shaped_text::thai_reach("ฎู").unwrap()[0], pdfcraft_fonts::shaped_text::thai_reach("น้ำ").unwrap()[1]);
for (name, height) in [("name", 20.0), ("big", 40.0), ("note", 100.0)] {
let all = fields(&doc);
let w = &field(&all, name).widgets[0];
let wd = doc.get(w.obj).as_dict().unwrap().clone();
assert!(wd.get(b"DA").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().starts_with("/Helv ")), "the /DA is kept");
let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let ap = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(!ap.contains('?') && ap.contains("/PCTh1 ") && ap.contains("(: ) Tj"), "{ap}");
let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).unwrap();
let font = fonts.get(b"PCTh1").and_then(|f| f.as_dict()).unwrap();
assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..]));
let Object::Stream(cmap) = &*doc.get(font.reference(b"ToUnicode").unwrap()) else { panic!("ToUnicode is a stream object") };
assert!(String::from_utf8_lossy(&cmap.decoded().unwrap()).contains("<0E190E490E33>"), "น้ำ copies as typed");
// The size each line is drawn at, and where: every line's ink is inside the box.
let size: f64 = ap.split(" Tf").next().and_then(|s| s.rsplit(' ').next()).and_then(|s| s.parse().ok()).unwrap();
let ys: Vec<f64> = ap.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect();
let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min));
assert!(top + hi * size <= height + 1e-3 && bottom + lo * size >= -1e-3, "{name}: {ys:?} at {size} in {height}");
}
}
/// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids
/// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off.
#[test]
@@ -1519,7 +1684,7 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
struct Ran;
impl Scripts for Ran {
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
@@ -1778,6 +1943,59 @@ fn missing_fields_and_built_names_are_flagged() {
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
}
/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank
/// option, and starting value, is a single space, a prefilled price, the row's product and a total
/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row
/// without a price leaves the product nothing to multiply, and it is 0 too, not 1.
#[test]
fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() {
let tx = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20)
};
let qty = |name: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>",
y + 20
)
};
let prd = |row: u32| {
format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {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 [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
qty("QTY 0", 700),
tx("price 0", 700, "/V (605)"),
tx("Total 0", 700, &format!("/V (0) {}", prd(0))),
qty("QTY 1", 660),
tx("price 1", 660, "/V (780)"),
tx("Total 1", 660, &format!("/V (0) {}", prd(1))),
qty("QTY 2", 620),
tx("price 2", 620, ""),
tx("Total 2", 620, &format!("/V (0) {}", prd(2))),
tx(
"Total",
580,
r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#,
),
];
let mut doc = document(&objs);
let totals = |doc: &Document| {
let all = fields(doc);
["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default())
};
recalculate(&mut doc).unwrap();
assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0");
set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row");
set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price");
let doc = reopen(&doc);
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
@@ -2933,20 +3151,22 @@ fn recalc_chain_where_script_changes_checkbox() {
ScriptResult {
rc: true,
value: "calc1".into(),
value_set: true,
changes: vec![FieldChange {
name: "agree".into(),
value: Some(vec!["Yes".into()]),
read_only: None,
required: None,
display: None,
rect: None,
}],
message: None,
}
} else if script == "read_box" {
let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default();
ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: format!("box_was_{box_val}"), value_set: true, changes: Vec::new(), message: None }
} else {
ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: String::new(), value_set: false, changes: Vec::new(), message: None }
}
}
}
@@ -2967,6 +3187,61 @@ fn recalc_chain_where_script_changes_checkbox() {
assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value");
}
/// A calculation returning a combo option's display text stores the option's export value in /V
/// and selects it in /I, as choosing it does.
#[test]
fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &kind, Some("country")).unwrap();
let obj = field(&fields(&doc), "country").obj;
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "Canada"), pair("fr", "France")]))).unwrap();
set_field_script(&mut doc, "country", "calculate", Some("France")).unwrap();
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 1);
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
assert_eq!(field(&fields(&doc), "country").value, ["fr"]);
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 0, "the display text names the value it already has");
}
/// An export value names its own option even when an earlier option shows that text, whether a
/// calculation or the user sets it.
#[test]
fn an_export_value_wins_over_an_earlier_options_display_text() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
let mut objs = Vec::new();
for (name, y) in [("calculated", 10.0), ("chosen", 40.0)] {
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, y, 100.0, y + 20.0], &kind, Some(name)).unwrap();
let obj = field(&fields(&doc), name).obj;
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "fr"), pair("fr", "France")]))).unwrap();
objs.push(obj);
}
set_field_script(&mut doc, "calculated", "calculate", Some("fr")).unwrap();
recalculate_with(&mut doc, &mut Pick).unwrap();
set_value(&mut doc, "chosen", &FieldValue::Choice(vec!["fr".into()])).unwrap();
for obj in objs {
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
}
}
#[test]
fn partial_appearance_keeps_custom_default_appearance_and_font_resources() {
let mut doc = fixture();
@@ -3334,6 +3609,97 @@ fn document_scripts_read_each_name_tree_node_once() {
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn name_tree_nodes_sharing_one_long_kids_array_queue_it_a_bounded_number_of_times() {
// 10,000 nodes share one /Kids array of 210,001 entries: the leaf, every node, and the leaf
// 200,000 times more. Queuing that array once per node would hold two billion entries.
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
let mut doc = Document::new_empty();
let action = doc.add(dict(vec![
(b"S", Object::Name(b"JavaScript".to_vec())),
(b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))),
]));
let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))]));
let kids = doc.add(Object::Null);
let nodes: Vec<Object> = (0..10_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))).collect();
let mut shared = vec![Object::Ref(leaf); 200_000];
shared.extend(nodes);
shared.push(Object::Ref(leaf));
doc.set(kids, Object::Array(shared));
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn a_direct_object_in_a_shared_kids_array_is_not_copied_for_every_node() {
// 1,000 nodes share one /Kids array that also holds a direct dictionary with a 2 MiB
// string. Queued with every node's kids, it would be copied 1,000 times (2 GiB) while the
// entry count stayed under its limit. Kids are references, so it is skipped.
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
let mut doc = Document::new_empty();
let action = doc.add(dict(vec![
(b"S", Object::Name(b"JavaScript".to_vec())),
(b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))),
]));
let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))]));
let kids = doc.add(Object::Null);
let big = dict(vec![(b"Pad", Object::String(pdfcraft_cos::PdfString::literal("x".repeat(2 << 20))))]);
let mut shared = vec![big];
shared.extend((0..1_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))));
shared.push(Object::Ref(leaf));
doc.set(kids, Object::Array(shared));
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn a_name_tree_node_listed_under_many_generations_is_read_once() {
// `Document::get` finds a node by object number alone, so `4 0 R` and `4 1 R` are one node.
// The root lists one under 10,000 generations: read for each, it would list its script
// 10,000 times and use up the walk's 10,000 visits before the node listed after it.
let kids: Vec<String> = (0..10_000).map(|g| format!("4 {g} R")).collect();
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(),
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
format!("<< /Kids [{} 5 0 R] >>", kids.join(" ")),
"<< /Names [(a) 6 0 R] >>".into(),
"<< /Names [(b) 6 0 R] >>".into(),
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
]);
let scripts = document_scripts_named(&doc);
assert_eq!(scripts.len(), 2, "{} scripts", scripts.len());
let js = "var ready = true;".to_string();
assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]);
}
#[test]
fn name_tree_nodes_sharing_one_names_array_list_its_scripts_once() {
// 9,999 nodes share one /Names array. Read for every node, its scripts would be listed 9,999
// times: ten million scripts and 1.3 GB when it names 1,000.
let nodes: Vec<String> = (0..9_999).map(|i| format!("{} 0 R", i + 6)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".to_string(),
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
format!("<< /Kids [{}] >>", nodes.join(" ")),
"[(a) 5 0 R (b) 5 0 R]".into(),
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
];
objs.extend(nodes.iter().map(|_| "<< /Names 4 0 R >>".to_string()));
let doc = document(&objs);
let scripts = document_scripts_named(&doc);
assert_eq!(scripts.len(), 2, "{} scripts", scripts.len());
let js = "var ready = true;".to_string();
assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]);
}
/// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one,
/// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list
/// box without one, and G only a down appearance (`/D`, no `/N`).
@@ -5487,6 +5853,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
}
#[test]
fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() {
let mut doc = fixture();
let city = field(&fields(&doc), "address.city").clone();
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap();
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap();
assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8));
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let mut doc = reopen(&doc);
let f = field(&fields(&doc), "address.city").clone();
assert_eq!(f.max_len, None, "no limit, not the parent's 3");
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int);
assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own");
set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap();
assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters");
// Without a parent limit, clearing still removes the key.
set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let name = field(&fields(&doc), "name").clone();
assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none());
}
#[test]
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
let mut doc = fixture();
@@ -5648,3 +6037,35 @@ fn resetting_many_named_fields_is_not_quadratic() {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
}
/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C`
/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB.
#[test]
fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() {
let widget = |name: &str, mk: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << {mk} >> /BS << /W 1 /S /S >> >>")
};
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 [5 0 R 6 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R] >>".into(),
widget("cmyk", "/BG [0 0 1 null] /BC [1 0 0 null]"),
widget("rgb", "/BG [0 0 1] /BC [1 0 0]"),
];
let mut doc = document(&objs);
let off = |doc: &mut Document, name: &str| {
redraw_field(doc, name).unwrap();
let all = fields(doc);
let w = field(&all, name).widgets[0].obj;
let wd = doc.get(w).as_dict().cloned().unwrap();
let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap());
let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance");
let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
};
let cmyk = off(&mut doc, "cmyk");
assert!(cmyk.contains("0 0 1 0 k") && cmyk.contains("1 0 0 0 K"), "CMYK background and border: {cmyk}");
let rgb = off(&mut doc, "rgb");
assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}");
}
+62 -5
View File
@@ -163,6 +163,11 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
let current = f.value.first().cloned().unwrap_or_default();
let v = if let Some(js) = &f.actions.scripts.calculate {
let r = scripts.run(FieldEvent::Calculate, js, &f, &current, &all);
// A script may set its own field through the field object (`getField("Total").value
// = …`) rather than through `event.value`. `apply_changes` leaves the event's own
// field to the event, so that value is the result unless the script also assigned
// `event.value`, even the value it already had.
let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default());
scripting::apply_changes(doc, &r.changes, &f.name)?;
if !r.changes.is_empty() {
all = fields(doc);
@@ -170,13 +175,37 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
if !r.rc {
continue;
}
r.value
match own {
Some(v) if !r.value_set => v,
_ => r.value,
}
} else {
let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
v
};
// A combo box result naming an option, by export value or display text, is stored as that
// option's export value (the first element of an /Opt pair, ISO 32000-2 Table 234), as
// `write_value` stores a choice, so /V, /I and exported form data agree.
let combo = f.kind == FieldKind::Combo;
let matched = if combo { option_named(&f, &v) } else { None };
let v = match matched.and_then(|i| f.options.get(i)) {
Some((export, _)) => export.clone(),
None => v,
};
if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
// A combo box's /I (its option indices, ISO 32000-2 Table 234) follows the value, as
// `write_value` keeps it, so the selection kept never disagrees with the value.
let picked = matched.and_then(|i| i64::try_from(i).ok());
doc.update_dict(f.obj, |d| {
d.set(b"V".to_vec(), PdfString::text(&v));
match picked {
Some(i) if combo => d.set(b"I".to_vec(), Object::Array(vec![Object::Int(i)])),
_ if combo => {
d.remove(b"I");
}
_ => {}
}
})?;
redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
if let Some(target) = all.iter_mut().find(|x| x.obj == obj) {
target.value = vec![v];
@@ -187,6 +216,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
Ok(changed)
}
/// The index of the choice option `v` names: the option whose export value it is, else the first
/// whose display text it is, so a display text never shadows a later option's export value.
fn option_named(f: &Field, v: &str) -> Option<usize> {
f.options.iter().position(|(e, _)| e == v).or_else(|| f.options.iter().position(|(_, d)| d == v))
}
/// The fields whose calculation differs between two readings of a form, matched by field object
/// and named as in `after`: the formulas an edit rewrote, such as a rename's.
pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec<String> {
@@ -206,6 +241,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
{
return invalid(format!("{:?} takes at most {max} characters", f.name));
}
// Thai this build can't draw is refused (before any script runs), rather than stored
// and drawn as `?`.
if let Some(why) = appearance::thai_refusal(doc, f, t) {
return invalid(format!("{:?} can't take this text: {why}", f.name));
}
// Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value.
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
@@ -275,7 +315,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
// Accept export values or display texts; free text only in editable combo boxes.
let mut exports = Vec::new();
for v in &vals {
match f.options.iter().find(|(e, d)| e == v || d == v) {
match option_named(f, v).and_then(|i| f.options.get(i)) {
Some((e, _)) => exports.push(e.clone()),
None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()),
None if f.options.is_empty() => exports.push(v.clone()),
@@ -426,12 +466,29 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
};
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
let mut undrawn = false;
// The Format script again with English month and weekday names, once, for widgets whose
// fonts can't draw the date language's (#5303).
let mut english: Option<Option<String>> = None;
for w in &f.widgets {
let (stream, missing) = match &shown {
Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false),
let (mut stream, missing) = match &shown {
Some(s) => {
let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false);
// Taken when the English run happened and draws whole, even where it is the
// stored value ("18-Oct-26" printed again from itself).
let again = match (&drawn.1, values) {
(Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(),
_ => None,
};
match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) {
Some(english) if english.1.is_none() => english,
_ => drawn,
}
}
None => appearance::field_appearance_drawn(doc, f, w, values, true),
};
undrawn |= missing.is_some();
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
let existing_ap = in_place
.then(|| {
doc.get(w.obj)