Merge upstream/main into main preserving custom features

- Resolve all 13 merge conflicts between upstream/main and main
- Preserve full image editing suite: OBB, crop, proportional resize, arrange, and context menu actions
- Preserve text editing formatting tools: bullet/numbered lists, subscript/superscript, and line spacing
- Cleanly integrate upstream's canvas modularization, font updates, and styling changes
- Refactor arrange_image and run_edit_page_image helpers to satisfy clippy cognitive complexity limits
- All engine, edit, and UI integration test suites passing cleanly
This commit is contained in:
Combiler01
2026-10-31 15:50:31 +05:30
565 changed files with 59061 additions and 14603 deletions
+9 -5
View File
@@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
/// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from
/// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place.
#[allow(clippy::too_many_arguments)]
struct TriggerUpdate<'a> {
trigger: Trigger,
replacement: Option<&'a FieldAction>,
previous: Option<Object>,
}
fn replace_trigger(
doc: &mut Document,
pages: &[pdfcraft_cos::ObjRef],
scripts: &mut pdfcraft_cos::DecodeBudget,
t: Trigger,
new: Option<&FieldAction>,
old: Option<Object>,
update: TriggerUpdate<'_>,
aa: &mut Dict,
a_entry: &mut Option<Object>,
) -> Result<(), FormError> {
let TriggerUpdate { trigger: t, replacement: new, previous: old } = update;
let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts));
if new.is_some() && new == old_action.as_ref() {
return Ok(());
@@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
Some(k) => aa.get(k).cloned(),
};
replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?;
replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?;
}
doc.update_dict(w.obj, |d| {
match &a_entry {
+2 -2
View File
@@ -19,10 +19,10 @@ mod script;
#[cfg(test)]
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation};
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};
use script::{Arg, call};
pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js};
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
/// The Format tab.
#[derive(Clone, Debug, PartialEq, Default)]
+62 -20
View File
@@ -206,6 +206,10 @@ enum Site {
Commas(String),
/// A name in the simplified field notation of a `BVCALC` comment.
Notation(String),
/// A field read by a name built at run time: a `getField` whose argument is more than one
/// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't
/// written out. Which field it is isn't known.
Built,
}
impl Site {
@@ -213,6 +217,7 @@ impl Site {
match self {
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
Site::Built => Vec::new(),
}
}
@@ -229,14 +234,16 @@ impl Site {
js_str(&parts.join(","))
})
}
Site::Built => None,
}
}
}
/// Where a Calculate script names fields, in order: the simplified field notation in its comment
/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
/// A name built at run time (`getField("row" + i)`) is not a site.
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name
/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or
/// `AFSimple_Calculate`.
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
let mut sites = Vec::new();
let (tokens, comments) = js_tokens(js);
@@ -255,35 +262,64 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
_ => None,
};
let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built));
for k in 0..tokens.len() {
if ident(k, "getField")
&& punct(k + 1, '(')
&& punct(k + 3, ')')
&& let Some((range, name)) = string(k + 2)
{
sites.push((range, Site::Literal(name)));
if ident(k, "getField") && punct(k + 1, '(') {
match string(k + 2) {
Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))),
_ => sites.extend(built(k)),
}
}
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) {
continue;
}
if !(string(k + 2).is_some() && punct(k + 3, ',')) {
sites.extend(built(k));
continue;
}
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
// An argument after it leaves the list as written, as after an array: its names are read,
// and what follows is a name built at run time.
if let Some((range, list)) = string(k + 4) {
sites.push((range, Site::Commas(list)));
if punct(k + 5, ')') || punct(k + 5, ',') {
sites.push((range, Site::Commas(list)));
}
if !punct(k + 5, ')') {
sites.extend(built(k));
}
continue;
}
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
// Nothing else is read, so no token is read for two calls.
let first = if punct(k + 4, '[') {
k + 5
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
// to end the call. An item that is more than a string (`"row" + i`), or anything after
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
// token is read for two calls.
let (first, close) = if punct(k + 4, '[') {
(k + 5, ']')
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
k + 7
(k + 7, ')')
} else {
sites.extend(built(k));
continue;
};
for (range, token) in tokens.iter().skip(first) {
match token {
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
Js::Punct(',') => {}
_ => break,
let mut j = first;
loop {
match tokens.get(j) {
// An item ends at a comma or the list's end, or where a script cut short ends.
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
sites.push((range.clone(), Site::Literal(name.clone())));
j += 1;
}
Some((_, Js::Punct(','))) => j += 1,
Some((_, Js::Punct(c))) if *c == close => {
if !punct(j + 1, ')') {
sites.extend(built(k));
}
break;
}
_ => {
sites.extend(built(k));
break;
}
}
}
}
@@ -315,6 +351,12 @@ pub fn calculation_reads(js: &str) -> Vec<String> {
unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned))
}
/// Whether a Calculate script also reads fields by names it builds at run time
/// (`getField("row" + i)`), which [`calculation_reads`] can't list.
pub fn reads_built_names(js: &str) -> bool {
calculation_sites(js).iter().any(|(_, site)| *site == Site::Built)
}
impl Calculate {
/// The fields it reads, each once, in the order it first names them. A name also stands for
/// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them.
+26 -3
View File
@@ -1,6 +1,6 @@
use super::calc::{js_tokens, notation_js, notation_range};
use super::calc::{js_tokens, notation_js, notation_range, notation_tokens};
use super::format::{DATE_PRESETS, TIME_PRESETS};
use super::*;
use super::{CalcOp, Calculate, Format, Validate};
// ── parsing scripts ─────────────────────────────────────────────────────────────────────────
@@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option<Calculate> {
if js.contains("BVCALC")
&& let Some(range) = notation_range(js, &js_tokens(js).1)
{
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
return Some(Calculate::Notation(notation_text(js.get(range)?).to_string()));
}
let args = call(js, "AFSimple_Calculate")?;
let op = match args.first().and_then(Arg::string)?.as_str() {
@@ -325,6 +325,29 @@ pub fn validate_js(v: &Validate) -> Option<String> {
}
}
/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it.
pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\";
/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment,
/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and
/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the
/// space after it. An empty one is refused too: it would be stored as `event.value = ;`.
pub fn notation_storable(expr: &str) -> bool {
let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::<Vec<_>>();
let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js));
!tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr))
}
/// `src` from its first notation token to its last: the spaces around it left out, but not an
/// escaped space that ends the last name (`Total\ `), as `str::trim` would.
fn notation_text(src: &str) -> &str {
let tokens = notation_tokens(src);
match (tokens.first(), tokens.last()) {
(Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(),
_ => "",
}
}
pub fn calculate_js(c: &Calculate) -> Option<String> {
match c {
Calculate::None => None,
+76
View File
@@ -156,6 +156,57 @@ fn a_regex_after_a_condition_is_not_read_as_code() {
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
}
/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that
/// isn't written out, reads a field whose name isn't known until the script runs.
#[test]
fn names_built_at_run_time_are_noticed() {
for js in [
"event.value = getField(\"row\" + i).value;",
"event.value = this.getField(name).value;",
"AFSimple_Calculate(\"SUM\", list);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", b));",
"AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));",
"AFSimple_Calculate(op, new Array(\"a\"));",
"AFSimple_Calculate(\"SUM\", \"row\" + suffix);",
"AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));",
"AFSimple_Calculate(\"SUM\", [\"row\" + i]);",
] {
assert!(reads_built_names(js), "{js}");
}
// A string that is only the start of a name or a list isn't read, or renamed, as one.
for (js, reads) in [
("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]),
] {
assert_eq!(parse_calculate(js), None, "{js} is a script");
assert_eq!(calculation_reads(js), reads, "{js}");
assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}");
}
for js in [
"event.value = getField(\"a\").value + getField('b').value;",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));",
"AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);",
"AFSimple_Calculate(\"SUM\", \"a, b\");",
"// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;",
"/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;",
] {
assert!(!reads_built_names(js), "{js}");
}
// The names it does know are still read and renamed.
let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));";
assert_eq!(calculation_reads(js), ["a"]);
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\"")));
// So are a list's, string or array, with another argument after it.
for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] {
assert!(reads_built_names(js), "{js}");
assert_eq!(calculation_reads(js), ["a", "b"], "{js}");
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}");
}
}
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
/// one another's field lists, don't make a rename read the rest of the script again for each.
#[test]
@@ -166,6 +217,21 @@ fn hostile_scripts_are_read_in_one_pass() {
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
}
/// A notation is stored only when it reads back from the script's comment as itself.
#[test]
fn a_notation_is_stored_only_when_it_reads_back() {
assert!(notation_storable("Price * Qty"));
assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read");
// A last name that ends in a space keeps it, escaped, when the notation is read back.
let total = "Qty * Total\\ ";
assert!(notation_storable(total));
let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap());
assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()]));
for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] {
assert!(!notation_storable(expr), "{expr:?}");
}
}
/// Notation is read through one lexer: field names with escapes, numbers and operators.
#[test]
fn notation_is_read_by_one_lexer() {
@@ -213,6 +279,16 @@ proptest::proptest! {
proptest::prop_assert_eq!(back, Some(js));
}
/// A notation of any names, written into a script, reads back from it as written.
#[test]
fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) {
proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC")));
let expr = names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" + ");
let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap();
proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone())));
proptest::prop_assert!(notation_storable(&expr), "{expr:?}");
}
/// What a formula reads is the same whether it is read from the tab's settings or from
/// the script they are written as.
#[test]
+21 -9
View File
@@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String {
}
/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box
/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt.
/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose
/// wrapped lines fit the height. The page appearance and the on-screen editor share this.
/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text
/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is
/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline
/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page
/// appearance and the on-screen editor share this.
pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 {
let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 };
auto_text_size_by(
@@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt
)
}
/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside
/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance
/// and the on-screen editor share this.
pub fn comb_auto_text_size(height: f64) -> f64 {
if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 }
}
/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side
/// ISO 32000-1 Annex C recommends.
pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0;
/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on
/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its
/// own font, whose widths and line breaks differ from the page's.
@@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i
}
return size;
}
let size = (height / 1.15).clamp(4.0, 12.0);
let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0);
let tw = line_width(size);
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
}
@@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox {
size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size),
multiline,
password: f.has(flags::PASSWORD),
comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0),
comb: f.is_comb(),
}
}
@@ -586,7 +599,7 @@ fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> Str
let inset = border_width(doc, wd);
let lw = line_width(doc, wd);
let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() };
if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) {
if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite() && (2..=MAX_COMB_DIVIDERS).contains(&cells)) {
return String::new();
}
let cell = w / cells as f64;
@@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
}
}
_ => {
let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0);
let comb = f.is_comb();
if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
if size == 0.0 {
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
@@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
}
} else {
if size == 0.0 {
// Comb cells hold one character each, so only the height limits them.
size = if comb {
((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0)
comb_auto_text_size(height - 2.0 * pad)
} else {
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
};
+1 -1
View File
@@ -1,6 +1,6 @@
use super::read::{acroform, fields};
use super::values::merged_appearance;
use super::*;
use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes};
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
pub fn need_appearances(doc: &Document) -> bool {
+7 -1
View File
@@ -11,12 +11,15 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
mod edit;
mod join;
mod lifecycle;
mod look;
mod tree;
pub(crate) use tree::ancestor_sets;
pub use edit::set_props;
pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field};
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
pub(crate) use look::button_appearance;
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon};
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
@@ -85,6 +88,9 @@ pub struct FieldProps {
pub max_len: Option<Option<usize>>,
/// Choice options (display texts; export values equal them).
pub options: Option<Vec<String>>,
/// Change one button widget's export value without changing its appearance state.
/// Widgets sharing that state are kept in sync. The index is zero-based.
pub export_value: Option<(usize, String)>,
/// Font size (0 = auto).
pub font_size: Option<f64>,
/// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in
+312 -122
View File
@@ -1,6 +1,7 @@
use super::join::{check_join, join_radio_group};
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
use super::*;
use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid};
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
@@ -14,6 +15,97 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
}
/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was
/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept`
/// are the old `/I` positions carried into the new list, which tell apart options sharing an
/// export value; a selected value none of them covers takes its first position, by `set_value`'s
/// rule. Returns them.
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec<usize> {
let mut indices: Vec<usize> = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect();
for v in values {
if !indices.iter().any(|&i| exports.get(i) == Some(v)) {
indices.extend(exports.iter().position(|e| e == v));
}
}
indices.sort_unstable();
indices.dedup();
if indices.is_empty() {
d.remove(b"I");
} else {
d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
}
indices
}
/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`.
fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec<usize> {
let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else {
return Vec::new();
};
let positions = doc.resolve(&i);
positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default()
}
/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else
/// the field's own, whose selection, `/I` included, moves with them.
fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec<usize>) {
match &props.options {
Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()),
None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())),
}
}
/// Sort a choice field's items (export value, display text) by display text, ignoring case, and
/// write them as `/Opt`; export values stay with their items. Returns the export values in their
/// new order and the `old` positions carried through the sort.
fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec<String>, Vec<usize>) {
let mut order: Vec<usize> = (0..items.len()).collect();
order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase()));
let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect();
let opt = sorted
.iter()
.map(|(e, disp)| {
if e == disp {
Object::String(PdfString::text(disp))
} else {
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
}
})
.collect();
d.set(b"Opt".to_vec(), Object::Array(opt));
let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect();
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
}
/// Build /Opt before modifying the document, preserving appearance states and selection.
fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Object>, FormError> {
if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
return invalid("only check boxes and radio buttons have an export value");
}
if value.is_empty() || value == "Off" || value.len() > 4096 {
return invalid("export value must be non-empty, at most 4096 bytes, and not Off");
}
let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?;
let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
if !f.options.is_empty() && f.options.len() != f.widgets.len() {
return invalid("button export options do not match its widgets");
}
let previous = f.export_of(index);
let mut out = Vec::with_capacity(f.widgets.len());
for (i, w) in f.widgets.iter().enumerate() {
let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
let same_state = w.on_state.as_deref() == Some(state);
if !same_state && (export == value || w.on_state.as_deref() == Some(value)) {
return invalid("export value conflicts with another button choice");
}
if same_state && Some(export) != previous {
return invalid("widgets with the same appearance state have conflicting export values");
}
out.push(Object::String(PdfString::text(if same_state { value } else { export })));
}
Ok(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
@@ -25,8 +117,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
patch.validate()?;
}
// Refused here, before anything is written, rather than stored as a different formula.
if let Some(crate::af::Calculate::Notation(n)) = &props.calculate
&& !crate::af::notation_storable(n)
{
return invalid(crate::af::NOTATION_NOT_STORABLE);
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
if props.export_value.is_some() && props.options.is_some() {
return invalid("give export_value or options, not both");
}
// Refuse a wrong type/index before any other requested property is written.
if let Some((wi, _)) = &props.caption {
if f.kind != FieldKind::PushButton {
@@ -57,6 +159,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
}
if let Some(options) = export_options.as_ref()
&& *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() })
{
doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?;
return Ok(name.to_string());
}
if let Some(lock) = props.locked {
for w in &f.widgets {
let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0);
@@ -70,59 +178,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(acts) = &props.actions {
crate::set_field_actions(doc, name, acts)?;
}
let mut new_name = name.to_string();
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
// A full name under the same parents only renames the field's own part (the editor
// shows nested fields by their full name): it isn't moved, so it keeps its place and
// goes on inheriting from its parents.
let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) {
(Some((parents, own)), Some((current, _))) if parents == current => own,
_ => n,
};
if n.contains('.') {
// A full name: the field moves under the parent fields it names (#2696).
let parts = name_parts(n)?;
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
return invalid(why);
}
let af = ensure_form(doc)?;
// Checked before anything changes, so a refused move leaves the field as it was.
let add = inherited(doc, f.obj);
check_place(doc, af, f.obj, &parts, &add)?;
// From here a failure (a write that `check_place` couldn't foresee) would leave the
// field half moved in `doc`. The engine runs every edit on a copy of the document and
// keeps it only when the edit succeeds (`Session::apply`), so the open document never
// sees that.
own_inherited(doc, f.obj, &add)?;
detach(doc, f.obj)?;
attach(doc, af, f.obj, &parts)?;
new_name = n.to_string();
} else {
if n.is_empty() {
return invalid("field names can't be empty");
}
// Rename the terminal part (the field's own /T). The parents' part stays, so the name
// that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO
// 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name.
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
new_name = format!("{prefix}{n}");
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) {
return invalid(why);
}
// A node of that name beside it that isn't a terminal field, such as a parent
// field left empty by a move.
let af = ensure_form(doc)?;
let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent"));
if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) {
return invalid(format!("a field named {new_name:?} already exists"));
}
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
}
// Calculations read the field by its new name, unless another field keeps the old one.
if all.iter().filter(|x| x.name == f.name).count() == 1 {
rename_in_calculations(doc, &all, &f.name, &new_name)?;
}
}
// A radio button renamed to another radio group's name joins that group (#4949). The other
// properties are written first, to this field, and its buttons then move.
let (new_name, join) = rename_field(doc, &all, &f, name, props)?;
// The new default appearance when the size or the look changes.
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
let new_da = match &props.look {
@@ -173,6 +231,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
return invalid("only check boxes and radio buttons have a check style");
}
let mut list_picked = None;
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
let parent_default = ancestor_sets(doc, f.obj, b"DV");
doc.update_dict(f.obj, |d| {
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
if let Some(t) = &props.tooltip {
@@ -190,27 +251,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
}
}
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
let mut new_exports: Option<Vec<String>> = None;
let mut kept = Vec::new();
if let Some(options) = &export_options {
d.set(b"Opt".to_vec(), Object::Array(options.clone()));
}
if let Some(opts) = &props.options {
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
new_exports = Some(opts.clone());
}
// "Sort items": the list itself is kept in order (export values stay with their items).
if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
let mut items: Vec<(String, String)> = match &props.options {
Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(),
None => f.options.clone(),
};
items.sort_by_key(|(_, display)| display.to_lowercase());
let opt = items
.iter()
.map(|(e, disp)| {
if e == disp {
Object::String(PdfString::text(disp))
} else {
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
}
})
.collect();
d.set(b"Opt".to_vec(), Object::Array(opt));
let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection);
new_exports = Some(exports);
kept = carried;
}
if let Some(exports) = &new_exports
&& matches!(f.kind, FieldKind::Combo | FieldKind::List)
{
list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len()));
}
if let Some(q) = props.quadding {
d.set(b"Q".to_vec(), Object::Int(q));
@@ -219,6 +279,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
// Buttons name their default state; text and choices hold a string.
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
// `/DV` is inheritable: with an ancestor's in place, removing the field's own would
// bring that one back, so the field keeps an empty default of its own instead.
Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => {
d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec()))
}
Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")),
Some(None) => {
d.remove(b"DV");
}
@@ -248,51 +314,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
}
}
if let Some(l) = &props.look {
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
for w in &f.widgets {
// Resolved first: an indirect /MK keeps its mark, rotation and other entries.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
doc.update_dict(w.obj, |d| {
match l.border {
Some(c) => mk.set(b"BC".to_vec(), arr(c)),
None => {
mk.remove(b"BC");
}
}
match l.fill {
Some(c) => mk.set(b"BG".to_vec(), arr(c)),
None => {
mk.remove(b"BG");
}
}
d.set(b"MK".to_vec(), Object::Dict(mk));
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0)));
bs.set(b"S".to_vec(), Object::name(l.style.code()));
if l.style == BorderStyle::Dashed {
bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)]));
}
d.set(b"BS".to_vec(), Object::Dict(bs));
})?;
}
}
if let Some(style) = props.check_style {
for w in &f.widgets {
// Resolved first: an indirect /MK keeps its colours.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(style.code()));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
}
if let Some((wi, r)) = props.rect {
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
}
apply_widget_style_props(doc, &f, name, props)?;
if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() {
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
return invalid("only text fields and drop-down lists have format, validate and calculate scripts");
@@ -341,6 +363,178 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
})?;
}
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
scroll_list(doc, f.obj, list_picked)?;
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
Ok(new_name)
}
/// Apply a new name from `props`: rename the field, move it under the parents a full name
/// names, or, for a radio button named after another radio group, pick that group to join.
/// Returns the field's new full name and the group it joins.
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option<Field>), FormError> {
let mut new_name = name.to_string();
let mut join = None;
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
// A full name under the same parents only renames the field's own part (the editor
// shows nested fields by their full name): it isn't moved, so it keeps its place and
// goes on inheriting from its parents.
let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) {
(Some((parents, own)), Some((current, _))) if parents == current => own,
_ => n,
};
let full = match f.name.rsplit_once('.') {
Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"),
_ => n.to_string(),
};
let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full);
if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) {
check_join(doc, f, group)?;
join = Some(group.clone());
new_name = full;
} else if n.contains('.') {
// A full name: the field moves under the parent fields it names (#2696).
let parts = name_parts(n)?;
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
return invalid(why);
}
let af = ensure_form(doc)?;
// Checked before anything changes, so a refused move leaves the field as it was.
let add = inherited(doc, f.obj);
check_place(doc, af, f.obj, &parts, &add)?;
// From here a failure (a write that `check_place` couldn't foresee) would leave the
// field half moved in `doc`. The engine runs every edit on a copy of the document and
// keeps it only when the edit succeeds (`Session::apply`), so the open document never
// sees that.
own_inherited(doc, f.obj, &add)?;
detach(doc, f.obj)?;
attach(doc, af, f.obj, &parts)?;
new_name = n.to_string();
} else {
if n.is_empty() {
return invalid("field names can't be empty");
}
// Rename the terminal part (the field's own /T). The parents' part stays, so the name
// that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO
// 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name.
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
new_name = format!("{prefix}{n}");
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) {
return invalid(why);
}
// A node of that name beside it that isn't a terminal field, such as a parent
// field left empty by a move.
let af = ensure_form(doc)?;
let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent"));
if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) {
return invalid(format!("a field named {new_name:?} already exists"));
}
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
}
// Calculations read the field by its new name, unless another field keeps the old one.
if all.iter().filter(|x| x.name == f.name).count() == 1 {
rename_in_calculations(doc, all, &f.name, &new_name)?;
}
}
Ok((new_name, join))
}
fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> {
if let Some(l) = &props.look {
let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect());
for w in &f.widgets {
// Resolved first: an indirect /MK keeps its mark, rotation and other entries.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
doc.update_dict(w.obj, |d| {
match l.border {
Some(c) => mk.set(b"BC".to_vec(), arr(c)),
None => {
mk.remove(b"BC");
}
}
match l.fill {
Some(c) => mk.set(b"BG".to_vec(), arr(c)),
None => {
mk.remove(b"BG");
}
}
d.set(b"MK".to_vec(), Object::Dict(mk));
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0)));
bs.set(b"S".to_vec(), Object::name(l.style.code()));
if l.style == BorderStyle::Dashed {
bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)]));
}
d.set(b"BS".to_vec(), Object::Dict(bs));
})?;
}
}
if let Some(style) = props.check_style {
for w in &f.widgets {
// Resolved first: an indirect /MK keeps its colours.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(style.code()));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
}
if let Some((wi, r)) = props.rect {
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
}
Ok(())
}
/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its
/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the
/// group's own buttons keep their appearances.
fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option<usize>) -> Result<(), FormError> {
if let Some(group) = join {
redraw_widgets(doc, name, only)?;
join_radio_group(doc, f, group)?;
let moved: Vec<usize> = fields(doc)
.into_iter()
.find(|g| g.name == new_name)
.map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect())
.unwrap_or_default();
for index in moved {
redraw_widgets(doc, new_name, Some(index))?;
}
Ok(())
} else {
redraw_widgets(doc, new_name, only)
}
}
/// A list box scrolls, as for a new value, so its first row is still an option and the
/// selection is in view. Its rows are measured with the edited font size and rectangle.
fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize>, usize)>) -> Result<(), FormError> {
if let Some((picked, count)) = list_picked
&& let Some(g) = fields(doc).into_iter().find(|g| g.obj == field)
{
if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count)
&& old != new
{
doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
}
crate::values::scroll_widgets(doc, &g, &picked, count)?;
}
Ok(())
}
fn apply_late_widget_props(
doc: &mut Document,
f: &Field,
props: &FieldProps,
new_da: &str,
rotated_rect: Option<Option<[f64; 4]>>,
) -> Result<Option<usize>, FormError> {
// A complete look replaces widget /DA overrides. Partial changes were patched above
// against each override, including size-only changes that keep its font and colour.
if props.look.is_some() {
@@ -348,7 +542,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if w.obj != f.obj {
doc.update_dict(w.obj, |d| {
if d.contains(b"DA") {
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
d.set(b"DA".to_vec(), PdfString::literal(new_da.to_owned().into_bytes()));
}
})?;
}
@@ -385,11 +579,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
// A label-only edit must not replace another widget's custom appearances.
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
redraw_widgets(doc, &new_name, only)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
Ok(new_name)
Ok(only)
}
/// Name field `old` `new` in each field's Calculate script that reads it
+129
View File
@@ -0,0 +1,129 @@
//! Renaming a radio button to an existing radio group's name makes it a button of that group
//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2
//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state.
use std::collections::{HashMap, HashSet};
use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};
use super::tree::{detach, list_of};
use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid};
/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`
/// keeps only the widget's own triggers.
const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];
/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
fn has_opt(doc: &Document, field: ObjRef) -> bool {
doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))
}
/// Refuse what [`join_radio_group`] can't do, before anything is written.
pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> {
for x in [f, target] {
if has_opt(doc, x.obj) {
return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name));
}
if x.widgets.is_empty() {
return invalid(format!("{} has no button", x.name));
}
}
if target.locked() {
return invalid(format!("{} is locked: unlock it first", target.name));
}
Ok(())
}
/// Move every button of radio field `f` into radio field `target`. Each keeps its export value
/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a
/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s
/// selected value. `f` leaves the form; its own value and field-level properties go with it,
/// and `target`'s stay. Call [`check_join`] first.
pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> {
let theirs: HashSet<String> = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
let mut taken = theirs.clone();
taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone()));
// The group's value counts only when one of its buttons has it.
let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned();
// Each clashing value of `f` gets one fresh name, shared by every button that had it. Of
// `taken.len() + 1` names at least one is free, so the search always ends in one.
let mut renamed: HashMap<String, String> = HashMap::new();
for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) {
if renamed.contains_key(old) {
continue;
}
let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else {
return invalid(format!("no free export value for {old}"));
};
taken.insert(fresh.clone());
renamed.insert(old.to_owned(), fresh);
}
// A group that is its own one button gets a field with a separate widget first, so it can
// hold more than one.
if let [only] = target.widgets.as_slice()
&& only.obj == target.obj
{
split_merged(doc, target, only)?;
}
detach(doc, f.obj)?;
// `f` is no longer a field, even when it stays on as a widget.
drop_from_calculation_order(doc, |r| r == f.obj)?;
let mut kids = Vec::with_capacity(f.widgets.len());
for w in &f.widgets {
let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
if w.obj == f.obj {
for k in FIELD_ONLY_KEYS {
d.remove(k);
}
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
match aa.as_ref().map(split_triggers) {
Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)),
Some((None, _)) => {
d.remove(b"AA");
}
None => {}
}
}
let mut state = w.on_state.clone();
if let Some(old) = w.on_state.as_deref()
&& let Some(fresh) = renamed.get(old)
&& rename_state(doc, &mut d, old, fresh)
{
state = Some(fresh.clone());
}
let on = state.is_some() && state == selected;
let shown = match (&state, on) {
(Some(s), true) => Object::Name(crate::name_bytes(s)),
_ => Object::name("Off"),
};
d.set(b"AS".to_vec(), shown);
d.set(b"Parent".to_vec(), Object::Ref(target.obj));
doc.set(w.obj, Object::Dict(d));
kids.push(Object::Ref(w.obj));
}
// `/Kids` may be an indirect array: read through it so the group's own buttons stay.
let mut list = list_of(doc, target.obj, b"Kids");
list.extend(kids);
doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?;
Ok(())
}
/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`,
/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other
/// users. Whether `/N` had the state: when it didn't, nothing is changed.
fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {
let (old, new) = (crate::name_bytes(old), crate::name_bytes(new));
let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false };
let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned());
if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) {
return false;
}
for key in [&b"N"[..], b"D", b"R"] {
let Some(mut sub) = states(&ap, key) else { continue };
if let Some(v) = sub.remove(&old) {
sub.set(new.clone(), v);
ap.set(key.to_vec(), Object::Dict(sub));
}
}
d.set(b"AP".to_vec(), Object::Dict(ap));
true
}
+143 -41
View File
@@ -1,6 +1,9 @@
use super::look::redraw_field;
use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names};
use super::*;
use super::{
Dict, Document, Field, FieldKind, FormError, MIN_FIELD_SIDE, NewField, ObjRef, Object, PdfString, Widget, appearance, fields, flags, invalid,
page_refs, rgb,
};
fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict {
let mut d = Dict::new();
@@ -194,8 +197,78 @@ pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormEr
delete_found_field(doc, f)
}
/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the
/// field and its other widgets: one radio button of a group, or one copy of a duplicated field,
/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted
/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was
/// on through the deleted widget alone turns off.
pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let Some(w) = f.widgets.get(widget).cloned() else {
return invalid(format!("{name} has no widget {}", widget.saturating_add(1)));
};
// A field merged with its widget is that widget's only one.
if f.widgets.len() < 2 || w.obj == f.obj {
return delete_found_field(doc, f);
}
let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len();
// Turn the group off when no button left *shows* the deleted one's state: a twin with the same
// on-state name that is currently off does not keep the choice visible.
let on_only_here = w.on_state.as_deref().is_some_and(|on| {
f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on))
});
drop_from_pages(doc, &std::iter::once(w.obj).collect())?;
detach(doc, w.obj)?;
drop_from_calculation_order(doc, |r| r == w.obj)?;
let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
let mut changed = false;
// The field's own `/Kids` is what made the widget its: `detach` follows the widget's
// `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep
// a widget no page shows.
if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
&& kids.iter().any(|k| k.as_ref() == Some(w.obj))
{
d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect()));
changed = true;
}
if per_widget_opt
&& let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
&& opt.len() == f.widgets.len()
{
let kept: Vec<Object> = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect();
d.set(b"Opt".to_vec(), Object::Array(kept));
changed = true;
}
if on_only_here && d.get(b"V").is_some() {
d.set(b"V".to_vec(), Object::name("Off"));
changed = true;
}
// The default (`/DV`, what Reset puts back) can't name a state no button has any more.
let dv_only_here = w.on_state.as_deref().is_some_and(|on| {
d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes())
&& !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on))
});
if dv_only_here {
d.set(b"DV".to_vec(), Object::name("Off"));
changed = true;
}
if changed {
doc.set(f.obj, Object::Dict(d));
}
Ok(())
}
fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
let widgets: std::collections::HashSet<ObjRef> = f.widgets.iter().map(|w| w.obj).collect();
drop_from_pages(doc, &widgets)?;
detach(doc, f.obj)?;
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
// widgets reachable for a full save.
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
}
/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference.
fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet<ObjRef>) -> Result<(), FormError> {
for p in page_refs(doc) {
let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
let list = doc.resolve(&a).as_array().cloned().unwrap_or_default();
@@ -207,15 +280,12 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
}
}
}
detach(doc, f.obj)?;
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
// widgets reachable for a full save.
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
Ok(())
}
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
/// by reference.
fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
@@ -237,7 +307,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool
}
/// Widget-only keys: they stay with each widget when a merged field is split.
const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];
const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"];
/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`,
/// `F`, `V`, `C`, Table 199) belong to the field.
const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"];
/// Split additional-actions dictionary `aa` into the widget's triggers and the field's.
/// An empty half is `None`.
pub(super) fn split_triggers(aa: &Dict) -> (Option<Dict>, Option<Dict>) {
let (mut widget, mut field) = (Dict::new(), Dict::new());
for (k, v) in aa.iter() {
let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field };
to.set(k.clone(), v.clone());
}
let some = |d: Dict| (!d.is_empty()).then_some(d);
(some(widget), some(field))
}
/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page
/// gets a widget at the same place, belonging to the same field, so they share one value.
@@ -258,40 +344,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
return Ok(0);
}
// A merged field/widget becomes a field with one widget kid.
let mut model_ref = model.obj;
if model.obj == f.obj {
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
let mut kid = Dict::new();
for k in WIDGET_KEYS {
if let Some(v) = d.get(k) {
kid.set(k.to_vec(), v.clone());
}
}
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
let kr = doc.add(Object::Dict(kid));
doc.update_dict(f.obj, |d| {
for k in WIDGET_KEYS {
d.remove(k);
}
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
})?;
// The page lists the widget now, not the field.
if let Some(p) = model.page {
let pr = refs[p];
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
let swap =
|a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
match annots {
Some(Object::Ref(ar)) => {
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
doc.set(ar, Object::Array(swap(a)));
}
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
_ => {}
}
}
model_ref = kr;
}
let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj };
let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();
let mut added = Vec::new();
for p in &targets {
@@ -324,3 +377,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
redraw_field(doc, name)?;
Ok(added.len())
}
/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that
/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid.
pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result<ObjRef, FormError> {
let refs = page_refs(doc);
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
let mut kid = Dict::new();
for k in WIDGET_KEYS {
if let Some(v) = d.get(k) {
kid.set(k.to_vec(), v.clone());
}
}
// The widget's own triggers go with it; the field's stay.
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default();
if let Some(w) = widget_aa {
kid.set(b"AA".to_vec(), Object::Dict(w));
}
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
let kr = doc.add(Object::Dict(kid));
doc.update_dict(f.obj, |d| {
for k in WIDGET_KEYS {
d.remove(k);
}
if aa.is_some() {
match field_aa {
Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)),
None => {
d.remove(b"AA");
}
}
}
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
})?;
// The page lists the widget now, not the field.
if let Some(&pr) = model.page.and_then(|p| refs.get(p)) {
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
let swap = |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
match annots {
Some(Object::Ref(ar)) => {
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
doc.set(ar, Object::Array(swap(a)));
}
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
_ => {}
}
}
Ok(kr)
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb};
/// The Appearance tab: borders, colours and text.
#[derive(Clone, Copy, Debug, PartialEq)]
+20 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid};
/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed.
pub(crate) fn ensure_form(doc: &mut Document) -> Result<ObjRef, FormError> {
@@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option<String> {
}
/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).
fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
let d = doc.get(holder).as_dict().cloned().unwrap_or_default();
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default()
}
@@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict {
add
}
/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without
/// its own entry.
pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
let mut seen = std::collections::HashSet::new();
let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent"));
while let Some(p) = parent {
if seen.len() > 64 || !seen.insert(p) {
return false;
}
let Some(d) = doc.get(p).as_dict().cloned() else { return false };
if d.contains(key) {
return true;
}
parent = d.reference(b"Parent");
}
false
}
/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves.
pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> {
if add.iter().next().is_some() {
+42 -4
View File
@@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20;
pub struct CalculatedField {
/// The field's full name.
pub name: String,
/// The field object. Two calculated fields can share a name in a malformed form; this tells
/// them apart.
pub obj: ObjRef,
/// The fields its formula reads, each once, as the formula names them: a name also stands for
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
pub reads: Vec<String>,
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
pub listed: bool,
/// What is wrong with its place in the order.
/// What is wrong with its formula or its place in the order.
pub issues: Vec<CalculationIssue>,
}
/// Something wrong with a calculated field's place in the calculation order.
/// Something wrong with a calculated field's formula or its place in the calculation order.
#[derive(Clone, Debug, PartialEq)]
pub enum CalculationIssue {
/// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft,
/// or mistyped), so the formula gets no value there.
MissingField { field: String },
/// It reads `field`, which calculates after it, so it uses that field's previous value.
RunsTooEarly { field: String },
/// Its formula reads its own result: it reads `fields`, which read it back (itself, when it
@@ -37,6 +43,9 @@ pub enum CalculationIssue {
/// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers
/// may not calculate it at all.
NotInOrder,
/// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those
/// aren't in `reads`, so the order isn't checked against them.
BuiltNames,
}
/// A form's calculation order and what is wrong with it.
@@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
.collect();
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
let component = components(&links);
let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
all.sort_unstable();
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
let mut out = Vec::with_capacity(calculated.len());
for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() {
let own = component.get(k).copied();
let mut issues = Vec::new();
let mut issues: Vec<CalculationIssue> =
reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect();
let read = links.get(k).map(Vec::as_slice).unwrap_or_default();
for &j in read {
if j > k && component.get(j).copied() != own {
@@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
if !listed {
issues.push(CalculationIssue::NotInOrder);
}
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) {
issues.push(CalculationIssue::BuiltNames);
}
out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues });
}
let fixed = fixed_order(&links, &component);
CalculationOrder { fields: out, extra, fixed }
}
/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a
/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts'
/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so
/// a read of one gets no value and is missing too.
fn found(names: &[&str], name: &str) -> bool {
let prefix = format!("{name}.");
let under = names.partition_point(|n| *n < prefix.as_str());
names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix))
}
/// For each calculated field (by its place in `names`), the places of the calculated fields it
/// reads, sorted. A read name finds the field of that name and the fields under it, as
/// [`crate::recalculate`] looks values up.
@@ -383,6 +408,19 @@ mod tests {
assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1");
}
#[test]
fn a_read_name_finds_a_field_or_a_group() {
let mut names = vec!["price", "row.1", "row.2", "rows", "total"];
names.sort_unstable();
for name in ["price", "row", "row.1", "rows"] {
assert!(found(&names, name), "{name}");
}
// A widget name finds nothing: recalculation and getField don't resolve it either.
for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] {
assert!(!found(&names, name), "{name}");
}
}
/// Two thousand formulas each reading a group of two thousand fields would be four million
/// reads; the checks stop at their cap.
#[test]
+17 -2
View File
@@ -27,7 +27,7 @@ pub mod table;
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
pub use author::{
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props,
};
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
pub use scripting::{
@@ -46,7 +46,7 @@ pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopte
#[cfg(test)]
use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads};
use read::{script, script_budget};
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order};
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders};
#[cfg(test)]
use values::has_password_flag;
use values::write_value;
@@ -176,6 +176,12 @@ impl Field {
self.flags & flag != 0
}
/// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO
/// 32000-2 Table 228). Each cell holds one character.
pub fn is_comb(&self) -> bool {
self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0)
}
pub fn read_only(&self) -> bool {
self.has(flags::READ_ONLY)
}
@@ -214,6 +220,15 @@ impl Field {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// Values for data export, resolving button appearance names through /Opt.
pub fn export_values(&self) -> Vec<String> {
if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) {
self.value.iter().map(|v| self.export_for_state(v).to_string()).collect()
} else {
self.value.clone()
}
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::tab_order::rank_tabs;
use super::*;
use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of};
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
+31 -4
View File
@@ -1,12 +1,14 @@
use super::read::{fields, page_refs};
use super::*;
use super::{Document, Field, FormError, ObjRef, Object, invalid};
/// A page's tab order (`/Tabs`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabOrder {
/// Rows, top to bottom, left to right within a row (`/R`).
/// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound
/// on the right).
Row,
/// Columns, left to right, top to bottom within a column (`/C`).
/// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a
/// document bound on the right).
Column,
/// The document structure (`/S`; annotation order when the file has none).
Structure,
@@ -23,6 +25,12 @@ impl TabOrder {
_ => TabOrder::Annotations,
}
}
/// The tab order of the page at `r`, with an indirect `/Tabs` resolved.
fn of_page(doc: &Document, r: ObjRef) -> TabOrder {
let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o));
TabOrder::of(tabs.as_deref().and_then(Object::as_name))
}
}
/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index).
@@ -38,13 +46,20 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col
}
}
}
// Rows and columns run right to left in a document bound on the right: rank the mirrored
// rectangles there.
if right_to_left(doc) {
for i in &mut items {
i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]];
}
}
let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len());
for (pi, p) in pages.iter().enumerate() {
let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect();
if on.is_empty() {
continue;
}
let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs")));
let tabs = TabOrder::of_page(doc, *p);
match tabs {
TabOrder::Row | TabOrder::Column => {
let row = tabs == TabOrder::Row;
@@ -87,6 +102,13 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col
}
}
/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`).
fn right_to_left(doc: &Document) -> bool {
let Some(root) = doc.root() else { return false };
let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false };
prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L"))
}
/// Set the tab order of pages (0-based).
pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> {
let refs = page_refs(doc);
@@ -104,6 +126,11 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
Ok(())
}
/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified).
pub fn tab_orders(doc: &Document) -> Vec<TabOrder> {
page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect()
}
/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field):
/// move field `name` one place earlier or later in its page's tab order. The page switches to
/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other
+992 -84
View File
File diff suppressed because it is too large Load Diff
+41 -20
View File
@@ -1,6 +1,9 @@
use super::appearances::{declares_pdfa, need_appearances};
use super::read::{acroform, field_flags, fields, page_refs};
use super::*;
use super::{
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance,
author, calc_order, flags, invalid, name_obj, scripting,
};
// ── writing ─────────────────────────────────────────────────────────────────────────────────
@@ -207,8 +210,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
let t = &scripting::accept(doc, f, t, scripts)?;
let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
doc.update_dict(f.obj, |d| {
if t.is_empty() {
if t.is_empty() && !shadow {
d.remove(b"V");
} else {
d.set(b"V".to_vec(), PdfString::text(t));
@@ -288,16 +292,13 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
indices.sort_unstable_by_key(Object::as_int);
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
let top = match (f.kind, f.widgets.first()) {
(FieldKind::List, Some(w)) => w.list.map(|rows| {
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
}),
_ => None,
};
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
let top = list_scroll(f, &picked, f.options.len());
// An empty value of its own keeps a parent field's /V from showing through.
let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
doc.update_dict(f.obj, |d| {
match exports.as_slice() {
[] if shadow => d.set(b"V".to_vec(), PdfString::text("")),
[] => {
d.remove(b"V");
}
@@ -315,16 +316,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
}
})?;
// A widget with its own `/TI` (read before the field's) scrolls too.
if let Some((old, new)) = top
&& old != new
{
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
}
}
}
scroll_widgets(doc, f, &picked, f.options.len())?;
redraw(doc, f, &exports, scripts)
}
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
@@ -333,6 +325,35 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
}
}
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
/// visible row would otherwise not show (#1524). `None` for any other field.
pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> {
f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count))
}
/// A list box widget's first row before and after scrolling so `picked` shows.
fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> {
w.list.map(|rows| {
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
(rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count))
})
}
/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and
/// size, so `picked` shows in each.
pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> {
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI"))
&& let Some((old, new)) = widget_scroll(w, picked, count)
&& old != new
{
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
}
}
Ok(())
}
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
let v = on.unwrap_or("Off");