Enforce Rust coding standards via Clippy (#4897)

* Refactor annot for strict Clippy checks

* Refactor automation for strict Clippy checks

* Refactor cos for strict Clippy checks

* Refactor edit for strict Clippy checks

* Refactor engine for strict Clippy checks

* Refactor export for strict Clippy checks

* Refactor forms for strict Clippy checks

* Refactor js for strict Clippy checks

* Refactor organize for strict Clippy checks

* Refactor pdfcraft-cli for strict Clippy checks

* Refactor preflight for strict Clippy checks

* Refactor print for strict Clippy checks

* Refactor redact for strict Clippy checks

* Refactor render for strict Clippy checks

* Refactor sign for strict Clippy checks

* Refactor ui canvas for strict Clippy checks

* Refactor ui commands for strict Clippy checks

* Refactor ui dialogs for strict Clippy checks

* Refactor ui support for strict Clippy checks

* Refactor ui tools for strict Clippy checks

* Refactor ui workflows for strict Clippy checks

* Refactor xfa for strict Clippy checks

* Refactor xfdf for strict Clippy checks

* Enforce workspace Clippy standards and document lint policy

* Fix menu overflow and form pointer handling

* Group annotation, form and document operation parameters

* Group signing and timestamp validation inputs

* Group demo form field properties

* Group UI interaction and rendering parameters

* Restore translated security and shortcut labels

* Fix platform-gated imports for Windows and wasm builds

* Qualify signing calls for macOS and Windows builds

* Restore the preflight check flow and the overflow menu title
This commit is contained in:
Dylan M. Taylor
2026-10-27 20:51:13 -04:00
committed by GitHub
219 changed files with 9846 additions and 7201 deletions
+1
View File
@@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
+3
View File
@@ -91,6 +91,9 @@ web-time = "1.1"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
+3 -1
View File
@@ -1,6 +1,8 @@
use super::commands::{parse_dpi, read};
use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options};
use super::*;
use super::{
Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line,
};
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
+1 -1
View File
@@ -2,7 +2,7 @@ use super::options::{
COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional,
validate_options,
};
use super::*;
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::CliError;
pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
+3 -1
View File
@@ -1,7 +1,9 @@
#[cfg(feature = "mcp")]
use super::options::MCP_OPTIONS;
use super::options::{RUN_OPTIONS, flag, positional, validate_options};
use super::*;
#[cfg(feature = "mcp")]
use super::{Arc, Path, Write, automation_args};
use super::{CliError, stdout_line};
// ---- automation --------------------------------------------------------------------------------
+1 -1
View File
@@ -1,5 +1,5 @@
use super::options::{UI_OPTIONS, flag, positional, validate_options};
use super::*;
use super::{CliError, Duration, stdout_line};
// ---- UI control channel client -----------------------------------------------------------------
+1
View File
@@ -13,6 +13,7 @@ disallowed-methods = [
{ path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
{ path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" },
]
cognitive-complexity-threshold = 30
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
+1 -1
View File
@@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten
add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?;
set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?;
delete_annotation(&mut doc, page, index)?;
signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
```
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
+52 -38
View File
@@ -1,8 +1,10 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{n, rich_text};
use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style};
use super::*;
use super::types::{
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
pub(crate) fn num_array(v: &[f64]) -> Object {
Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
@@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result<usize, AnnotError> {
let page = page_ref(doc, new.page)?;
let style = &new.style;
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
}
validate_style(style)?;
let rect = rect_for(&new.shape, style)?;
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
let opacity = style.opacity.clamp(0.0, 1.0);
if opacity < 1.0 {
d.set(b"CA".to_vec(), Object::Real(opacity));
}
set_non_opaque_style(&mut d, opacity);
let border = |d: &mut Dict| {
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
@@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
}
Shape::Redact { quads, overlay, look } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
d.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
if !overlay.is_empty() {
d.set(b"OverlayText".to_vec(), PdfString::text(overlay));
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
d.set(
b"DA".to_vec(),
PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()),
);
d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
if look.repeat {
d.set(b"Repeat".to_vec(), Object::Bool(true));
}
}
}
Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look),
Shape::Rectangle { .. } | Shape::Oval { .. } => {
d.set(b"C".to_vec(), rgb(style.color));
if let Some(f) = style.fill {
@@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
if let Some(pr) = popup {
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Annot"));
p.set(b"Subtype".to_vec(), Object::name("Popup"));
p.set(b"Rect".to_vec(), num_array(&pr));
p.set(b"Parent".to_vec(), Object::Ref(r));
p.set(b"Open".to_vec(), Object::Bool(false));
p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
let pref = doc.add(Object::Dict(p));
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
list.push(Object::Ref(pref));
}
add_popup(doc, r, popup, &mut list)?;
set_annots(doc, page, list)?;
Ok(index)
}
fn validate_style(style: &Style) -> Result<(), AnnotError> {
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
}
Ok(())
}
fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) {
dict.set(b"C".to_vec(), rgb(style.color));
dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0])));
dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat()));
if !overlay.is_empty() {
dict.set(b"OverlayText".to_vec(), PdfString::text(overlay));
let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0));
let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 };
dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()));
dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64));
if look.repeat {
dict.set(b"Repeat".to_vec(), Object::Bool(true));
}
}
}
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
if opacity < 1.0 {
dict.set(b"CA".to_vec(), Object::Real(opacity));
}
}
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
let Some(rect) = rect else { return Ok(()) };
let mut popup = Dict::new();
popup.set(b"Type".to_vec(), Object::name("Annot"));
popup.set(b"Subtype".to_vec(), Object::name("Popup"));
popup.set(b"Rect".to_vec(), num_array(&rect));
popup.set(b"Parent".to_vec(), Object::Ref(parent));
popup.set(b"Open".to_vec(), Object::Bool(false));
popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
let popup_ref = doc.add(Object::Dict(popup));
doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?;
annots.push(Object::Ref(popup_ref));
Ok(())
}
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
/// box is fitted to `/Rect` by PDF viewers.
+1 -1
View File
@@ -2,7 +2,7 @@ use super::create::{finite, num_array};
use super::lookup::{annot_dict, annots, page_refs};
use super::modify::refuse_foreign_text;
use super::types::NOTE_SIZE;
use super::*;
use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance};
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
/// text (a restyle, a new size or colour).
+1 -1
View File
@@ -3,7 +3,7 @@ use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::*;
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
// ── reading ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -41,7 +41,7 @@ pub use modify::{
};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, rect_quad,
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
};
pub mod appearance;
+1 -1
View File
@@ -1,6 +1,6 @@
use super::info::text_value;
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
use super::*;
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
// ── pages and /Annots ───────────────────────────────────────────────────────────────────────
+7 -25
View File
@@ -6,8 +6,9 @@ use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::types::{
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
StyleChange, TextReplacement,
};
use super::*;
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
@@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 {
}
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
#[allow(clippy::too_many_arguments)]
pub fn set_style(
doc: &mut Document,
page: usize,
index: usize,
color: Option<Rgb>,
opacity: Option<f64>,
width: Option<f64>,
endings: Option<&[LineEnding]>,
meta: &Meta,
) -> Result<(), AnnotError> {
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
@@ -521,7 +513,7 @@ pub fn set_style(
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
return Err(AnnotError::Invalid("invalid style".into()));
}
if let Some(ends) = endings {
if let Some(ends) = &endings {
let callout = subtype == "FreeText" && d.contains(b"CL");
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
@@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
/// from the caller, so the edit stays deterministic.
#[allow(clippy::too_many_arguments)]
pub fn add_text_replacement(
doc: &mut Document,
page: usize,
quads: &[[f64; 8]],
replacement: &str,
author: &str,
strike: &Style,
caret: &Style,
meta: &Meta,
caret_meta: &Meta,
) -> Result<usize, AnnotError> {
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement;
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
let s = add_annotation(
doc,
+45 -14
View File
@@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() {
fn restyle_and_resize_redraw_the_appearance() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap();
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
@@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() {
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
let before = list(&doc, 1)[0].clone();
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
assert_eq!(
set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")),
Err(AnnotError::Unsupported("Stamp".into()))
);
assert_eq!(list(&doc, 1)[0], before);
// Lines can't be resized as rectangles.
let l = add_annotation(
@@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
@@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() {
assert_eq!(text(&all[s], b"Subj"), "Signature");
// Other stamps still can't be restyled; ours can.
let mut doc = doc;
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err());
}
#[test]
@@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
let i = add_text_replacement(
&mut doc,
TextReplacement {
page: 0,
quads: &quads,
replacement: "new words",
author: "Ada",
strike: &red,
caret: &blue,
strike_meta: &meta("r"),
caret_meta: &meta("r-caret"),
},
)
.unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let strike = &a[i];
@@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
assert!(
add_text_replacement(
&mut doc,
TextReplacement {
page: 0,
quads: &[],
replacement: "x",
author: "Ada",
strike: &red,
caret: &blue,
strike_meta: &meta("r"),
caret_meta: &meta("r-caret")
}
)
.is_err()
);
}
#[test]
@@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() {
)
.unwrap();
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap();
let mut doc = reopen(&doc);
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
let after = rect(&line);
@@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() {
let other_dict = list(&doc, 0)[other].clone();
let source = other_dict.get(b"AP").unwrap();
let before = doc.resolve(source);
set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
for full in [false, true] {
let bytes = if full {
@@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
.unwrap();
assert_eq!(mark(&doc, s), Some('张'));
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
assert_eq!(mark(&doc, t), Some('签'));
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
assert_eq!(mark(&doc, t), None);
@@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let before = list(&doc, 0)[k].clone();
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err();
let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance");
let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err();
@@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() {
fn text_box_font_size_changes_the_da_and_grows_the_box() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap();
let height = |doc: &Document| {
let rect: Vec<f64> = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
@@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() {
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
assert!(props(&doc, 0, index).unwrap().fillable);
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap();
let saved = reopen(&doc);
let properties = props(&saved, 0, index).unwrap();
assert_eq!(properties.fill, Some([1.0; 3]));
@@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() {
b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>",
]);
assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star));
set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[0];
assert_eq!(d.name(b"Name"), Some(&b"Star"[..]));
+28 -1
View File
@@ -1,5 +1,5 @@
use super::create::normalize;
use super::*;
use super::{ObjRef, appearance};
pub type Rgb = [f64; 3];
@@ -563,6 +563,33 @@ pub struct Style {
pub fill: Option<Rgb>,
}
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StyleChange {
pub color: Option<Rgb>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub endings: Option<Vec<LineEnding>>,
}
impl StyleChange {
pub fn new(color: Option<Rgb>, opacity: Option<f64>, width: Option<f64>, endings: Option<Vec<LineEnding>>) -> Self {
Self { color, opacity, width, endings }
}
}
/// Inputs for replacing selected text with a grouped strikeout and caret annotation.
pub struct TextReplacement<'a> {
pub page: usize,
pub quads: &'a [[f64; 8]],
pub replacement: &'a str,
pub author: &'a str,
pub strike: &'a Style,
pub caret: &'a Style,
pub strike_meta: &'a Meta,
pub caret_meta: &'a Meta,
}
impl Default for Style {
fn default() -> Self {
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
impl Automation {
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
+1 -1
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, one_based, summary};
use super::paths::{child, read_input, read_input_bytes, unused, write_atomic};
use super::*;
use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json};
impl Automation {
pub(crate) fn doc_create(&mut self, a: &Args) -> Result<Value> {
+3 -3
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
use super::paths::{read_input, write_atomic};
use super::*;
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
@@ -742,7 +742,7 @@ impl Automation {
let ids: Vec<Value> = pdfcraft_engine::sign::keychain::identities(None)
.map_err(failed)?
.iter()
.map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
.map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
.collect();
#[cfg(not(target_os = "macos"))]
let ids: Vec<Value> = Vec::new();
@@ -755,7 +755,7 @@ impl Automation {
let ids = listing
.ids
.iter()
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) }))
.map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) }))
.collect();
let unusable = listing
.unusable
+1 -1
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, nest_rows, security, summary};
use super::paths::{child, read_input, write_atomic};
use super::*;
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::error::{Result, ToolError};
use super::*;
use super::{Document, Value, comments, json};
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
/// most 14 400 across).
+1 -1
View File
@@ -1,7 +1,7 @@
use super::dispatch::Content;
use super::error::{Result, ToolError, failed};
use super::json::{Args, one_based};
use super::*;
use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools};
/// Default and maximum resolution for `page_render`.
pub(crate) const DEFAULT_DPI: f64 = 96.0;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::error::{Result, failed};
use super::*;
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
impl Automation {
// ---- paths -------------------------------------------------------------------------------
+128 -64
View File
@@ -2030,21 +2030,33 @@ fn comments_through_tools() {
let dir = workdir("comments");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let id = add_comment_examples(&mut a, doc);
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
let rect = verify_comment_listing(&list, &id);
edit_rectangle_comment(&mut a, doc, &rect);
edit_text_comment(&mut a, doc, &list);
verify_comment_errors(&mut a, doc, &id);
save_and_reopen_comments(&dir, &mut a, doc);
}
fn add_comment_examples(a: &mut Automation, doc: u64) -> String {
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
assert_eq!(hl["comment"]["lines"], 1);
let id = hl["comment"]["id"].as_str().unwrap().to_string();
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" }));
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 }));
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 }));
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] }));
ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] }));
ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" }));
ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" }));
id
}
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
fn verify_comment_listing(list: &Value, id: &str) -> Value {
assert_eq!(list["count"], 6, "{list}");
let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone();
let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check")));
assert_eq!(c["status"], "Accepted");
assert_eq!(c["replies"].as_array().unwrap().len(), 1);
@@ -2057,33 +2069,38 @@ fn comments_through_tools() {
assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0]));
let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone();
assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point");
rect
}
fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) {
// Edit by page + index; several changes are one undo step.
let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap());
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap());
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" }));
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone();
assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved")));
assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0]));
let undo = ok(&mut a, "edit_undo", json!({ "doc": doc }));
let undo = ok(a, "edit_undo", json!({ "doc": doc }));
assert_eq!(undo["undone"], "Edit comment");
ok(&mut a, "edit_redo", json!({ "doc": doc }));
ok(a, "edit_redo", json!({ "doc": doc }));
// Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none.
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" }));
ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" }));
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_))));
let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone();
assert!(matches!(
a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })),
Err(ToolError::Failed(_))
));
}
fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) {
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
// stay, the height follows the wrapped lines.
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let r: Vec<f64> = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
@@ -2092,9 +2109,11 @@ fn comments_through_tools() {
// The rendered page shows the rectangle's border.
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert!(matches!(png[0], Content::Png { .. }));
}
ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id }));
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5);
fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) {
ok(a, "comment_delete", json!({ "doc": doc, "id": id }));
assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5);
assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_))));
@@ -2103,10 +2122,11 @@ fn comments_through_tools() {
Err(ToolError::InvalidArgs(_))
));
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_))));
}
// Comments survive a save and reopen.
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
let mut b = auto(&dir);
fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) {
ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" }));
let mut b = auto(dir);
let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
}
@@ -2809,6 +2829,12 @@ fn accessibility_check_report_and_fixes_through_tools() {
let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap();
assert!(manual_html.contains("<p>Needs manual check: 1</p>"));
assert!(!manual_html.contains("no problems"));
}
#[test]
fn accessibility_figure_tags_and_alternative_text_through_tools() {
let dir = workdir("a11y-figures");
let mut a = auto(&dir);
// Figures: list, describe, mark decorative.
std::fs::write(
dir.join("figures.pdf"),
@@ -5260,6 +5286,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
#[test]
fn local_gotor_link_open_applies_destinations_and_honors_root() {
let root = local_gotor_fixtures();
let mut a = auto(&root);
assert_relative_gotor_destination(&mut a);
assert_absolute_gotor_policy(&mut a);
assert_unapplied_gotor_destinations(&mut a);
assert_encrypted_gotor_passwords(&mut a);
assert_unsupported_gotor_targets(&mut a);
assert_gotor_root_boundaries(&mut a);
assert_url_links_cannot_open_as_gotor(&mut a);
}
fn local_gotor_fixtures() -> PathBuf {
let (base, root) = sandbox("gotor-open");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("target.pdf"), fixture(3)).unwrap();
@@ -5277,12 +5315,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap();
link_dir(&root, "outside-link", &base.join("outside"));
std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap();
root
}
let mut a = auto(&root);
let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
let listed = ok(&mut a, "link_list", json!({ "doc": source }));
fn assert_relative_gotor_destination(a: &mut Automation) {
let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
let listed = ok(a, "link_list", json!({ "doc": source }));
assert_eq!(listed["links"][0]["other"], "GoToR");
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["path"], "target.pdf");
assert_eq!(opened["destination_applied"], true);
assert_eq!(opened["destination"]["page"], 3);
@@ -5290,51 +5330,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
assert_eq!(opened["document"]["pages"], 3);
let target_id = opened["document"]["doc"].as_u64().unwrap();
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
}
let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
fn assert_absolute_gotor_policy(a: &mut Automation) {
let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
let before = a.session().docs().len();
for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] {
assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute")));
assert_eq!(a.session().docs().len(), before);
}
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true }));
assert_eq!(opened["path"], "target.pdf");
assert_eq!(opened["destination"]["page"], 2);
}
fn assert_unapplied_gotor_destinations(a: &mut Automation) {
for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] {
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["destination_applied"], false);
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
}
}
let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
fn assert_encrypted_gotor_passwords(a: &mut Automation) {
let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before);
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before);
let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
assert_eq!(opened["document"]["encrypted"], true);
}
fn assert_unsupported_gotor_targets(a: &mut Automation) {
for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] {
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}");
}
}
fn assert_gotor_root_boundaries(a: &mut Automation) {
for source_path in ["sub/escape.pdf", "symlink.pdf"] {
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened");
}
}
let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) {
let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
assert!(
matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
);
}
@@ -5887,8 +5939,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
fn digital_ids_signing_and_validation_through_tools() {
let dir = workdir("signing");
let mut a = auto(&dir);
create_signing_identity(&mut a);
let doc = sign_document_and_verify_trust(&mut a, &dir);
verify_signature_revisions_and_bundle(&mut a, doc, &dir);
}
fn create_signing_identity(a: &mut Automation) {
let id = ok(
&mut a,
a,
"sign_id_create",
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }),
);
@@ -5897,13 +5955,15 @@ fn digital_ids_signing_and_validation_through_tools() {
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
// Eight characters at least (#1847): seven is refused.
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
}
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 {
let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0);
// A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere).
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_))));
assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_))));
let store = ok(&mut a, "sign_windows_ids", json!({}));
let store = ok(a, "sign_windows_ids", json!({}));
let ids = store["ids"].as_array().unwrap();
assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64);
assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:")));
@@ -5917,7 +5977,7 @@ fn digital_ids_signing_and_validation_through_tools() {
Err(ToolError::InvalidArgs(_))
));
let r = ok(
&mut a,
a,
"sign_document",
json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
);
@@ -5929,33 +5989,37 @@ fn digital_ids_signing_and_validation_through_tools() {
assert!(dir.join("signed.pdf").exists());
// Trust the ID: valid; comment afterwards: still valid, change allowed.
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
let list = ok(a, "sign_list", json!({ "doc": doc }));
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0];
ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0];
assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed")));
doc
}
fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) {
// A full rewrite is refused for a signed document.
assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_))));
ok(&mut a, "doc_save", json!({ "doc": doc }));
ok(a, "doc_save", json!({ "doc": doc }));
// Three revisions: the original, the signature, the comment; the middle one is signed.
let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap();
assert_eq!(revs.len(), 3, "{revs:?}");
assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}");
let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1);
let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap();
assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment");
assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1);
assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_))));
assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
// The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one.
use pdfcraft_engine::sign::{cms, der, pkcs12};
let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw;
let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]);
std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap();
assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
}
/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file.
+1 -1
View File
@@ -1,7 +1,7 @@
use super::reader::find;
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
use super::xref::{Revision, XrefEntry};
use super::*;
use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared};
impl Document {
pub fn bytes(&self) -> &Arc<Vec<u8>> {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::reader::{find, generated_id};
use super::state::Credentials;
use super::xref::XrefIndex;
use super::*;
use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object};
impl Document {
/// A new document with an empty page tree (for split / extract / combine). It has no
+1 -1
View File
@@ -3,7 +3,7 @@ use super::state::SECTION_READS;
use super::xref::{
MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData,
};
use super::*;
use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared};
impl Document {
#[allow(clippy::type_complexity)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::xref::XrefEntry;
use super::*;
use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak};
thread_local! {
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, BTreeMap, Lexer};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XrefEntry {
+4 -1
View File
@@ -2,7 +2,10 @@ use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::*;
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
literal, matrix_number, n, shaped_glyph, win_ansi,
};
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
+4 -1
View File
@@ -1,5 +1,8 @@
use super::types::{AddedImage, AddedText};
use super::*;
use super::{
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
shape_thai,
};
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
+1 -1
View File
@@ -1,7 +1,7 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::*;
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::norm;
use super::*;
use super::{Content, MarkKind, ObjRef, Std14};
pub(super) const TAG: &str = "Added";
+1 -1
View File
@@ -5,7 +5,7 @@ use super::place::{
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
};
use super::*;
use super::{Document, EditError, Object, added, helvetica_width};
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, EditError, Object, Rgb, added};
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::marks::{MarkKind, matrix_number, n};
use super::*;
use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi};
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
pdfcraft_model::pages(doc)
+118 -75
View File
@@ -6664,12 +6664,124 @@ fn actual_text_pairs_past_the_marked_content_cap() {
}
}
/// #2992: the colour a page is painted with before anything else is its background, for exports
/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes
/// behind; a page that paints anything else first, a fill that doesn't cover the page, a
/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none.
fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
}
let xref_offset = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap()
}
fn assert_page_background_fill_cases(gs: &str) {
assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"one of the rectangles covers it"
);
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""),
Some([1.0, 0.0, 0.0]),
"nonzero, same winding: no hole"
);
assert_eq!(
page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""),
None,
"reversed by a mirroring cm"
);
assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(
page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""),
None,
"a page fill with a hole cut in it"
);
}
fn assert_page_background_transform_and_clip_cases() {
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""),
None,
"a turned fill short of the corners"
);
assert_eq!(
page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned fill over them"
);
// The same for a clip.
assert_eq!(
page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
None,
"a turned clip"
);
assert_eq!(
page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
Some([1.0, 0.0, 0.0]),
"two covering clips"
);
assert_eq!(
page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""),
None,
"the second clip doesn't cover"
);
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
}
/// #2992: page background detection follows the first full-page fill under text and ignores
/// partial, translucent, clipped, or unsupported fills.
#[test]
fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
let mut doc = fixture();
assert_eq!(page_background(&doc, 0).unwrap(), None, "text first");
add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap();
@@ -6680,77 +6792,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content");
assert!(page_background(&doc, 9).is_err());
let page = |content: &str, resources: &str| {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap()
};
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it");
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole");
assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm");
assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it");
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners");
assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them");
// The same for a clip.
assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip");
assert_eq!(
page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips");
assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover");
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
assert_page_background_fill_cases(gs);
assert_page_background_transform_and_clip_cases();
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::*;
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
+1 -1
View File
@@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock};
use super::fallback::MAX_TYPE3_GLYPHS;
use super::lines::{page_dict, text_lines};
use super::rewrite::rewrite_block_from;
use super::*;
use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession};
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
/// are never installed, copied into assets, or used as a font for another document.
+3 -1
View File
@@ -1,6 +1,8 @@
use super::interp::Ts;
use super::lines::page_dict;
use super::*;
use super::{
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
};
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
+283 -249
View File
@@ -1,6 +1,6 @@
use super::fallback::is_type3_fallback;
use super::rewrite::vertical_advance;
use super::*;
use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc};
/// Text and graphics state captured for rewriting.
#[derive(Clone, Debug, PartialEq)]
@@ -472,6 +472,280 @@ type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
type Invocation = (usize, Vec<u8>, Ts);
#[derive(Default)]
struct MarkedContent {
regions: Vec<Option<usize>>,
layers: Vec<Option<Vec<u8>>>,
actual_text: Vec<Option<(usize, usize, String)>>,
}
impl MarkedContent {
fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) {
// Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and
// inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
self.layers
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value {
Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()),
_ => None,
});
self.actual_text.push(logical.map(|text| (shown_count, index, text)));
}
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
self.regions.pop();
self.layers.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
}
struct TextRunState<'a> {
ts: &'a mut Ts,
tm: &'a mut Matrix,
tlm: &'a mut Matrix,
at_bt: &'a (usize, Ts),
bt: usize,
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
let ts = &mut *state.ts;
let tm = &mut *state.tm;
let tlm = &mut *state.tlm;
let at_bt = state.at_bt;
let bt = state.bt;
let marked = state.marked;
let carry = &mut *state.carry;
let out = &mut *state.out;
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
*tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm);
*tm = *tlm;
}
let Some((name, m)) = ts.font.clone() else { return Ok(()) };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
*tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)],
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm: *tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.regions.last().copied().flatten(),
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
Ok(())
}
pub(super) fn interpret(
doc: &Document,
ops: &[Op],
@@ -490,66 +764,17 @@ pub(super) fn interpret(
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
let mut marked = std::mem::take(&mut carry.marked);
let mut layers = std::mem::take(&mut carry.layers);
let mut actual: Vec<Option<(usize, usize, String)>> = Vec::new();
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
// Marked text paragraphs/headings are authoritative boundaries. Ignore
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
// A layer named in the resources is told apart by its name, an inline one by where it opens.
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec)));
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
actual.push(logical.map(|s| (out.len(), i, s)));
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
marked.pop();
layers.pop();
if let Some(Some((start, open, logical))) = actual.pop() {
let tail = out.split_off(start.min(out.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for shown in items {
first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count);
first.extra_ops.push(shown.op);
first.extra_ops.extend(shown.extra_ops);
first.rect = [
first.rect[0].min(shown.rect[0]),
first.rect[1].min(shown.rect[1]),
first.rect[2].max(shown.rect[2]),
first.rect[3].max(shown.rect[3]),
];
first.frame = union(first.frame, shown.frame);
first.ink.extend(shown.ink);
}
first.extra_ops.extend([open, i]);
// Only right-to-left ActualText is already in typing order and must stay
// out of the line's bidi recovery. Word and Chrome wrap Latin ligatures
// (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
out.push(first);
}
}
marked.end(i, &mut out);
}
b"q" => stack.push(ts.clone()),
b"Q" => {
@@ -650,207 +875,16 @@ pub(super) fn interpret(
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
let Some((name, m)) = ts.font.clone() else { continue };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still
// takes the one immediately before it, as the renderer and every other
// reader here do (#3569) -- not the first, which a reader counting operands
// from the front of a surplus list would otherwise take.
b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
// Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred
// on it horizontally; `half` is half the widest glyph.
let vertical = ts.vertical;
let (mut y_text, mut half) = (0.0, 0.0_f64);
// Where each word's glyphs start and end along the run (text space), and whether
// the next glyph continues the last word.
let mut words: Vec<(f64, f64)> = Vec::new();
let mut in_word = false;
let mut glyph_count = 0_usize;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
glyph_count = glyph_count.saturating_add(1);
let shown = m.text_of(code);
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
if let Some(w1) = vertical {
half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0);
if !blank {
let ink_end = y_text + w1 * ts.size;
let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
y_text += w1 * ts.size + spacing;
continue;
}
if !blank {
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
// A negative font size or `Tz` runs the glyphs backwards:
// the word is the union of its glyphs, whichever way they go.
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
match words.last_mut() {
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
_ => words.push((lo, hi)),
}
}
in_word = !blank;
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
if vertical.is_some() {
// Down by n/1000 em, not scaled by `Tz`.
let dy = -n / 1000.0 * ts.size;
if -dy > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
if dy.abs() > ts.size.abs() * 0.2 {
in_word = false;
}
y_text += dy;
continue;
}
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
}
}
}
}
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box from `a` to `b` along the run: from descent to ascent across it, or for
// vertical text, the widest glyph's width centred on the pen.
let span = |a: f64, b: f64| match vertical {
Some(_) => {
[trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)]
}
None => [
trm0.apply(a, ts.rise + m.descent * ts.size),
trm0.apply(b, ts.rise + m.descent * ts.size),
trm0.apply(a, ts.rise + m.ascent * ts.size),
trm0.apply(b, ts.rise + m.ascent * ts.size),
],
};
let corners = span(0.0, if vertical.is_some() { y_text } else { x_text });
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
// The same corners along and across the baseline: the run's own box, not its
// upright bounding box turned (which is wider by the box's height on a slant).
let dir = direction(&trm0);
let frame = corners
.iter()
.map(|&p| to_frame(dir, p))
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
span(a, b)
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
})
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
continue;
}
out.push(Shown {
op: i,
extra_ops: Vec::new(),
glyph_count,
// Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the
// preview divides by size × `Tz`, so `Tz` is put back here.
advance: if vertical.is_some() { y_text * ts.scale } else { x_text },
tracking: 0.0,
tm: Matrix([
tm.0[0],
tm.0[1],
tm.0[2],
tm.0[3],
tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2],
tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3],
]),
state: ts.clone(),
tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
frame,
ink,
baseline: start.1,
start_x: start.0,
end_x: end.0,
end_y: end.1,
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
visual_order: m.visual_order,
decodable,
scope: marked.last().copied().flatten(),
paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
});
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
show_text(op, i, &mut state)?;
}
_ => {}
}
}
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
carry.layers = layers;
carry.marked = marked.regions;
carry.layers = marked.layers;
positioned_tracking(&mut out);
Ok(out)
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::fallback::{same_font, same_paint};
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
use super::*;
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
impl TextLine {
/// The baseline's height in user space.
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,11 @@
//! Geometry-based regions and mixed-style rewriting of existing text.
use super::*;
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
};
#[path = "text_regions/grouping.rs"]
mod grouping;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3,7 +3,10 @@
//! another one's output; the commit writes into the document it was opened on, and refuses if
//! that document has changed since.
use super::*;
use super::{
BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from,
text_lines,
};
/// One paragraph being edited: the document as it was opened, and the page's lines then.
pub struct TextSession {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
/// A change to an existing page image.
#[derive(Clone, Debug, PartialEq)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table};
/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not
/// here: its wording is only the hole, because a batch carries its name as text (see
+24 -3
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
@@ -328,7 +331,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?;
pdfcraft_annot::add_text_replacement(
doc,
pdfcraft_annot::TextReplacement {
page: *page,
quads,
replacement: text,
author,
strike,
caret,
strike_meta: &strike_meta,
caret_meta: &caret_meta,
},
)?;
}
Edit::MarkAnnotation { page, index, marked, author } => {
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
@@ -338,7 +353,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
pdfcraft_annot::set_style(
doc,
*page,
*index,
pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() },
&cx.meta(),
)?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
+15 -13
View File
@@ -25,6 +25,17 @@ pub struct PreparedDocument {
document: Document,
}
struct DocumentOpenData<'a> {
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
info: DocInfo,
structure: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&'a str>,
xfa: Option<XfaLayout>,
}
impl OpenContext {
/// Prepare a document without mutating the session that supplied this context.
pub fn prepare_open(
@@ -241,7 +252,8 @@ impl Session {
};
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
let mut document =
Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa });
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
@@ -254,18 +266,8 @@ impl Session {
}
/// The last step of opening: build the document record and register it.
#[allow(clippy::too_many_arguments)]
fn build_document(
trust: &Arc<TrustStore>,
name: String,
path: Option<String>,
bytes: Arc<Vec<u8>>,
mut info: DocInfo,
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
password: Option<&str>,
xfa: Option<XfaLayout>,
) -> Document {
fn build_document(trust: &Arc<TrustStore>, data: DocumentOpenData<'_>) -> Document {
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
+8 -1
View File
@@ -1,4 +1,11 @@
use super::*;
#[cfg(test)]
use super::check_refresh_panic;
use super::{
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings,
render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts,
write_incremental, xfa, xfa_sync_datasets,
};
impl Session {
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
+5 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
source_kind, writable, write_full,
};
impl Session {
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard,
render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
};
impl Session {
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort,
apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
};
impl Session {
pub fn new() -> Self {
+83 -66
View File
@@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
assert!(doc.links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list");
}
metadata_and_comment_edits_keep_the_indexes(&mut session, id);
link_edits_rebuild_only_the_link_list(&mut session, id);
content_and_marker_edits_rebuild_the_markers(&mut session, id);
}
fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) {
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert!(session.get(id).unwrap().marks().is_empty());
@@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index");
}
fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) {
session
.apply(
id,
@@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() {
session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap();
assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes");
assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes");
}
fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) {
session
.apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } })
.unwrap();
@@ -8106,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
// Redo now succeeds cleanly.
assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page");
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
rolls_back_metadata_scope(&mut s, id);
rolls_back_protection_keys(&mut s, id);
}
/// Undo/redo of a metadata edit rolled back after a refresh panic.
fn rolls_back_metadata_scope(s: &mut Session, id: DocId) {
// 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`.
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap();
let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false };
@@ -8124,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
assert_eq!(s.get(id).unwrap().info.title, None);
assert_eq!(s.redo(id).unwrap().as_str(), "Change Title");
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft"));
}
/// Undo/redo of a protection edit rolled back after a refresh panic.
fn rolls_back_protection_keys(s: &mut Session, id: DocId) {
// 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting.
let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() };
s.apply(id, Edit::Protect(protect)).unwrap();
@@ -8720,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
bytes
}
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
#[test]
fn need_appearances_buttons_are_displayed_from_their_captions() {
let pdf = |need: bool| {
let flag = if need { "/NeedAppearances true " } else { "" };
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes()
};
let render = |bytes: Arc<Vec<u8>>| {
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
};
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
let (px, _) = page.rgba.as_chunks::<4>();
(200 - y1..200 - y0)
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
.count()
};
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
let shown = render(s.get(id).unwrap().display.clone());
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
let printed = render(Arc::new(pictures));
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
let dot = ink(page, (188, 108, 212, 132));
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
}
let saved = s.save_bytes(id).unwrap();
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
// Without /NeedAppearances the file's filled squares are shown.
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
let shown = render(s.get(id).unwrap().display.clone());
for x in [20, 100, 180] {
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
}
}
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
#[test]
fn need_appearances_buttons_are_displayed_from_their_captions() {
let pdf = |need: bool| {
let flag = if need { "/NeedAppearances true " } else { "" };
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes()
};
let render = |bytes: Arc<Vec<u8>>| {
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
};
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
let (px, _) = page.rgba.as_chunks::<4>();
(200 - y1..200 - y0)
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
.count()
};
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
let shown = render(s.get(id).unwrap().display.clone());
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
let printed = render(Arc::new(pictures));
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
let dot = ink(page, (188, 108, 212, 132));
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
}
let saved = s.save_bytes(id).unwrap();
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
// Without /NeedAppearances the file's filled squares are shown.
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
let shown = render(s.get(id).unwrap().display.clone());
for x in [20, 100, 180] {
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::Zip;
use super::as_html::esc;
use super::layout::{Image, Item, Page, Table, body_size, items};
use super::*;
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::as_docx::rgb8;
use super::as_html::allowed;
use super::layout::{Block, Item, Page, count_lookup_steps, items};
use super::*;
use std::collections::HashMap;
fn rtf_text(s: &str) -> String {
let mut o = String::new();
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use std::collections::HashMap;
/// A paragraph of a page.
#[derive(Clone, Debug, PartialEq)]
-2
View File
@@ -10,8 +10,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
mod pptx;
mod zip;
+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::{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 {
@@ -248,51 +196,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 +245,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() {
@@ -348,7 +373,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 +410,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 ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write};
use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request};
use super::*;
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
macro_rules! getter {
($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{
+1 -1
View File
@@ -1,6 +1,6 @@
use super::host::{arg, error, s, text};
use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION};
use super::*;
use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc};
/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups
/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.`
+6 -1
View File
@@ -1,6 +1,11 @@
#[cfg(not(target_arch = "wasm32"))]
use super::CLOCK_EVERY;
use super::ast::{Expr, Idx};
use super::value::{Val, num_text, parse_num};
use super::*;
use super::{
HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances,
resolve_som,
};
impl Interp<'_> {
pub(super) fn step(&mut self) -> R<()> {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Func, Rc};
// ── syntax ──────────────────────────────────────────────────────────────────────────────────
+47 -20
View File
@@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars
use super::parser::parse;
use super::value::{Val, fixed, is_numeric_text, parse_num};
use super::words::word_num;
use super::*;
use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit};
impl Interp<'_> {
/// A built-in function: its result is never a string past [`MAX_STRING`], and work on
@@ -22,6 +22,15 @@ impl Interp<'_> {
fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R<Val> {
let lower = name.to_ascii_lowercase();
let n = |v: f64| Ok(Val::Num(v));
if let Some(value) = self.date_builtin(&lower, args)? {
return Ok(value);
}
if let Some(value) = self.financial_builtin(&lower, args)? {
return Ok(value);
}
if let Some(value) = self.unit_builtin(&lower, args)? {
return Ok(value);
}
Ok(match lower.as_str() {
// ── arithmetic
"abs" => return n(self.arg_num(args, 0)?.abs()),
@@ -271,7 +280,24 @@ impl Interp<'_> {
let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect();
parse_num(&cleaned).map_or(Val::Null, Val::Num)
}
// ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight)
"ref" => {
let Some(e) = args.first() else { return Ok(Val::Null) };
self.eval(e)?
}
"eval" => {
let src = self.arg_str(args, 0)?;
self.charge((src.len() / 16) as u64)?;
let body = parse(&src)?;
let (_, v) = self.block(&body)?;
v
}
"get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")),
_ => return Err(format!("unknown function {name}")),
})
}
fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
let value = match lower {
"date" => {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -304,7 +330,7 @@ impl Interp<'_> {
let days = self.arg_num(args, 0)?;
let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() };
if !days.is_finite() || days.abs() >= MAX_DAYS {
return Ok(Val::Null);
return Ok(Some(Val::Null));
}
let (y, m, d) = civil_from_days(days as i64);
Val::Str(format_date(y, m, d, &pic))
@@ -326,7 +352,7 @@ impl Interp<'_> {
"num2time" | "num2gmtime" => {
let ms = self.arg_num(args, 0)?;
if !ms.is_finite() || ms < 0.0 {
return Ok(Val::Null);
return Ok(Some(Val::Null));
}
let s = (ms / 1000.0) as u64;
Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60))
@@ -344,7 +370,13 @@ impl Interp<'_> {
parts.next().and_then(|p| p.trim().parse::<f64>().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0);
Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round())
}
// ── financial
_ => return Ok(None),
};
Ok(Some(value))
}
fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
let value = match lower {
"fv" => {
let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?);
Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) })
@@ -428,7 +460,13 @@ impl Interp<'_> {
}
Val::Num((lo + hi) / 2.0 * 12.0)
}
// ── units and references
_ => return Ok(None),
};
Ok(Some(value))
}
fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R<Option<Val>> {
let value = match lower {
"unittype" => {
let s = self.arg_str(args, 0)?;
let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect();
@@ -459,19 +497,8 @@ impl Interp<'_> {
let target = if to.trim().is_empty() { unit.clone() } else { to };
Val::Num(v / per_in(&unit) * per_in(&target))
}
"ref" => {
let Some(e) = args.first() else { return Ok(Val::Null) };
self.eval(e)?
}
"eval" => {
let src = self.arg_str(args, 0)?;
self.charge((src.len() / 16) as u64)?;
let body = parse(&src)?;
let (_, v) = self.block(&body)?;
v
}
"get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")),
_ => return Err(format!("unknown function {name}")),
})
_ => return Ok(None),
};
Ok(Some(value))
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
use super::ast::{Expr, Stmt};
use super::value::{Val, is_numeric_text, num_text};
use super::*;
use super::{
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip,
descendants_named, instances, reset_names, resolve_som,
};
impl Interp<'_> {
// ── expressions
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::MAX_STRING;
// ── lexer ───────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
use super::ast::{Expr, Idx, Stmt};
use super::lexer::{Tok, Token, lex};
use super::*;
use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc};
struct Parser {
toks: Vec<Token>,
+1 -1
View File
@@ -2,7 +2,7 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie
use super::format::{printd, printf, printx, scand};
use super::host::{arg, function, host, s, text, text_line};
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
use super::*;
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
// ── app, util, console ──────────────────────────────────────────────────────────────────────
+4 -1
View File
@@ -1,6 +1,9 @@
use super::model::{DocInfo, FieldState, FieldType, Request};
use super::run::MAX_ARRAY_LENGTH;
use super::*;
use super::{
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell,
xfa,
};
pub(crate) struct Host {
pub(crate) fields: Arc<Vec<FieldState>>,
+1 -1
View File
@@ -1,7 +1,7 @@
use super::globals::{event_object, install};
use super::host::{Host, Shared, text};
use super::model::{DocInfo, Event, FieldState, Limits, Outcome};
use super::*;
use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string};
/// Longest script, in bytes.
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
+123 -50
View File
@@ -1017,6 +1017,14 @@ mod formcalc {
#[test]
fn built_in_functions_cover_the_common_set() {
assert_numeric_and_logical_builtins();
assert_string_builtins();
assert_format_and_date_builtins();
assert_financial_and_unit_builtins();
assert_builtin_errors();
}
fn assert_numeric_and_logical_builtins() {
assert_eq!(calc("Abs(-2.5)"), "2.5");
assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b");
assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb");
@@ -1034,6 +1042,9 @@ mod formcalc {
assert_eq!(calc(r#"Within(5, 1, 10)"#), "1");
assert_eq!(calc(r#"HasValue("")"#), "0");
assert_eq!(calc(r#"HasValue("x")"#), "1");
}
fn assert_string_builtins() {
assert_eq!(calc(r#"At("abcd", "c")"#), "3");
assert_eq!(calc(r#"Left("hello", 2)"#), "he");
assert_eq!(calc(r#"Right("hello", 2)"#), "lo");
@@ -1050,6 +1061,11 @@ mod formcalc {
assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two");
assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents");
assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent");
assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c");
assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b");
}
fn assert_format_and_date_builtins() {
assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50");
assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00");
assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024");
@@ -1065,6 +1081,9 @@ mod formcalc {
assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250");
assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000");
assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01");
}
fn assert_financial_and_unit_builtins() {
assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49");
assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25");
assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51");
@@ -1073,8 +1092,9 @@ mod formcalc {
assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1");
assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1");
assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72");
assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c");
assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b");
}
fn assert_builtin_errors() {
assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network"));
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
}
@@ -1147,6 +1167,13 @@ s"#
#[test]
fn form_objects_resolve_assign_and_record_effects() {
assert_formcalc_reads_fields_and_rows();
assert_formcalc_reads_runtime_properties();
assert_formcalc_records_primary_effects();
assert_formcalc_updates_instances_and_records_requests();
}
fn assert_formcalc_reads_fields_and_rows() {
// Fields deref to their values; `$` is the current field.
assert_eq!(calc("qty * price"), "10");
assert_eq!(calc("$.name"), "total");
@@ -1170,7 +1197,6 @@ s"#
assert_eq!(in_row("Sum(row[*].amount)"), "15");
assert_eq!(in_row("row[1].amount"), "5");
assert_eq!(in_row("row[-1].amount"), "10");
// Past the ends there is no object, so a property of it is an error (as in Acrobat).
let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate");
assert!(past.error.unwrap().contains("no object"));
assert_eq!(in_row("Exists(row[-5])"), "0");
@@ -1182,12 +1208,18 @@ s"#
assert_eq!(calc("table._row.count"), "2");
assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1");
assert_eq!(calc("HasValue(total)"), "0");
}
fn assert_formcalc_reads_runtime_properties() {
assert_eq!(calc("$host.numPages"), "3");
assert_eq!(calc("$layout.pageCount()"), "3");
assert_eq!(calc("$event.name"), "calculate");
assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5");
assert_eq!(calc("$.border.fill.color.value"), "");
assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name"));
}
fn assert_formcalc_records_primary_effects() {
// Assignment to a field (by name and by path), a property and a variable.
let o = go(
r#"page1.qty = 6
@@ -1215,7 +1247,9 @@ table._row.count"#,
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
]
);
// Instance managers: remove, setInstances, count assignment, and the floor of one.
}
fn assert_formcalc_updates_instances_and_records_requests() {
let o = go(
"table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count",
"form1[0].page1[0].go[0]",
@@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#,
);
assert_eq!(
o.effects,
vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())]
vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),]
);
}
@@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#,
#[test]
fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() {
let total = "form1[0].page1[0].total[0]";
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast);
assert!(run("while (1) do endwhile").error.unwrap().contains("looped"));
assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped"));
assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some());
assert_hostile_control_flow_is_bounded();
assert_hostile_numeric_inputs_are_safe();
assert_hostile_outputs_are_bounded();
assert_hostile_nesting_is_bounded();
assert_hostile_deadlines_and_parse_errors();
assert_hostile_instance_growth_is_bounded();
assert_hostile_message_count_is_bounded();
assert_hostile_small_stack_is_safe();
}
fn assert_hostile_control_flow_is_bounded() {
assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped"));
assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped"));
assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some());
// A 120k-term chain is refused at parse time (its tree would be as deep as it is long).
assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run("nothing.x = 1").error.unwrap().contains("unknown name"));
assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing"));
assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name"));
assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing"));
// Values go into fields only: a subform or the form itself is refused, nothing is recorded.
let o = run("details = \"junk\"");
let o = run_hostile_formcalc("details = \"junk\"");
assert!(o.error.unwrap().contains("not a field"));
assert!(o.effects.is_empty());
assert!(run("$form = \"junk\"").error.unwrap().contains("not a field"));
assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field"));
}
fn assert_hostile_numeric_inputs_are_safe() {
// Hostile numbers into the string functions.
assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX"));
assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc"));
assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some(""));
assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some(""));
assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none());
assert!(run(r#"Round(1e300, 1e300)"#).error.is_none());
assert!(run(r#"Choose(1e300, 1)"#).error.is_none());
assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX"));
assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc"));
assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some(""));
assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some(""));
assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none());
assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none());
assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none());
}
fn assert_hostile_outputs_are_bounded() {
// Strings can't grow past the cap through any function, and messages and values are clipped.
assert!(
run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)")
run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)")
.error
.unwrap()
.contains("too long")
);
assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
let o = run("$host.messageBox(Space(100000))");
assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
let o = run_hostile_formcalc("$host.messageBox(Space(100000))");
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len());
let o = run("qty = Space(100000)");
let o = run_hostile_formcalc("qty = Space(100000)");
assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len());
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
}
fn assert_hostile_nesting_is_bounded() {
// A comment of dashes is not deep nesting.
assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2"));
assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2"));
// Parentheses don't reset the chain count: a tree is never deeper than the cap.
let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string();
for _ in 0..30 {
nested = format!("({nested}{})", "+1".repeat(40));
}
assert!(run(&nested).error.unwrap().contains("too long"));
assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32"));
assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long"));
assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32"));
// Nested statements inside recursion count against the depth limit.
let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20));
assert!(run(&deep).error.unwrap().contains("nested"));
assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested"));
}
fn assert_hostile_deadlines_and_parse_errors() {
let total = "form1[0].page1[0].total[0]";
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
let doc = XfaDoc::default();
// A deadline already passed stops the script at its first look at the clock.
let o = crate::formcalc::run_formcalc_at(
"for i = 1 upto 100000 do Eval(\"1\") endfor",
@@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#,
Some(std::time::Duration::ZERO),
);
assert!(o.error.unwrap().contains("too long"));
assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
assert!(run("Space(1e12)").error.is_none());
assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long"));
assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None);
assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None);
assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None);
assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some(""));
assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name"));
assert!(run("\"unterminated").error.unwrap().contains("unterminated"));
assert!(run("1 ) 2").error.is_some());
assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none());
assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
assert!(run_hostile_formcalc("Space(1e12)").error.is_none());
assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long"));
assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None);
assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None);
assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None);
assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some(""));
assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name"));
assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated"));
assert!(run_hostile_formcalc("1 ) 2").error.is_some());
assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none());
}
fn assert_hostile_instance_growth_is_bounded() {
// Adding rows forever stops at the instance cap with the effects it managed.
let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count");
let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count");
assert!(o.error.unwrap().contains("looped"));
assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len());
let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count");
let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count");
assert_eq!(o.error, None);
assert_eq!(o.result.as_deref(), Some("1002"));
}
fn assert_hostile_message_count_is_bounded() {
// Message boxes past the cap are dropped and noted.
let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor");
let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor");
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100);
assert!(!o.notes.is_empty());
}
fn assert_hostile_small_stack_is_safe() {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
// On a small stack (the wasm build's), the limits stop the script before the stack does.
let small = std::thread::Builder::new()
.stack_size(1 << 20)
@@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#,
);
assert!(o.abandoned || o.error.is_some(), "{o:?}");
}
fn run_hostile_formcalc(script: &str) -> XfaOutcome {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
run_formcalc(script, &ev, &doc, &form(), fast)
}
}
mod session {
+4 -1
View File
@@ -2,7 +2,10 @@ use super::host::host;
use super::instances::instance_manager;
use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names};
use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink};
use super::*;
use super::{
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand,
text,
};
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
+1 -1
View File
@@ -2,7 +2,7 @@ use super::model::{
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
effect_key, payload,
};
use super::*;
use super::{Context, HashMap, JsResult, RefCell, error};
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
pub(crate) struct HNode {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::host::{HNode, XHost, host};
use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect};
use super::nodes::{instances, node_object};
use super::*;
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text};
pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult<JsValue> {
let vals: Vec<JsValue> = items.iter().map(|i| JsValue::from(*i as f64)).collect();
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::HashSet;
/// What kind of template object a node is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+4 -1
View File
@@ -2,7 +2,10 @@ use super::globals::{recalculate, relayout};
use super::host::{HNode, XHost, host};
use super::instances::{instance_manager, list_object};
use super::model::{MAX_DEPTH, XfaEffect};
use super::*;
use super::{
Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function,
js_string, s, text,
};
// ── node objects ────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,7 +1,7 @@
use super::globals::install;
use super::host::{Shared, XHost};
use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result};
use super::*;
use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text};
/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`.
pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome {
+1 -1
View File
@@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() {
let (r, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap();
let (_, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.int(b"VendorKey"), Some(42));
let ap = d.resolve(dict.get(b"AP").unwrap());
+1 -1
View File
@@ -1,5 +1,5 @@
use super::copy::Copier;
use super::*;
use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries};
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.
pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level};
use super::*;
use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild};
/// A new document containing copies of `pages` from `src` (Extract Pages / Split).
/// Document information (title, author…) is carried over, and so is the print standard the
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest};
/// Keys that must not be followed when copying: they point back up or across the source
/// document, and following them would drag in unrelated objects.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::copy::WIDGET_KEYS;
use super::*;
use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk};
/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names
/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of
+151 -87
View File
@@ -514,99 +514,163 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
}
}
for (r, o) in &objs {
let Some(d) = dict_of(o) else { continue };
let ty = d.name(b"Type");
let sub = d.name(b"Subtype");
// Actions anywhere (structure elements also have /S: their role), with the actions they
// chain to and those written directly in an /A or /OpenAction.
let own = (d.get(b"S").is_some() && is_action(d)).then_some(d);
let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict()));
for a in [own].into_iter().chain(direct).flatten() {
if let Some(s) = chain_forbidden(doc, a) {
issue("6.5.1", "A {} action isn't allowed", Some(s), None, true);
}
}
// Annotations.
if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) {
let page = page_of(*r);
let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default();
if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true);
continue;
}
let f = d.int(b"F").unwrap_or(0);
if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) {
issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true);
}
let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some());
if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) {
let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| {
let v: Vec<f64> = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect();
v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6
});
if !zero {
issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false);
}
}
if d.get(b"AA").is_some() {
issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true);
}
}
// Form fields with additional actions (non-widget fields).
if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") {
issue("6.5.2", "A form field has additional actions", None, None, true);
}
// Fonts.
if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() {
check_font(doc, d, &mut issue);
}
// Streams.
if let Object::Stream(s) = &**o {
let filters: Vec<Vec<u8>> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) {
Some(f) => match &*f {
Object::Name(n) => vec![n.clone()],
Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(),
_ => Vec::new(),
},
None => Vec::new(),
};
if filters.iter().any(|f| f == b"LZWDecode") {
issue("6.1.7.2", "A stream uses LZW compression", None, None, false);
}
if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() {
issue("6.1.7.1", "A stream refers to an external file", None, None, false);
}
if sub == Some(b"Image") {
if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) {
issue("6.2.8", "An image asks to be interpolated", None, None, true);
}
if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() {
issue("6.2.8", "An image has alternates or OPI information", None, None, true);
}
}
if sub == Some(b"PS") {
issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false);
}
}
// Embedded files.
if ty == Some(b"Filespec") && d.get(b"EF").is_some() {
match level {
Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false),
Level::A3b => {
if d.get(b"AFRelationship").is_none() {
issue("6.8", "An embedded file has no AFRelationship", None, None, true);
}
}
}
}
}
check_objects(doc, level, &objs, &page_of, &mut issue);
// One message per kind of problem is enough for repeated ones (as preflight summaries do).
let mut seen = std::collections::HashSet::new();
out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page)));
out
}
fn check_objects(
doc: &Document,
level: Level,
objects: &[(ObjRef, std::sync::Arc<Object>)],
page_of: &impl Fn(ObjRef) -> Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
for (r, o) in objects {
let Some(d) = dict_of(o) else { continue };
check_one_object(doc, level, *r, d, o, page_of, issue);
}
}
fn check_one_object(
doc: &Document,
level: Level,
reference: ObjRef,
dict: &Dict,
object: &Object,
page_of: &impl Fn(ObjRef) -> Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let ty = dict.name(b"Type");
let subtype = dict.name(b"Subtype");
check_actions(doc, dict, issue);
if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) {
// A forbidden annotation stops every later check for its object, as the scan did before
// it was split into helpers: the rest of its own checks and the form field, font, stream
// and embedded file below all came after that `continue`.
return;
}
if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") {
issue("6.5.2", "A form field has additional actions", None, None, true);
}
if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() {
check_font(doc, dict, issue);
}
if let Object::Stream(stream) = object {
check_stream(doc, stream, subtype, issue);
}
if ty == Some(b"Filespec") && dict.get(b"EF").is_some() {
check_embedded_file(level, dict, issue);
}
}
fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict);
let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict()));
for action in [own].into_iter().chain(direct).flatten() {
if let Some(name) = chain_forbidden(doc, action) {
issue("6.5.1", "A {} action isn't allowed", Some(name), None, true);
}
}
}
fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool {
ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some())
}
fn check_annotation(
doc: &Document,
dict: &Dict,
subtype: Option<&[u8]>,
page: Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) -> bool {
let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default();
if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) {
issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true);
return true;
}
let flags = dict.int(b"F").unwrap_or(0);
if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) {
issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true);
}
check_annotation_appearance(doc, dict, subtype, &name, page, issue);
if dict.get(b"AA").is_some() {
issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true);
}
false
}
fn check_annotation_appearance(
doc: &Document,
dict: &Dict,
subtype: Option<&[u8]>,
name: &str,
page: Option<usize>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let has_appearance = dict
.get(b"AP")
.map(|appearance| doc.resolve(appearance))
.and_then(|appearance| appearance.as_dict().cloned())
.is_some_and(|appearance| appearance.get(b"N").is_some());
if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) {
return;
}
let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| {
let values: Vec<f64> = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect();
values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6
});
if !zero_area {
issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false);
}
}
fn check_stream(
doc: &Document,
stream: &pdfcraft_cos::Stream,
subtype: Option<&[u8]>,
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let filters: Vec<Vec<u8>> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) {
Some(filter) => match &*filter {
Object::Name(name) => vec![name.clone()],
Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(),
_ => Vec::new(),
},
None => Vec::new(),
};
if filters.iter().any(|filter| filter == b"LZWDecode") {
issue("6.1.7.2", "A stream uses LZW compression", None, None, false);
}
if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() {
issue("6.1.7.1", "A stream refers to an external file", None, None, false);
}
if subtype == Some(b"Image") {
if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) {
issue("6.2.8", "An image asks to be interpolated", None, None, true);
}
if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() {
issue("6.2.8", "An image has alternates or OPI information", None, None, true);
}
}
if subtype == Some(b"PS") {
issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false);
}
}
fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
match level {
Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false),
Level::A3b if dict.get(b"AFRelationship").is_none() => {
issue("6.8", "An embedded file has no AFRelationship", None, None, true);
}
Level::A3b => {}
}
}
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
let sub = d.name(b"Subtype");
if sub == Some(b"Type3") {

Some files were not shown because too many files have changed in this diff Show More