Merge origin/main into fix/cleared-default-stays-cleared

Adopt upstream's explicit import lists in author/edit.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-10-27 21:35:31 -04:00
co-authored by Claude Opus 5.5
259 changed files with 11550 additions and 7522 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 {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::calc::{js_tokens, notation_js, notation_range};
use super::format::{DATE_PRESETS, TIME_PRESETS};
use super::*;
use super::{CalcOp, Calculate, Format, Validate};
// ── parsing scripts ─────────────────────────────────────────────────────────────────────────
+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 {
+126 -105
View File
@@ -1,6 +1,6 @@
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
use super::*;
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]> {
@@ -70,59 +70,7 @@ 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)?;
}
}
let new_name = 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 {
@@ -255,51 +203,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");
@@ -348,6 +252,127 @@ 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)?;
redraw_widgets(doc, &new_name, only)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
Ok(new_name)
}
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<String, FormError> {
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)?;
}
}
Ok(new_name)
}
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(())
}
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() {
@@ -355,7 +380,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()));
}
})?;
}
@@ -392,11 +417,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
+4 -1
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();
+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)]
+1 -1
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> {
+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;
+1 -1
View File
@@ -1,5 +1,5 @@
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)]
+123 -80
View File
@@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() {
})
.unwrap();
invalid_dotted_field_names_are_rejected(&mut doc, &text);
}
fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) {
for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] {
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}");
assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}");
assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}");
assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}");
}
let deep = vec!["p"; 33].join(".");
assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep");
assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken");
assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep");
assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken");
}
/// Review on #2957: a parent left empty by a move keeps its name from the next default name
@@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr
/// - Helv, TiRo and Cour are there.
#[test]
fn every_shape_of_form_fonts_ends_as_objects() {
let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>");
let mut cases = 0;
for dr_shape in ["missing", "inline", "object"] {
for font_shape in ["missing", "inline", "object", "number"] {
for helv in ["missing", "inline", "object", "dangling", "null", "number"] {
for mine in ["missing", "inline", "object"] {
if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") {
continue;
}
if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") {
let case = FontShapeCase::new(dr_shape, font_shape, helv, mine);
if !case.is_valid() {
continue;
}
cases += 1;
let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}");
let mut entries = String::new();
match helv {
"inline" => entries += &format!("/Helv {} ", font("Arial")),
"object" => entries += "/Helv 7 0 R ",
"dangling" => entries += "/Helv 99 0 R ",
"null" => entries += "/Helv null ",
"number" => entries += "/Helv 3 ",
_ => {}
}
match mine {
"inline" => entries += &format!("/Mine {} ", font("Symbol")),
"object" => entries += "/Mine 8 0 R ",
_ => {}
}
let fonts_value = match font_shape {
"inline" => format!("<< {entries}>>"),
"object" => "6 0 R".into(),
"number" => "7".into(),
_ => String::new(),
};
let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") };
let dr_value = match dr_shape {
"inline" => dr_dict.clone(),
"object" => "5 0 R".into(),
_ => String::new(),
};
let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") };
let mut doc = document(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
af,
if dr_shape == "object" { dr_dict } else { "null".into() },
if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() },
font("Helvetica-Oblique"),
font("ZapfDingbats"),
]);
add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name"))
.unwrap_or_else(|e| panic!("{what}: {e}"));
let doc = reopen(&doc);
let af = acroform(&doc).unwrap();
let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR")));
let fonts = dr_fonts(&doc);
for (name, value) in fonts.iter() {
assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name));
}
for name in ["Helv", "TiRo", "Cour"] {
assert!(fonts.contains(name.as_bytes()), "{what}: no {name}");
}
let base =
|name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec)));
match helv {
"inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"),
"object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"),
"dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"),
"null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"),
"number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"),
_ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"),
}
match mine {
"inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"),
"object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"),
_ => assert!(!fonts.contains(b"Mine"), "{what}"),
}
if dr_shape == "object" {
assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object");
}
if font_shape == "object" {
assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object");
}
assert_font_shape_case(&case);
}
}
}
@@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() {
assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried");
}
struct FontShapeCase<'a> {
dr: &'a str,
fonts: &'a str,
helv: &'a str,
mine: &'a str,
description: String,
}
impl<'a> FontShapeCase<'a> {
fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self {
Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") }
}
fn is_valid(&self) -> bool {
let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing";
let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing");
!(self.dr == "missing" && !only_default_dr || invalid_fonts)
}
}
fn assert_font_shape_case(case: &FontShapeCase<'_>) {
let mut doc = font_shape_document(case);
add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name"))
.unwrap_or_else(|e| panic!("{}: {e}", case.description));
let doc = reopen(&doc);
let acro = acroform(&doc).unwrap();
let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description)));
let fonts = dr_fonts(&doc);
assert_font_entries_are_references(&fonts, &case.description);
assert_required_form_fonts(&fonts, &case.description);
assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case);
}
fn font_shape_document(case: &FontShapeCase<'_>) -> Document {
let mut entries = String::new();
match case.helv {
"inline" => entries += &format!("/Helv {} ", form_font("Arial")),
"object" => entries += "/Helv 7 0 R ",
"dangling" => entries += "/Helv 99 0 R ",
"null" => entries += "/Helv null ",
"number" => entries += "/Helv 3 ",
_ => {}
}
match case.mine {
"inline" => entries += &format!("/Mine {} ", form_font("Symbol")),
"object" => entries += "/Mine 8 0 R ",
_ => {}
}
let fonts_value = match case.fonts {
"inline" => format!("<< {entries}>>"),
"object" => "6 0 R".into(),
"number" => "7".into(),
_ => String::new(),
};
let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") };
let dr_value = match case.dr {
"inline" => dr_dict.clone(),
"object" => "5 0 R".into(),
_ => String::new(),
};
let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") };
document(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
acroform,
if case.dr == "object" { dr_dict } else { "null".into() },
if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() },
form_font("Helvetica-Oblique"),
form_font("ZapfDingbats"),
])
}
fn form_font(base: &str) -> String {
format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>")
}
fn assert_font_entries_are_references(fonts: &Dict, description: &str) {
for (name, value) in fonts.iter() {
assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name));
}
}
fn assert_required_form_fonts(fonts: &Dict, description: &str) {
for name in ["Helv", "TiRo", "Cour"] {
assert!(fonts.contains(name.as_bytes()), "{description}: no {name}");
}
}
fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) {
let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec)));
match case.helv {
"inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description),
"object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description),
"dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description),
"null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description),
"number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description),
_ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description),
}
match case.mine {
"inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description),
"object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description),
_ => assert!(!fonts.contains(b"Mine"), "{}", case.description),
}
if case.dr == "object" {
assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description);
}
if case.fonts == "object" {
assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description);
}
}
/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form
/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A
/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects
+4 -1
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, af, appearance, author,
calc_order, flags, invalid, name_obj, scripting,
};
// ── writing ─────────────────────────────────────────────────────────────────────────────────