mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
commit
f996aef12f
654 files changed
+99220
-5792
No files matched your search
+99
-11
@@ -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 }),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
@@ -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, ¤t, &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)
|
||||
|
||||
Reference in new issue
Block a user