mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:20:32 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+41
-20
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user