Files
pdfcraft/crates/forms/src/scripting.rs
T
6427357fcb Forms: date fields name months in the date language, not always English (#5303) (#5464)
* Forms: date fields name months in the date language, not always English (#5303)

`AFDate_FormatEx("dd-mmm-yy")` wrote "18-Oct-26" in an Italian interface,
where a localized Acrobat writes "18-ott-26". Form date formats,
`util.printd` and `util.scand` now take their month and weekday names
from the date language (Preferences > Date format > Language, which
follows the interface language until one is picked), and still read
English names typed in. The names are process-wide, so their test runs
in its own binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: dates read back in every date language (#5303)

Trying every date language showed what a field wrote not always reading
back: names holding digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.")
or combining vowel signs (Thai, Hindi, Telugu) fell apart word by word,
and Italian, Spanish, Romanian and Albanian share some short weekday and
month names ("mar"). The language's names are now found whole in the
text first, the longest at each place, and read as their English names;
the format's order of `mmm` and `ddd` settles a shared name. Both
AFDate parsing and util.scand do this, on text up to 1 KB. A test reads
back every language, each name format, and hostile text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: date names no field font can draw are drawn in English (#5303)

With the names in the date language, a Russian dd-mmm-yy field drawn in
Helvetica showed "18-????.-26": no /DR font held "окт." and the WinAnsi
stand-in drew each letter as ?. The same held for Japanese, Chinese,
Greek, Arabic, Thai and others, and for Latin names outside WinAnsi
(Czech "říj"). Before, the names were English and always drew. An
AFDate/AFTime field now formats its value again with English names for
a widget whose fonts can't draw the localized text; a custom Format
script is run again with util.printd writing English names for such a
widget, and its result kept only when it draws whole. A field whose
font holds the names keeps them, and scripts otherwise still print the
date language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: an English weekday before the month keeps the month (#5303)

With Italian dates, "Tue 17 Mar 2026" in a "ddd d mmm yyyy" field read
as no date: the language's names were found in the text, but not
English ones, so "Tue" left the format's weekday unfilled and "Mar",
which Italian shares between Tuesday and March, was taken as the
weekday. Both AFDate parsing and util.scand lost the month, and
Spanish, Romanian and Albanian ("mar") did the same. English names are
now found too and fill their field of the format as localized ones do.
A test reads English and mixed weekdays before the month in those
languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: an English date run is drawn even when it prints the value (#5303)

A Helvetica field storing "18-Oct-26" whose Format script prints it
again as dd-mmm-yy still showed "18-???.-26" in Russian. The English
run printed "18-Oct-26", but it was dropped for equalling the stored
value, which was how a failed run was told apart: a failing script
handed back the value unchanged. The English run now says whether it
ran (`None` when the script failed or the runner can't write English
dates), and what it made is drawn whenever it draws whole. A test
reprints the stored value under Russian, and checks that a failing
script keeps its text and is reported undrawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: a short weekday typed without its dot is the weekday (#5303)

With French dates, "mar 6 janv 2026" in a "ddd d mmm yyyy" field read
as no date through util.scand, and "mar 06/01/2026" in "ddd dd/mm/yyyy"
through AFDate too. French writes Tuesday as "mar.", which a name
search only found with its dot; since English names are found as well,
the English "Mar" (March) was the only name of that length, so the
weekday became a month. Romanian "mar" did the same. A short name
ending in a dot is now also found typed without it, as the same kind of
name, so the format's order of name fields decides between it and an
equal name of the other kind. Both copies of the name search change. A
test reads French and Romanian weekdays typed without their dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: a shared name is the month when no weekday is typed (#5303)

With Italian dates, "17 mar 2026" in a "ddd d mmm yyyy" field read as
no date, nor did "mar 17, 2026" in "dddd, mmmm d, yyyy". AFDate leaves
weekdays out of what it reads (they are shown, not typed), but the name
search still expected the format's weekday first, so "mar", which
Italian shares between Tuesday and March, filled it and left no month.
Spanish and Albanian "mar" and Albanian "sht" (Saturday, September) did
the same, and the keystroke check refused those dates. Where such a
read finds no date, the names are now read again with no weekday
expected, so the shared name is the month; a typed weekday still comes
first ("mar 17 mar 2026"). util.scand reads its fields in order and
needs the weekday, so it is unchanged. A test types those dates with the
weekday left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: Turkish names typed in capitals are read (#5303)

With Turkish dates, "1 MAYIS 2026" and "1 EKİM 2026" in a d mmmm yyyy
field read as no date through AFDate and util.scand, and "SALI 6 OCAK
2026" through util.scand. Names were compared lower-cased, which turns
I into i, not the dotless ı of "Mayıs" and "Salı", and İ into i with a
combining dot above, which "Ekim" doesn't have. Names and typed text
are now folded alike, the capital I and the dotted and dotless i all as
i and the combining dot dropped, one piece for each char so positions
in the text stay aligned: in both copies of the name search, the AFDate
month reader and util.scand's name matcher. English reads as before. A
test types Turkish names in capitals and English ones as a check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: Greek names typed in capitals are read again (#5303)

Folding names char by char turned a capital Σ into σ, so "ΙΑΝΟΥΆΡΙΟΣ"
no longer matched "Ιανουάριος", whose last letter is the final ς. The
fold now makes ς a σ on both sides, in the forms and JS parsers alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 16:12:04 +01:00

330 lines
13 KiB
Rust

//! Field scripts beyond the AF calls: the hook through which the engine runs JavaScript.
//!
//! `forms` does not depend on a JavaScript engine (architecture §3: `forms → js` is not an
//! allowed edge). It calls a [`Scripts`] implementation at the points Acrobat runs field
//! events — Keystroke (on commit) and Validate before a value is accepted, Calculate in the
//! calculation order, Format when the appearance is drawn — and applies what the script asks
//! for: the event's value, `rc`, and changes to other fields.
use std::collections::HashSet;
use pdfcraft_cos::{Document, ObjRef, Object, PdfString};
use crate::{Field, FieldKind, FieldValue, FormError, fields, flags};
/// The field events forms run scripts for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldEvent {
Keystroke,
Validate,
Calculate,
Format,
}
impl FieldEvent {
/// `event.name`.
pub fn name(self) -> &'static str {
match self {
FieldEvent::Keystroke => "Keystroke",
FieldEvent::Validate => "Validate",
FieldEvent::Calculate => "Calculate",
FieldEvent::Format => "Format",
}
}
}
/// A change a script made to a field (`None`: unchanged).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldChange {
pub name: String,
pub value: Option<Vec<String>>,
pub read_only: Option<bool>,
pub required: Option<bool>,
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
pub display: Option<i32>,
}
/// What a script did.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptResult {
/// `event.rc`.
pub rc: bool,
/// `event.value` afterwards.
pub value: String,
pub changes: Vec<FieldChange>,
/// Why it rejected the value (its alert), for the error shown.
pub message: Option<String>,
}
/// Runs field scripts (JavaScript).
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.
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 }
}
}
/// Run the Keystroke (commit) and Validate scripts for a new text value. Returns the value to
/// store, or why it is rejected.
pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result<String, FormError> {
let mut v = value.to_string();
for (event, script, default) in [
(FieldEvent::Keystroke, &f.actions.scripts.keystroke, "The value entered does not match the format of the field"),
(FieldEvent::Validate, &f.actions.scripts.validate, "The value entered is not valid for the field"),
] {
let Some(js) = script else { continue };
let all = fields(doc);
let r = scripts.run(event, js, f, &v, &all);
apply_changes(doc, &r.changes, &f.name)?;
if !r.rc {
return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name))));
}
v = r.value;
}
Ok(v)
}
/// The text a custom Format script shows for `value`, if the field has one.
pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
let js = f.actions.scripts.format.as_ref()?;
let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc));
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.
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
if changes.is_empty() {
return Ok(());
}
let all = fields(doc);
for c in changes {
let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
if let Some(v) = &c.value
&& c.name != except
&& *v != f.value
{
let value = match f.kind {
FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()),
FieldKind::CheckBox => FieldValue::Check(!v.is_empty()),
FieldKind::Radio => FieldValue::Radio(v.first().cloned()),
FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()),
_ => continue,
};
let mut tmp = f.clone();
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
tmp.actions = Default::default();
crate::write_value(doc, &tmp, &value, &mut NoScripts)?;
}
let mut ff = f.flags;
for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] {
match on {
Some(true) => ff |= bit,
Some(false) => ff &= !bit,
None => {}
}
}
if ff != f.flags {
doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(ff as i64)))?;
}
if let Some(display) = c.display {
for w in &f.widgets {
let old = doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0);
// Hidden 2, Print 4, NoView 32 (Table 167).
let base = old & !(2 | 4 | 32);
let new = match display {
1 => base | 2 | 4,
2 => base,
3 => base | 4 | 32,
_ => base | 4,
};
if new != old {
doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?;
}
}
}
}
Ok(())
}
/// Apply what a button or console script changed (values, read-only, required, visibility),
/// then recalculate.
pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> {
apply_changes(doc, changes, "")?;
crate::recalculate_with(doc, scripts)?;
Ok(())
}
/// Document-level JavaScripts (the catalog's `/Names /JavaScript` tree), in name order: they
/// define the functions field scripts call.
pub fn document_scripts(doc: &Document) -> Vec<String> {
document_scripts_named(doc).into_iter().map(|(_, js)| js).collect()
}
/// [`document_scripts`] with their names.
pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(root) = doc.root() else { return Vec::new() };
let cat = doc.get(root);
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
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.
let mut stack = vec![tree];
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut visited = 0;
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(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));
}
}
}
}
out.sort();
out
}
/// 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)?;
let cat = doc.get(root).as_dict().cloned().unwrap_or_default();
let mut names = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned()).unwrap_or_default();
let tree = names.get(b"JavaScript").map(|t| doc.resolve(t)).and_then(|t| t.as_dict().cloned()).unwrap_or_default();
if tree.contains(b"Kids") {
return Err(FormError::Invalid("the document's JavaScript name tree has kids; edit it with a full editor".into()));
}
let mut pairs: Vec<Object> = tree.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()).unwrap_or_default();
let mut kept = Vec::new();
for pair in pairs.chunks(2) {
if let [k, v] = pair
&& !matches!(&*doc.resolve(k), Object::String(s) if s.to_text() == name)
{
kept.push((k.clone(), v.clone()));
}
}
if let Some(js) = js {
let mut action = pdfcraft_cos::Dict::new();
action.set(b"S".to_vec(), Object::name("JavaScript"));
action.set(b"JS".to_vec(), PdfString::text(js));
let a = doc.add(Object::Dict(action));
kept.push((Object::String(PdfString::text(name)), Object::Ref(a)));
}
kept.sort_by_key(|(k, _)| match k {
Object::String(s) => s.to_text(),
_ => String::new(),
});
pairs = kept.into_iter().flat_map(|(k, v)| [k, v]).collect();
let mut t = pdfcraft_cos::Dict::new();
t.set(b"Names".to_vec(), Object::Array(pairs));
names.set(b"JavaScript".to_vec(), Object::Dict(t));
doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names)))?;
Ok(())
}
/// Field Properties ▸ Format / Validate / Calculate ▸ custom script, or Actions ▸ Mouse Up ▸
/// Run a JavaScript: set (or remove, with `None`) field `name`'s script for `event` — one of
/// `keystroke`, `format`, `validate`, `calculate` (the field's `/AA` K, F, V, C) or `mouse_up`
/// (its widgets' `/A`). Calculated fields join the calculation order (`/CO`).
pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option<&str>) -> Result<(), FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
let action = |doc: &mut Document, js: &str| {
let mut a = pdfcraft_cos::Dict::new();
a.set(b"S".to_vec(), Object::name("JavaScript"));
a.set(b"JS".to_vec(), PdfString::text(js));
Object::Ref(doc.add(Object::Dict(a)))
};
if event == "mouse_up" {
for w in &f.widgets {
let a = js.map(|j| action(doc, j));
doc.update_dict(w.obj, |d| match a {
Some(a) => d.set(b"A".to_vec(), a),
None => {
d.remove(b"A");
}
})?;
}
return Ok(());
}
let key: &[u8] = match event {
"keystroke" => b"K",
"format" => b"F",
"validate" => b"V",
"calculate" => b"C",
other => return Err(FormError::Invalid(format!("unknown field event {other:?} (keystroke, format, validate, calculate, mouse_up)"))),
};
let mut aa = doc.get(f.obj).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
match js {
Some(j) => {
let a = action(doc, j);
aa.set(key.to_vec(), a);
}
None => {
aa.remove(key);
}
}
doc.update_dict(f.obj, |d| {
if aa.is_empty() {
d.remove(b"AA");
} else {
d.set(b"AA".to_vec(), Object::Dict(aa));
}
})?;
if key == b"C" {
crate::calc_order::keep_listed(doc, f.obj, js.is_some())?;
}
Ok(())
}