Merge tested main for v0.21.1

This commit is contained in:
Dylan M. Taylor
2026-11-11 20:40:29 -05:00
committed by GitHub
60 changed files with 2193 additions and 306 deletions
+4
View File
@@ -29,6 +29,10 @@ jobs:
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+6 -2
View File
@@ -24,13 +24,17 @@ jobs:
name: fuzz (mutation, 15 min)
runs-on: ubuntu-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
with:
toolchain: stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
with:
key: fuzz
- name: Fetch the pdf.js corpus (seeds)
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "20 2 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -30,6 +34,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: pdfcraft
app-title: PdfCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
Generated
+41 -40
View File
@@ -1289,7 +1289,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1299,6 +1299,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.6",
"semver",
"serde",
"serde_json",
@@ -4263,7 +4264,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"chrono",
"craft-update",
@@ -4288,7 +4289,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4299,7 +4300,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4311,7 +4312,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4322,7 +4323,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4346,7 +4347,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4364,11 +4365,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.21.0"
version = "0.21.1"
[[package]]
name = "pdfcraft-content"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4376,7 +4377,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4393,7 +4394,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"encoding_rs",
"hayro-ccitt",
@@ -4413,7 +4414,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"aes",
"md-5",
@@ -4426,7 +4427,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"hayro-syntax",
"icu_segmenter",
@@ -4445,7 +4446,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -4489,14 +4490,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"fax",
"flate2",
@@ -4509,7 +4510,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4519,7 +4520,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4532,11 +4533,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.21.0"
version = "0.21.1"
[[package]]
name = "pdfcraft-js"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"boa_engine",
"chrono",
@@ -4544,7 +4545,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4557,14 +4558,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4579,7 +4580,7 @@ dependencies = [
[[package]]
name = "pdfcraft-ocr-worker"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"js-sys",
"pdfcraft-ocr",
@@ -4589,7 +4590,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4606,11 +4607,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.21.0"
version = "0.21.1"
[[package]]
name = "pdfcraft-organize"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4621,7 +4622,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4632,7 +4633,7 @@ dependencies = [
[[package]]
name = "pdfcraft-prc"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"flate2",
"proptest",
@@ -4641,7 +4642,7 @@ dependencies = [
[[package]]
name = "pdfcraft-prc-render"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"flate2",
"pdfcraft-prc",
@@ -4650,7 +4651,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4661,7 +4662,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"craft-print",
"log",
@@ -4676,7 +4677,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"aho-corasick",
"pdfcraft-annot",
@@ -4700,7 +4701,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4724,7 +4725,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4763,11 +4764,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.21.0"
version = "0.21.1"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"arboard",
"base64 0.23.1",
@@ -4826,7 +4827,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -4841,7 +4842,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"base64 0.23.1",
"image",
@@ -4855,7 +4856,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.21.0"
version = "0.21.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-annot",
+2 -2
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.21.0"
version = "0.21.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -30,7 +30,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.21.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.21.0" }
+26 -3
View File
@@ -206,7 +206,9 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
let mut metrics: HashMap<Vec<u8>, std::rc::Rc<Metrics>> = HashMap::new();
// Built once per font object, however many resource names alias it.
let mut by_font: HashMap<ObjRef, std::rc::Rc<Metrics>> = HashMap::new();
// The id each open marked-content sequence is in: its own, else the enclosing one's.
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = HashMap::new();
@@ -255,9 +257,30 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
gs.font = op.name(0).map(<[u8]>::to_vec);
if let Some(name) = op.name(0)
&& !metrics.contains_key(name)
&& let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
{
metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f));
// Keyed by the object at the end of the reference chain, so names that reach one
// font through different chains share its metrics (the hop limit is resolve's).
let font = fonts.get(name).and_then(|o| {
let (mut cur, mut last) = (o.clone(), None);
for _ in 0..16 {
let Object::Ref(r) = cur else { break };
last = Some(r);
cur = (*doc.get(r)).clone();
}
last
});
let built = match font.and_then(|r| by_font.get(&r)) {
Some(m) => Some(m.clone()),
None => {
fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().map(|d| std::rc::Rc::new(Metrics::from_dict(doc, d))))
}
};
if let Some(m) = built {
if let Some(r) = font {
by_font.insert(r, m.clone());
}
metrics.insert(name.to_vec(), m);
}
}
}
b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0),
+47 -22
View File
@@ -857,19 +857,38 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
/// Most stroke points a stored drawing may have for resizing or erasing it. Counted as each
/// stroke is resolved, so one stroke array referenced again and again can't expand past it.
const MAX_STORED_INK_POINTS: usize = 1_000_000;
/// Most points the eraser densifies a drawing into before testing them against its path.
const MAX_ERASE_SAMPLES: usize = 4_000_000;
/// The strokes of an ink annotation's `/InkList`, at most [`MAX_STORED_INK_POINTS`] points.
fn ink_strokes(doc: &Document, d: &Dict) -> Result<Vec<Vec<[f64; 2]>>, AnnotError> {
let too_many = || AnnotError::Invalid(format!("the drawing has more than {MAX_STORED_INK_POINTS} points"));
let list = d.get(b"InkList").map(|l| doc.resolve(l));
let mut points = 0usize;
let mut strokes = Vec::new();
for s in list.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default() {
let stroke = doc.resolve(s);
let Some(a) = stroke.as_array() else {
strokes.push(Vec::new());
continue;
};
// Charged before the coordinates are read: a shared array costs each time it is listed.
points = points.saturating_add(a.len() / 2);
if points > MAX_STORED_INK_POINTS {
return Err(too_many());
}
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
strokes.push(v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
}
Ok(strokes)
}
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
})
.collect();
let strokes = ink_strokes(doc, &d)?;
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
};
@@ -1475,17 +1494,7 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]
if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 {
return Err(AnnotError::Invalid("invalid eraser path".into()));
}
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
})
.collect();
let strokes = ink_strokes(doc, &d)?;
// Distance from a point to the eraser's path (segments, or a single point).
let near = |p: [f64; 2]| -> bool {
if path.len() == 1 {
@@ -1501,17 +1510,27 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]
};
let mut kept: Vec<Vec<[f64; 2]>> = Vec::new();
let mut erased = false;
let mut samples = 0usize;
for s in &strokes {
// Densify so long segments are cut where the eraser crosses them.
let mut pts: Vec<[f64; 2]> = Vec::new();
for w in s.windows(2) {
let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize;
samples = samples.saturating_add(n);
if samples > MAX_ERASE_SAMPLES {
return Err(AnnotError::Invalid("the drawing is too large to erase at this eraser size".into()));
}
for k in 0..n {
let t = k as f64 / n as f64;
pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]);
}
}
if let Some(last) = s.last() {
// The final point is a sample too.
samples = samples.saturating_add(1);
if samples > MAX_ERASE_SAMPLES {
return Err(AnnotError::Invalid("the drawing is too large to erase at this eraser size".into()));
}
pts.push(*last);
}
let mut run: Vec<[f64; 2]> = Vec::new();
@@ -1537,6 +1556,12 @@ pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]
delete_annotation(doc, page, index)?;
return Ok(true);
}
// The retained runs are stored as the new `/InkList`: past the cap, writing them would leave a
// drawing that later `resize_ink` and `erase_ink` refuse to read.
let points: usize = kept.iter().map(Vec::len).sum();
if points > MAX_STORED_INK_POINTS {
return Err(AnnotError::Invalid(format!("erasing would leave a drawing with more than {MAX_STORED_INK_POINTS} points")));
}
let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0);
let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0);
doc.update_dict(r, |d| {
+67
View File
@@ -4797,6 +4797,73 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
}
}
/// A drawing whose /InkList lists one stroke array over and over is refused once its points
/// pass the cap, counted as each stroke is resolved, before they are collected or scaled.
#[test]
fn ink_points_are_counted_as_strokes_resolve() {
let numbers: String = (0..2000).map(|k| format!(" {}", k % 100)).collect();
let refs = " 5 0 R".repeat(501);
let doc_with = |refs: &str| {
doc_of(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /InkList [{refs}] >>"),
format!("[{numbers}]"),
])
};
// 501 × 1,000 points is under the cap: the points are read (this bare drawing then has no
// appearance it can be redrawn with, which is another matter).
let mut doc = doc_with(&refs);
let under = set_rect(&mut doc, 0, 0, [0.0, 0.0, 50.0, 50.0], &meta(""));
assert!(!matches!(&under, Err(AnnotError::Invalid(m)) if m.contains("points")), "{under:?}");
// 1,001 × 1,000 points is over it.
let mut doc = doc_with(&" 5 0 R".repeat(1001));
match set_rect(&mut doc, 0, 0, [0.0, 0.0, 50.0, 50.0], &meta("")) {
Err(AnnotError::Invalid(m)) => assert!(m.contains("more than 1000000 points"), "{m}"),
other => panic!("refused: {other:?}"),
}
match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 1.0, &meta("")) {
Err(AnnotError::Invalid(m)) => assert!(m.contains("more than 1000000 points"), "{m}"),
other => panic!("refused: {other:?}"),
}
}
/// The eraser's densified samples have a total: a tiny eraser over long strokes is refused
/// instead of interpolating millions of points.
#[test]
fn the_eraser_densifies_within_a_total() {
let zigzag: String = (0..2100).map(|k| format!(" {} {}", if k % 2 == 0 { 0 } else { 5000 }, k)).collect();
let mut doc = doc_of(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 5000 2100] /InkList [[{zigzag}]] >>"),
]);
match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 0.01, &meta("")) {
Err(AnnotError::Invalid(m)) => assert!(m.contains("too large to erase"), "{m}"),
other => panic!("refused: {other:?}"),
}
}
/// Each stroke's final point is a sample too: 500,000 strokes of two points at 8 samples a segment
/// are 4,000,000 densified samples and as many final points again, past the total.
#[test]
fn the_eraser_counts_each_strokes_final_point() {
let refs = " 5 0 R".repeat(500_000);
let mut doc = doc_of(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /InkList [{refs}] >>"),
"[0 0 8 0]".into(),
]);
match erase_ink(&mut doc, 0, 0, &[[1.0, 1.0]], 2.0, &meta("")) {
Err(AnnotError::Invalid(m)) => assert!(m.contains("too large to erase"), "{m}"),
other => panic!("refused: {other:?}"),
}
}
/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's
/// catalog says so, and a newer one is left as it is.
#[test]
+33
View File
@@ -14042,6 +14042,39 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
}
}
/// A page that names one font under 300 resource names builds its metrics once: each alias
/// used to build (and keep) a set of its own.
#[test]
fn font_aliases_share_one_set_of_metrics() {
let fonts: String = (0..300).map(|i| format!(" /A{i} 5 0 R")).collect();
let content: String = format!("BT 72 700 Td {} ET", (0..300).map(|i| format!("/A{i} 12 Tf (x) Tj ")).collect::<String>());
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] /Contents 4 0 R /Resources << /Font <<{fonts} >> >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
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(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
text::METRICS_BUILT.with(|n| n.set(0));
let lines = text::text_lines(&doc, 0).unwrap();
assert!(lines.iter().map(|l| l.text.matches('x').count()).sum::<usize>() >= 300, "{lines:?}");
let built = text::METRICS_BUILT.with(std::cell::Cell::get);
assert!(built < 10, "metrics built {built} times for one font");
}
/// Each `Tj`/`TJ` moves the text matrix by the width of its text. A run shown later in the same
/// text object without a `Td` or `Tm` of its own (a raised run in another colour, a column after
/// a `TJ` gap) is placed by those advances. Rewriting the paragraph before it must keep them, as
+2
View File
@@ -54,6 +54,8 @@ pub(crate) use fallback::{SOURCE_SPACE_CALLS, take_source_character_measurements
#[cfg(test)]
use fallback::{add_type3_font, is_type3_fallback};
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
#[cfg(test)]
pub(crate) use interp::METRICS_BUILT;
use interp::{Origin, Paint, union};
#[cfg(test)]
pub(crate) use lines::dropped_advances_within;
+2 -2
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::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession};
use super::{DecodeBudget, Dict, Document, EditError, Object, TextLine, TextSession};
/// An embedded source font or an approved bundled document-font choice.
/// Source bytes stay in memory and are never installed or copied into another document.
@@ -45,7 +45,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
budget -= bytes.len();
result.push(TextEditorFont {
resource: String::from_utf8_lossy(name).into_owned(),
face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(),
face: pdfcraft_fonts::pdf::base_font_name(doc, &font).rsplit('+').next().unwrap_or_default().to_owned(),
bytes,
choice_only: false,
});
+21 -1
View File
@@ -664,6 +664,13 @@ impl Carry {
/// The advance is an error message when the font's writing mode can't be read.
type CachedFont = (Rc<Metrics>, Option<String>, Result<Option<f64>, String>);
#[cfg(test)]
thread_local! {
/// Font metrics built by [`interpret`] on this thread, for the test that keeps font aliases
/// sharing one set.
pub(crate) static METRICS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A `Do` met while interpreting: its operator index, XObject name and the state there.
type Invocation = (usize, Vec<u8>, Ts);
@@ -976,6 +983,7 @@ pub(super) fn interpret(
let fonts_res = super::resource_dict(doc, resources, b"Font", &mut held_fonts).unwrap_or(&none);
let states = super::resource_dict(doc, resources, b"ExtGState", &mut held_states).unwrap_or(&none);
let mut out: Vec<Shown> = Vec::new();
let mut by_font: HashMap<pdfcraft_cos::ObjRef, CachedFont> = HashMap::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack);
@@ -1105,11 +1113,23 @@ pub(super) fn interpret(
ts.size = op.num(1).unwrap_or(ts.size);
if let Some(name) = op.name(0) {
let (m, face, vertical) = cache.entry(name.to_vec()).or_insert_with(|| {
// Names that refer to one font object share what was built for it: a
// thousand aliases of one font are one set of metrics, not a thousand.
let font = fonts_res.get(name).and_then(Object::as_ref);
if let Some(built) = font.and_then(|r| by_font.get(&r)) {
return built.clone();
}
#[cfg(test)]
METRICS_BUILT.with(|n| n.set(n.get() + 1));
let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d));
let vertical = dict.as_ref().map_or(Ok(None), |d| vertical_advance(doc, d).map_err(|e| e.to_string()));
let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone());
(Rc::new(metrics), face, vertical)
let built: CachedFont = (Rc::new(metrics), face, vertical);
if let Some(r) = font {
by_font.insert(r, built.clone());
}
built
});
ts.font = Some((name.to_vec(), m.clone()));
ts.face = face.clone();
+33 -14
View File
@@ -958,41 +958,60 @@ pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<
/// the document as it was.
pub(crate) fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
marks: &[(pdfcraft_cos::ObjRef, pdfcraft_redact::Mark)],
overlays: &[pdfcraft_cos::ObjRef],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
use pdfcraft_cos::Object;
use pdfcraft_redact::RedactError;
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
let mut shown = after.clone();
let model = pdfcraft_model::pages(&shown);
// A batch can move pages after the marks were collected: a mark is found by the page object it
// was placed on, not by the index that page had when it was drawn. A page the batch deleted
// takes its marks with it, so they are not checked against whatever page now holds the index.
// One index, built once: each mark is resolved against it instead of scanning every page,
// and the marks are grouped by resolved page in the same pass.
let mut index: std::collections::HashMap<pdfcraft_cos::ObjRef, usize> = std::collections::HashMap::new();
for (i, p) in model.iter().enumerate() {
index.entry(p.obj).or_insert(i);
}
let mut marked: Vec<usize> = Vec::new();
let mut rects_of: std::collections::HashMap<usize, Vec<[f64; 4]>> = std::collections::HashMap::new();
for (obj, m) in marks {
let Some(&pi) = index.get(obj) else { continue };
if !rects_of.contains_key(&pi) {
marked.push(pi);
}
rects_of.entry(pi).or_default().extend(m.rects.iter().map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]));
}
marked.sort_unstable();
marked.dedup();
if marked.is_empty() {
return Ok(());
}
let mut shown = after.clone();
let model = pdfcraft_model::pages(&shown);
for &pi in &marked {
let Some(page) = model.get(pi) else { continue };
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
let kept: Vec<Object> = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect();
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
}
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
let bytes = Arc::new(write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?);
// Page geometry comes from the document as it is now written: a page moved earlier in the batch
// brings its own size and rotation with it.
let pages = pdfcraft_render::inspect(bytes.clone(), password).map_err(|e| EditError::Write(e.to_string()))?.pages;
// The model counts one entry per `/Kids` entry; the renderer one per page it draws. If they
// disagree, a mark's index could land on another page's text, and the check cannot confirm
// the redaction.
if pages.len() != model.len() {
return Err(RedactError::AmbiguousPages.into());
}
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, config);
let mut residue = 0;
for &pi in &marked {
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
// A page that cannot be read back cannot be confirmed clean.
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
let rects: Vec<[f64; 4]> = marks
.iter()
.filter(|m| m.page == pi)
.flat_map(|m| m.rects.iter())
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
.collect();
let rects = rects_of.get(&pi).cloned().unwrap_or_default();
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
let area = (x1 - x0) * (y1 - y0);
+25 -2
View File
@@ -253,6 +253,11 @@ pub(crate) struct EditCtx {
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
/// leaves out.
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
/// The marks the edit's applied redactions took out (a batch's included), as they were when
/// each apply ran, for the renderer's check.
/// Each applied mark with the page object it was drawn on, which a later page move changes the
/// index of but not the identity.
pub(crate) redaction_marks: Vec<(pdfcraft_cos::ObjRef, pdfcraft_redact::Mark)>,
/// What the edit's applied redactions left as it was that still holds the removed text.
pub(crate) redaction_elsewhere: Option<pdfcraft_redact::Elsewhere>,
/// What became of the PDF/A and PDF/UA identification when the edit's applied redactions
@@ -310,6 +315,7 @@ impl EditCtx {
rng: getrandom::fill,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
redaction_marks: Vec::new(),
redaction_elsewhere: None,
redaction_identification: pdfcraft_redact::Identification::default(),
prepared: None,
@@ -529,7 +535,17 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
Edit::DuplicatePages { pages, at } => {
// A copy of a page cannot be tied back to the marks an earlier redaction of this edit
// applied: text put back under a mark would sit on the copy, unchecked. Fail closed.
if !cx.redaction_marks.is_empty() {
return Err(EditError::Invalid(
"a batch that applies redactions can't also copy pages: the copies can't be checked against the marks; the document was not changed"
.into(),
));
}
pdfcraft_organize::duplicate_pages(doc, pages, *at)?
}
Edit::ReplacePages { pages, name, bytes, src_pages } => {
let src = crate::sources::open_source_into(name, bytes, doc)?;
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
@@ -991,8 +1007,15 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
pdfcraft_edit::add_content(doc, *page, &content)?;
}
Edit::ApplyRedactions { pages } => {
let model = pdfcraft_model::pages(doc);
cx.redaction_marks.extend(
pdfcraft_redact::marks(doc)
.into_iter()
.filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page)))
.filter_map(|m| model.get(m.page).map(|p| (p.obj, m))),
);
let report = pdfcraft_redact::apply(doc, pages.as_deref())?;
cx.redaction_overlays = report.overlays;
cx.redaction_overlays.extend(report.overlays);
cx.redaction_elsewhere = Some(report.still_holding);
cx.redaction_identification = report.identification;
}
+15 -5
View File
@@ -185,6 +185,7 @@ impl Session {
}
cx.renamed_fields.clear();
cx.redaction_overlays.clear();
cx.redaction_marks.clear();
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
run_checked_edit(&mut next, &edit, &mut cx)?;
// A field's enter or exit scripts that changed nothing leave no edit behind (no undo step,
@@ -195,11 +196,20 @@ impl Session {
return Ok(None);
}
// Redaction removed what its own interpreter places under the marks. The marks were drawn
// over the page as the renderer shows it, so the renderer has the last word.
if let Edit::ApplyRedactions { pages } = &edit {
let marks: Vec<pdfcraft_redact::Mark> =
pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect();
guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref()))
// over the page as the renderer shows it, so the renderer has the last word: on every
// apply the edit ran, a batch's included.
if !cx.redaction_marks.is_empty() {
// A batch that deletes a marked page and adds pages cannot be tied back to the marks:
// a copy of a page may carry the text the redaction removed from the original only.
// Fail closed.
let before: std::collections::HashSet<_> = pdfcraft_model::pages(&editor.cos).iter().map(|p| p.obj).collect();
let after: std::collections::HashSet<_> = pdfcraft_model::pages(&next).iter().map(|p| p.obj).collect();
if cx.redaction_marks.iter().any(|(o, _)| !after.contains(o)) && after.iter().any(|o| !before.contains(o)) {
return Err(EditError::Invalid(
"a batch that deletes a page with a redaction mark and adds pages can't be verified; the document was not changed".into(),
));
}
guard(|| shown_under_redactions(&next, &cx.redaction_marks, &cx.redaction_overlays, doc.password.as_deref()))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
}
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
+63 -2
View File
@@ -3542,9 +3542,10 @@ fn the_renderer_check_refuses_text_still_shown_under_a_mark() {
mark_shown_text(&mut s, id, "SECRET42");
let doc = s.get(id).unwrap();
let mut cos = doc.editor.as_ref().unwrap().cos.clone();
let marks = pdfcraft_redact::marks(&cos);
let model = pdfcraft_model::pages(&cos);
let marks: Vec<_> = pdfcraft_redact::marks(&cos).into_iter().filter_map(|m| model.get(m.page).map(|p| (p.obj, m))).collect();
let refused = |cos: &pdfcraft_cos::Document| {
let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None);
let shown = shown_under_redactions(cos, &marks, &[], None);
matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0)
};
assert!(refused(&cos));
@@ -13778,6 +13779,66 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() {
}
}
/// The renderer's check covers a redaction applied inside a batch too, with the marks as they
/// were when it ran: one the batch itself added is checked, and text a later edit of the batch
/// puts back under a mark refuses the whole batch.
#[test]
fn redactions_applied_in_a_batch_get_the_renderer_check() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let apply = Edit::ApplyRedactions { pages: None };
// Text added where the mark was shows under it again: the renderer sees it and refuses.
let under = AddedText { rect: [150.0, 130.0, 300.0, 160.0], text: "SECRET42 SECRET42".into(), ..Default::default() };
let refused = s.apply(id, Edit::Batch { label: "Redact then write".into(), edits: vec![apply.clone(), Edit::AddText { page: 0, text: under }] });
assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed");
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
// Applied alone in a batch, the redaction goes through, and the text is gone.
s.apply(id, Edit::Batch { label: "Redact".into(), edits: vec![apply] }).unwrap();
assert!(!page_texts(&s, id)[0].contains("SECRET42"), "{:?}", page_texts(&s, id));
assert_eq!(s.get(id).unwrap().redaction_marks(), 0);
}
/// A batch that deletes a page holding a redaction mark and adds pages is refused whole: the
/// marks cannot be tied to copies, so redaction fails closed. Without added pages it goes through.
#[test]
fn a_batch_that_deletes_a_marked_page_and_adds_pages_is_refused() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let edits = vec![
Edit::ApplyRedactions { pages: None },
Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 },
Edit::DeletePages { pages: vec![0] },
];
let refused = s.apply(id, Edit::Batch { label: "x".into(), edits });
assert!(matches!(refused, Err(EditError::Invalid(_))), "{refused:?}");
assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed");
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
}
/// A batch that applies redactions and then copies a page is refused whole: the copy can't be tied
/// to the marks, so text restored under one would be unchecked. Copying first, or alone, is fine.
#[test]
fn a_batch_that_applies_redactions_and_copies_a_page_is_refused() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page_with("/Font << /F1 4 0 R >>", "", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
let id = s.open("batch.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let apply = Edit::ApplyRedactions { pages: None };
let copy = Edit::DuplicatePages { pages: vec![0], at: None };
let refused = s.apply(id, Edit::Batch { label: "x".into(), edits: vec![apply.clone(), copy.clone()] });
assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("copy pages")), "{refused:?}");
assert!(page_texts(&s, id)[0].contains("SECRET42"), "nothing changed");
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
// Copying before the redaction is applied puts the mark on the copy too, and both are checked.
s.apply(id, Edit::Batch { label: "y".into(), edits: vec![copy, apply] }).unwrap();
assert!(page_texts(&s, id).iter().all(|t| !t.contains("SECRET42")), "{:?}", page_texts(&s, id));
}
/// A reset in an opening script reaches a field of a row an earlier opening script added, before
/// the form is laid out again with the row's widgets: its data goes back to its default and stays
/// so when the form is saved.
+15 -10
View File
@@ -821,6 +821,20 @@ fn number(t: &Tok) -> Option<u32> {
}
}
/// The font's `/BaseFont` (a Type 3 font's descriptor `/FontName` when it has none), read without
/// building its metrics, so it decodes no CMap.
pub fn base_font_name(doc: &Document, font: &Dict) -> String {
font.name(b"BaseFont")
.map(|name| String::from_utf8_lossy(name).into_owned())
.or_else(|| {
(font.name(b"Subtype") == Some(b"Type3"))
.then(|| dict(doc, font.get(b"FontDescriptor")))
.flatten()
.and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned()))
})
.unwrap_or_default()
}
/// Codespace ranges and CID mappings from an embedded CMap stream, at most
/// [`MAX_CMAP_RANGES`] of each (every glyph searches them).
#[allow(clippy::type_complexity)]
@@ -909,16 +923,7 @@ impl Metrics {
pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self {
let mut reads = Reads::new(doc, budget);
let mut m = Self::read_metrics(doc, font, &mut reads);
let base = font
.name(b"BaseFont")
.map(|name| String::from_utf8_lossy(name).into_owned())
.or_else(|| {
(font.name(b"Subtype") == Some(b"Type3"))
.then(|| dict(doc, font.get(b"FontDescriptor")))
.flatten()
.and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned()))
})
.unwrap_or_default();
let base = base_font_name(doc, font);
m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let (bold, italic) = style_from_name(&base);
m.bold |= bold;
+10
View File
@@ -1823,6 +1823,16 @@ fn low_codes_resolve_to_the_covering_range() {
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
}
#[test]
fn base_font_name_matches_the_metrics_name() {
let doc = Document::new_empty();
let mut f = Dict::new();
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+Body"));
assert_eq!(base_font_name(&doc, &f), "ABCDEF+Body");
assert_eq!(base_font_name(&doc, &f), Metrics::from_dict(&doc, &f).base_font);
assert_eq!(base_font_name(&doc, &Dict::new()), "");
}
#[test]
fn refused_subset_reads_do_not_encode_unverified_glyphs() {
let mut doc = Document::new_empty();
+147
View File
@@ -5601,6 +5601,75 @@ fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() {
assert!(!has_password_values(&doc));
}
/// A field reachable both near the top of the tree and at the end of a long branch: the field
/// walk reaches it near the top and reads the password field below it, so the quick check must
/// not stop at the long branch's depth limit and conclude there is none. The password field's
/// widget is on the page but names no `/Parent`, so the page widgets alone don't find it either.
#[test]
fn a_password_field_past_the_depth_limit_on_one_branch_is_still_found() {
let chain = 64;
let x = 6 + chain; // the shared field
let y = x + 1; // the password field
let w = y + 1; // its widget
let mut objs: Vec<String> = vec![
"<< /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(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{w} 0 R] >>"),
"<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
format!("<< /T (short) /Kids [{x} 0 R] >>"),
];
for d in 0..chain {
let next = if d + 1 == chain { x } else { 7 + d };
objs.push(format!("<< /T (n{d}) /Kids [{next} 0 R] >>"));
}
objs.push(format!("<< /T (shared) /Kids [{y} 0 R] >>"));
objs.push(format!("<< /FT /Tx /T (pin) /Ff 8192 /V (deepsecret) /Kids [{w} 0 R] >>"));
objs.push("<< /Type /Annot /Subtype /Widget /Rect [50 600 150 620] /P 3 0 R >>".into());
let mut doc = document(&objs);
assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD)), "the field walk reads the password field");
assert!(has_password_flag(&doc));
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
}
/// A password field below an unlisted field whose other widget is on the page: the field walk
/// adopts the whole unlisted field from that widget, so the quick check looks below it too.
#[test]
fn a_password_field_below_an_adopted_page_field_is_found() {
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 /Annots [6 0 R] >>".into(),
"<< /Fields [] /DA (/Helv 0 Tf 0 g) >>".into(),
"<< /T (grp) /Kids [6 0 R 7 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret8) /Parent 5 0 R /Rect [0 0 0 0] >>".into(),
]);
assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD)), "the field walk adopts the password field");
assert!(has_password_flag(&doc));
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
}
/// A nameless widget listed in `/Fields` and on no page, whose `/Parent` is an unlisted password
/// field: the field walk adopts the parent, so the quick check follows the entry's `/Parent` too.
#[test]
fn a_password_parent_of_a_nameless_fields_entry_is_found() {
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(),
"<< /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
"<< /T (pin) /FT /Tx /Ff 8192 /V (secret8) /Kids [6 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [0 0 0 0] >>".into(),
]);
assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD) && f.value.iter().any(|v| v == "secret8")), "the field walk reads the parent");
assert!(has_password_flag(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
}
/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut
/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits.
#[test]
@@ -6802,6 +6871,64 @@ fn locked_button_export_edits_leave_the_document_unchanged() {
}
}
/// A widget reached from `/Fields` as an unnamed kid of a plain field, whose own `/Parent` is an
/// unlisted password field: the quick check must still read that `/Parent` chain.
#[test]
fn a_page_widget_seen_under_fields_still_has_its_parent_chain_checked() {
let 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 /Annots [6 0 R] >>".into(),
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
"<< /T (grp) /Kids [6 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(),
"<< /FT /Tx /T (pin) /Ff 8192 /V (secret9) /Kids [6 0 R] >>".into(),
]);
assert!(has_password_flag(&doc));
}
/// A radio button that is read-only on its own (its own `/Ff`) can't be picked, and a selected one
/// can't be switched away from, though the group as a whole is writable.
#[test]
fn a_read_only_radio_button_is_neither_picked_nor_left() {
let lock = |doc: &mut Document, num: u32| doc.update_dict(pdfcraft_cos::ObjRef::new(num, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap();
let mut doc = radio_group_form();
lock(&mut doc, 7); // Medium
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("M".into()))), Err(FormError::ReadOnly(_))));
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))).expect("an unlocked button is picked");
// Small (selected) locked: the group stays on it.
let mut doc = radio_group_form();
lock(&mut doc, 6);
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))), Err(FormError::ReadOnly(_))));
assert_eq!(field(&fields(&doc), "size").value, ["S"]);
}
/// The group's `/V` names its selection even when the button's `/AS` says nothing: a read-only
/// button that `/V` selects is not switched away from either.
#[test]
fn a_read_only_radio_button_selected_by_the_group_value_is_not_left() {
let mut doc = radio_group_form();
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
d.set(b"Ff".to_vec(), Object::Int(1));
d.set(b"AS".to_vec(), Object::name("Off"));
})
.unwrap();
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into()))), Err(FormError::ReadOnly(_))));
assert_eq!(field(&fields(&doc), "size").value, ["S"]);
}
/// Clearing the form skips a group held by a read-only button and still clears the rest.
#[test]
fn clearing_the_form_goes_on_past_a_group_held_by_a_read_only_button() {
let mut doc = radio_group_form();
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap();
doc.update_dict(pdfcraft_cos::ObjRef::new(9, 0), |d| d.set(b"DV".to_vec(), pdfcraft_cos::PdfString::text("Bob"))).unwrap();
assert_eq!(reset(&mut doc, None).unwrap(), 1);
let all = fields(&doc);
assert_eq!(field(&all, "size").value, ["S"]);
assert_eq!(field(&all, "name").value, ["Bob"]);
}
#[test]
/// Cosmetic field updates skip calculations, while a value write still triggers the chain.
fn script_cosmetic_mutations_do_not_run_the_calculation_chain() {
@@ -6937,6 +7064,26 @@ fn radio_buttons_without_as_sharing_the_value_show_one_on() {
assert_eq!(on_widgets(&doc, "r").0, [false, true, false]);
}
/// Two buttons share the on state `Yes`; the first is on by its `/AS` and the second is read-only
/// and off. `/V` is `Yes` for both, but only the first is selected: picking another option switches
/// the first off, which is allowed, and the read-only one never changes.
#[test]
fn a_read_only_button_sharing_the_on_state_of_the_selected_one_does_not_block() {
let mut doc = radio_without_as(49152, "/Ff 1 /AS /Off");
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"AS".to_vec(), Object::name("Yes"))).unwrap();
set_value(&mut doc, "r", &FieldValue::Radio(Some("No".into()))).expect("the writable selected button may be switched off");
assert_eq!(on_widgets(&doc, "r").0, [false, false, true]);
}
/// Selecting another button through `FieldValue::Widget` can't switch a read-only selected one off.
#[test]
fn a_read_only_selected_radio_button_is_not_left_by_picking_a_widget() {
let mut doc = radio_group_form();
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1))).unwrap();
assert!(matches!(set_value(&mut doc, "size", &FieldValue::Widget(2)), Err(FormError::ReadOnly(_))));
assert_eq!(field(&fields(&doc), "size").value, ["S"]);
}
/// Table 227, ReadOnly: a processor "shall not allow a user to change the value". A button whose own
/// dictionary sets it can't be turned on by number.
#[test]
+115 -24
View File
@@ -356,6 +356,9 @@ fn write_value_impl(doc: &mut Document, f: &Field, value: &FieldValue, scripts:
let Some(state) = f.widgets.get(*i).and_then(|w| w.on_state.clone()) else {
return invalid(format!("{:?} has no button {} with an on state", f.name, i.saturating_add(1)));
};
if f.kind == FieldKind::Radio && radio_change_blocked(f, Some(&state), Some(*i)) {
return Err(FormError::ReadOnly(f.name.clone()));
}
set_states(doc, f, Some(&state), Some(*i))
}
(FieldKind::CheckBox, v) => {
@@ -406,6 +409,9 @@ fn write_value_impl(doc: &mut Document, f: &Field, value: &FieldValue, scripts:
if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() {
return invalid(format!("{:?} must keep one option selected", f.name));
}
if radio_change_blocked(f, choice.as_deref(), None) {
return Err(FormError::ReadOnly(f.name.clone()));
}
set_states(doc, f, choice.as_deref(), None)
}
(FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_) | FieldValue::ChoiceIndices(_)) => {
@@ -541,17 +547,49 @@ pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], co
Ok(())
}
/// The one widget of an exclusive radio group that turns on when `on` is chosen (`pick` when it has
/// that state, else the one already on, else the first); `None` when `on` is `None` or the group
/// turns every widget with that state on together.
fn chosen_widget(f: &Field, on: Option<&str>, pick: Option<usize>) -> Option<usize> {
let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON);
on.filter(|_| exclusive).and_then(|c| {
let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c));
let n = f.widgets.len();
pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has))
})
}
/// Whether setting a radio group to `on` (through widget `pick`, when the caller chose a widget)
/// would turn a read-only button on or off: such a button can't be picked, and a selected one can't
/// be switched off by picking another. Judged per widget, by the state `set_states` would give it,
/// so buttons sharing an on-state name are told apart by their `/AS`; the group's `/V` stands for
/// the selection only when no button's `/AS` says which is on.
fn radio_change_blocked(f: &Field, on: Option<&str>, pick: Option<usize>) -> bool {
let chosen = chosen_widget(f, on, pick);
let before = |i: usize, w: &Widget| {
let Some(s) = w.on_state.as_deref() else { return false };
if f.widget_on(i) {
return true;
}
// No button sharing this on state is on by its `/AS` and the group's `/V` names it: the
// first of them holds the selection, whatever its own `/AS` says (an `Off` among them).
let sharing = |x: &Widget| x.on_state.as_deref() == Some(s);
f.value.first().map(String::as_str) == Some(s)
&& !f.widgets.iter().any(|x| sharing(x) && x.state.as_deref() == Some(s))
&& f.widgets.iter().position(sharing) == Some(i)
};
f.widgets.iter().enumerate().any(|(i, w)| {
let after = on.is_some() && w.on_state.as_deref() == on && chosen.is_none_or(|k| k == i);
w.read_only && before(i, w) != after
})
}
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. The widgets whose on
/// state is `on` turn on together in a check box (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2) and in a
/// radio group with RadiosInUnison. Without it, at most one radio button is on (Table 229,
/// §12.7.5.2.3): widget `pick` when it has that state, else the one already on, else the first.
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>, pick: Option<usize>) -> Result<(), FormError> {
let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON);
let chosen = on.filter(|_| exclusive).and_then(|c| {
let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c));
let n = f.widgets.len();
pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has))
});
let chosen = chosen_widget(f, on, pick);
// `/V` names the on state, the same for every widget that has it: which of them is on is
// their `/AS` alone (§12.7.5.2.4).
let v = on.unwrap_or("Off");
@@ -720,29 +758,69 @@ fn password_fields(doc: &Document) -> Vec<Field> {
/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save
/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like
/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be
/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep.
/// missing from `/Fields`, together with the whole tree above and below them that [`fields`]
/// adopts. Each dictionary is visited once.
///
/// It must never answer `false` for a form whose [`fields`] include a password field, or a typed
/// password would be saved. So where it can't look as far as [`fields`] may (a branch deeper than
/// the field walk's limit, which depends on the order the tree is visited in, or a `/Parent`
/// chain too long to follow), it answers `true` and the caller reads every field.
pub(crate) fn has_password_flag(doc: &Document) -> bool {
const MAX_FIELD_DEPTH: usize = 64;
let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0);
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect();
let kids_of =
|r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default();
let mut seen = std::collections::HashSet::new();
while let Some((o, depth)) = stack.pop() {
let Object::Ref(r) = o else { continue };
if depth > 64 || !seen.insert(r) {
continue;
// Whether a dictionary below `start` sets the flag, or the walk was cut short.
let subtree = |start: Vec<Object>, seen: &mut std::collections::HashSet<ObjRef>| {
let mut stack: Vec<(Object, usize)> = start.into_iter().map(|o| (o, 0)).collect();
while let Some((o, depth)) = stack.pop() {
let Object::Ref(r) = o else { continue };
if !seen.insert(r) {
continue;
}
if depth > MAX_FIELD_DEPTH || sets_it(r) {
return true;
}
stack.extend(kids_of(r).into_iter().map(|k| (k, depth + 1)));
}
if sets_it(r) {
false
};
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
let top_refs: Vec<ObjRef> = top.iter().filter_map(Object::as_ref).collect();
if subtree(top, &mut seen) {
return true;
}
// A `/Fields` entry may itself name a field above it through `/Parent` (one `fields` adopts when
// the entry has no name of its own), whether or not a page lists it.
for r in top_refs {
let chain = field_chain(doc, r);
if chain.len() >= MAX_FIELD_DEPTH || chain.iter().copied().any(sets_it) {
return true;
}
if subtree(vec![Object::Ref(field_root(doc, &chain, r))], &mut seen) {
return true;
}
let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default();
stack.extend(kids.into_iter().map(|k| (k, depth + 1)));
}
// Widgets on the pages, and the fields above them.
page_refs(doc).into_iter().any(|p| {
// Widgets on the pages, the fields above them, and the fields below the topmost one.
for p in page_refs(doc) {
let annots =
doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default();
annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it))
})
// Every widget's `/Parent` chain is read, even one already reached from `/Fields`: the
// field walk can adopt it under a different parent there. Each chain is at most
// MAX_FIELD_DEPTH long, and `subtree` returns at once for a root it has seen.
for r in annots.iter().filter_map(Object::as_ref) {
let chain = field_chain(doc, r);
if chain.len() >= MAX_FIELD_DEPTH || chain.iter().copied().any(sets_it) {
return true;
}
let root = field_root(doc, &chain, r);
if subtree(vec![Object::Ref(root)], &mut seen) {
return true;
}
}
}
false
}
/// Whether any text field with the Password flag holds a value in `doc`.
@@ -768,6 +846,12 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)])
Ok(())
}
/// The topmost dictionary of `chain` (from [`field_chain`]): a `/Parent` that is not a dictionary
/// ends the field tree, so the walk below it starts at the last one that is.
fn field_root(doc: &Document, chain: &[ObjRef], r: ObjRef) -> ObjRef {
chain.iter().rev().copied().find(|c| doc.get(*c).as_dict().is_some()).unwrap_or(r)
}
/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats.
fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
let mut chain = vec![r];
@@ -919,10 +1003,17 @@ pub fn reset_with_formatting(
// A reset is not blocked by NoToggleToOff: go through the writer directly.
let mut tmp = f.clone();
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
write_value_impl(doc, &tmp, &v, scripts, true)?;
changed += 1;
if matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
redraw_fields.insert(f.obj);
match write_value_impl(doc, &tmp, &v, scripts, true) {
Ok(()) => {
changed += 1;
if matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
redraw_fields.insert(f.obj);
}
}
// a read-only button of the group holds the value: this field is left as it is, and the
// rest of the form is still cleared
Err(FormError::ReadOnly(_)) => {}
Err(e) => return Err(e),
}
}
let calculation_runner: &mut dyn Scripts = if calculate_with_scripts { scripts } else { &mut NoScripts };
+8 -3
View File
@@ -1,4 +1,6 @@
use super::host::{FieldKey, arg, colour, error, function, host, read, s, set_value, strings, text, text_line, value_of, write};
use super::host::{
FieldKey, arg, charge, colour, error, function, host, kind_of, read, s, strings, text, text_bytes, text_line, value_for, value_of, write,
};
use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request};
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
@@ -41,8 +43,9 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
let value_get = getter!(ctx, n, |f, c| value_of(&f, c));
let value_set = {
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
let vals = strings(&arg(args, 0), c)?;
write(c, name, |f| set_value(f, vals))?;
let vals = value_for(kind_of(c, name)?, strings(&arg(args, 0), c)?);
charge(c, name, |f| text_bytes(&f.value), text_bytes(&vals))?;
write(c, name, |f| f.value = vals)?;
Ok(JsValue::undefined())
}
function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone()))
@@ -104,6 +107,7 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
let text_color_set = {
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
let v = strings(&arg(args, 0), c)?;
charge(c, name, |f| f.text_color.as_deref().map_or(0, text_bytes), text_bytes(&v))?;
write(c, name, |f| f.text_color = Some(v.into()))?;
Ok(JsValue::undefined())
}
@@ -113,6 +117,7 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
let fill_color_set = {
fn set(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
let v = strings(&arg(args, 0), c)?;
charge(c, name, |f| f.fill_color.as_deref().map_or(0, text_bytes), text_bytes(&v))?;
write(c, name, |f| f.fill_color = Some(v.into()))?;
Ok(JsValue::undefined())
}
+9 -4
View File
@@ -33,9 +33,12 @@ impl Interp<'_> {
Ok(())
}
/// `more` nodes into `out`, each once, never past [`MAX_LIST`].
/// `more` nodes into `out`, each once, never past [`MAX_LIST`]. Every node visited costs a
/// step, kept or not, and the call itself one more: a search that matches nothing still
/// costs work when it is repeated over a long list.
pub(super) fn gather(&mut self, out: &mut Vec<usize>, seen: &mut HashSet<usize>, more: impl IntoIterator<Item = usize>) -> R<()> {
for i in more {
self.step()?;
if seen.insert(i) {
out.push(i);
if out.len() > MAX_LIST {
@@ -43,7 +46,7 @@ impl Interp<'_> {
}
}
}
self.charge(1)
self.step()
}
/// The nodes of `v`, each once.
@@ -294,8 +297,10 @@ impl Interp<'_> {
}
}
/// `v` onto `out`: a node only once, never past [`MAX_LIST`].
/// `v` onto `out`: a node only once, never past [`MAX_LIST`]. A duplicate costs its step
/// too: `all` on a list of same-named siblings makes one for every pair.
fn list_push(&mut self, out: &mut Vec<Val>, seen: &mut HashSet<usize>, v: Val) -> R<()> {
self.step()?;
if let Val::Node(i) = v
&& !seen.insert(i)
{
@@ -305,7 +310,7 @@ impl Interp<'_> {
if out.len() > MAX_LIST {
return Err(LIST_TOO_LONG.into());
}
self.charge(1)
Ok(())
}
/// Property `name` of form object `i` as FormCalc reads it: its value, attributes,
+6 -2
View File
@@ -56,6 +56,9 @@ const CENTURY_SPLIT: i64 = 30;
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Characters of the longest month name: all a month token needs to read of the text.
const LONGEST_MONTH: usize = 9;
/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …).
pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
let pic: Vec<char> = picture.chars().collect();
@@ -71,8 +74,9 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
}
i += run;
if c == 'M' && run >= 3 {
// Month name.
let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect();
// Month name: only as much of the text as a name can be, so a picture of many
// month tokens does not copy the rest of a long text for each of them.
let rest: String = txt.get(j..).unwrap_or(&[]).iter().take(LONGEST_MONTH).collect();
let lower = rest.to_ascii_lowercase();
let found = MONTHS
.iter()
+37 -3
View File
@@ -27,6 +27,10 @@ pub(crate) struct Host {
pub(crate) reset_limit_exceeded: bool,
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
pub(crate) kept: usize,
/// Bytes of text the script has added to the fields' values and colours, net of what it
/// replaced, against [`xfa::MAX_PAYLOAD`]: each assignment alone is capped, so without this
/// many fields could each hold a maximal array.
pub(crate) retained: usize,
/// Alerts, console lines and requests the limits left out.
pub(crate) dropped: usize,
}
@@ -262,12 +266,42 @@ pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue {
}
}
pub(crate) fn set_value(f: &mut FieldState, vals: Vec<String>) {
f.value = match f.kind {
/// Bytes of text in `v`, counting each string's heap size and its handle.
pub(crate) fn text_bytes(v: &[String]) -> usize {
v.iter().fold(0usize, |n, t| n.saturating_add(t.len()).saturating_add(std::mem::size_of::<String>()))
}
/// Charge the replacement of the text `old` selects from field `name` with `new_bytes` of text to
/// the run's retained-text budget; an assignment the budget has no room for is an error and
/// charges nothing.
pub(crate) fn charge(ctx: &Context, name: &FieldKey, old: impl FnOnce(&FieldState) -> usize, new_bytes: usize) -> JsResult<()> {
let i = field_index(ctx, name)?;
let mut h = host(ctx)?.borrow_mut();
let freed = old(&h.fields[i]);
let total = h.retained.saturating_sub(freed).saturating_add(new_bytes);
if total > xfa::MAX_PAYLOAD {
return Err(error(format!("the fields' values and colours hold more than {} bytes of text", xfa::MAX_PAYLOAD)));
}
h.retained = total;
Ok(())
}
pub(crate) fn kind_of(ctx: &Context, name: &FieldKey) -> JsResult<FieldType> {
let i = field_index(ctx, name)?;
Ok(host(ctx)?.borrow().fields[i].kind)
}
/// What a field of `kind` keeps of the values assigned to it.
pub(crate) fn value_for(kind: FieldType, vals: Vec<String>) -> Vec<String> {
match kind {
FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(),
FieldType::ListBox => vals,
_ => vals.into_iter().take(1).collect(),
};
}
}
pub(crate) fn set_value(f: &mut FieldState, vals: Vec<String>) {
f.value = value_for(f.kind, vals);
}
#[cfg(test)]
+1
View File
@@ -270,6 +270,7 @@ pub(crate) fn run_in_context(
reset_calls: setup.reset_calls,
reset_limit_exceeded: false,
kept: 0,
retained: 0,
dropped: 0,
}));
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
+46
View File
@@ -87,6 +87,17 @@ fn calculate_scripts_read_fields_and_set_the_value() {
assert!(o.rc);
}
/// An array of field values copies each entry: one long string listed many times is held to
/// the run's byte budget, not copied once per listing.
#[test]
fn an_array_of_values_stops_at_the_byte_budget() {
let fill = "var s = 'x'; while (s.length < 65536) s += s; var a = []; for (var i = 0; i < N; i++) a.push(s); getField('qty').value = a;";
let o = go(&fill.replace('N', "600"), &Event::field("Calculate", "total", ""));
assert!(o.error.as_deref().is_some_and(|e| e.contains("byte limit exceeded")), "{:?}", o.error);
let o = go(&fill.replace('N', "400"), &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
}
/// `event.value` reads and writes as a plain value, numbers included, and the outcome says whether
/// the script assigned it, even to the value it already had.
#[test]
@@ -528,6 +539,7 @@ fn app_alert_titles_are_clipped_and_charged() {
reset_calls: 0,
reset_limit_exceeded: false,
kept: 0,
retained: 0,
dropped: 0,
};
h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") });
@@ -1113,6 +1125,24 @@ mod xfa_model {
assert_eq!(o.result.as_deref(), Some("7"));
}
/// A copy of a subform costs its nodes, names and SOMs: instances of one with a long name
/// stop at the run's byte budget, well before the instance cap.
#[test]
fn instances_of_a_subform_with_long_names_stop_at_the_byte_budget() {
let mut f = form();
let table = &mut f.children[0].children[4];
assert_eq!(table.name, "table");
for row in &mut table.children {
row.children.push(field(&"n".repeat(1 << 20), "", "", false));
}
let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let o = run_xfa("for (var i = 0; i < 100; i++) table._row.addInstance(1); table._row.count", &ev, &XfaDoc::default(), &f, Limits::default());
assert_eq!(o.error, None, "{:?}", o.error);
let count: usize = o.result.as_deref().unwrap_or_default().parse().expect("a count");
assert!((3..50).contains(&count), "{count} instances");
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count(), count - 2);
}
/// On screen every page is a sheet of its own: `xfa.layout.sheet` counts it from 0, as
/// `absPage` does (forms show `sheet(this) + 1`), and `sheetCount` is the page count.
#[test]
@@ -1625,6 +1655,10 @@ mod formcalc {
#[test]
fn date2num_rejects_days_past_the_end_of_the_month() {
// A month name is read from the text's next few characters only, whatever follows.
let serial = calc(r#"Date2Num("2024-09-01")"#);
assert_eq!(calc(r#"Date2Num("September 1, 2024, and a note that runs on", "MMMM D, YYYY")"#), serial);
assert_eq!(calc(r#"Date2Num("Sep 1, 2024", "MMM D, YYYY")"#), serial);
assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0");
assert_eq!(calc(r#"Date2Num("31/04/2024", "DD/MM/YYYY")"#), "0");
assert_eq!(calc(r#"Date2Num("2023-02-29")"#), "0");
@@ -2641,6 +2675,18 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() {
assert_eq!(o.alerts, [Message::plain("old viewer")]);
}
/// Arrays assigned to several fields are held to one run-wide budget, and replacing a field's
/// array frees its charge.
#[test]
fn retained_array_text_is_charged_across_fields() {
let fill = "var s = 'x'; while (s.length < 65536) s += s; var a = []; for (var i = 0; i < 320; i++) a.push(s);";
let one = |tail: &str| go(&format!("{fill} {tail}"), &Event::field("Calculate", "total", ""));
let o = one("getField('qty').textColor = a; getField('qty').textColor = a; getField('zip').textColor = ['RGB'];");
assert_eq!(o.error, None, "replacing an array frees its charge: {:?}", o.error);
let o = one("getField('qty').textColor = a; getField('zip').fillColor = a;");
assert!(o.error.as_deref().is_some_and(|e| e.contains("hold more than")), "{:?}", o.error);
}
#[test]
fn string_arrays_refuse_aggregate_text_before_request_clipping() {
let o = go("resetForm(new Array(65).fill('x'.repeat(1 << 19)));", &Event::doc("Console"));
+4 -2
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use super::host::host;
use super::instances::instance_manager;
use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names};
@@ -379,7 +381,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
"DataView",
"Intl",
];
let mut defined: Vec<String> = RESERVED.iter().map(ToString::to_string).collect();
let mut defined: HashSet<String> = RESERVED.iter().map(ToString::to_string).collect();
for scope in scopes {
let kids: Vec<(usize, String, bool)> = {
let h = host(ctx)?.borrow();
@@ -398,7 +400,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
let im = instance_manager(ctx, scope, &name)?;
ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?;
}
defined.push(name);
defined.insert(name);
}
}
Ok(())
+15 -1
View File
@@ -137,6 +137,17 @@ fn child_som(parent_som: String, name: &str, index: usize) -> String {
}
}
/// Bytes a copy of `src`'s subtree keeps: a node, its name and its SOM (the copy's SOMs differ
/// in their indices only) for every node [`clone_subtree`] copies.
fn subtree_bytes(h: &XHost, src: usize, depth: usize) -> usize {
let Some(n) = h.nodes.get(src).filter(|_| depth <= MAX_DEPTH) else { return 0 };
let own = std::mem::size_of::<HNode>().saturating_add(n.name.len()).saturating_add(n.som.len());
n.children
.iter()
.filter(|&&k| !h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0))
.fold(own, |sum, &k| sum.saturating_add(subtree_bytes(h, k, depth + 1)))
}
/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index.
fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize) -> Option<usize> {
if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES {
@@ -214,7 +225,10 @@ impl XHost {
let name = h.nodes.get(first).map(|n| n.name.to_string()).unwrap_or_default();
let parent_som = h.nodes.get(parent).map(|p| p.som.to_string()).unwrap_or_default();
let som = child_som(parent_som, &name, live.len());
if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) {
// The copy's nodes, their names and SOMs, are charged to the run's byte budget before
// they are made: a subtree with long names must not be copied past it.
let copied = subtree_bytes(h, first, 0);
if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len().saturating_add(copied)) {
return None;
}
let new = clone_subtree(h, first, Some(parent), 0)?;
+21 -10
View File
@@ -9,7 +9,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, MAX_DECODED, Object, XrefEntry};
use pdfcraft_cos::{Dict, Document, MAX_DECODED, ObjRef, Object, XrefEntry};
/// The categories, in Acrobat's order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -91,6 +91,9 @@ const MAX_FORM_DEPTH: usize = 64;
struct Audit<'a> {
doc: &'a Document,
owner: HashMap<u32, SpaceCategory>,
/// Resource dictionaries (by reference) already walked: one shared by many pages, forms or
/// appearances is walked once, as claiming is the same each time.
walked: HashSet<ObjRef>,
}
impl Audit<'_> {
@@ -124,6 +127,18 @@ impl Audit<'_> {
o.map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
}
/// The resource dictionary `holder` points at (if it does), by kind: [`Self::resources`],
/// once per dictionary given by reference.
fn resources_of(&mut self, holder: Option<&Object>, depth: usize) {
if let Some(Object::Ref(r)) = holder
&& !self.walked.insert(*r)
{
return;
}
let res = self.resolve_dict(holder);
self.resources(&res, depth);
}
/// A resource dictionary, by kind; `depth` counts the form XObjects it is nested in.
fn resources(&mut self, res: &Dict, depth: usize) {
let xobjects = self.resolve_dict(res.get(b"XObject"));
@@ -135,8 +150,7 @@ impl Audit<'_> {
// A form: its own resources first, by kind, then the rest of it. Owning it
// first stops a form that draws itself.
self.owner.insert(r.num, SpaceCategory::XObjectForms);
let inner = self.resolve_dict(s.dict.get(b"Resources"));
self.resources(&inner, depth + 1);
self.resources_of(s.dict.get(b"Resources"), depth + 1);
self.claim(&Object::Dict(s.dict.clone()), SpaceCategory::XObjectForms);
}
_ => self.claim(x, SpaceCategory::XObjectForms),
@@ -172,8 +186,7 @@ impl Audit<'_> {
_ => Vec::new(),
};
for dict in streams {
let res = self.resolve_dict(dict.get(b"Resources"));
self.resources(&res, 1);
self.resources_of(dict.get(b"Resources"), 1);
}
}
}
@@ -216,19 +229,17 @@ fn object_stream(doc: &Document, num: u32) -> Option<u32> {
/// Audit the space a document uses; `file_len` is the size of the file on disk (the rest is
/// overhead).
pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
let mut a = Audit { doc, owner: HashMap::new() };
let mut a = Audit { doc, owner: HashMap::new(), walked: HashSet::new() };
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
// Order matters: the most specific owners first. Resources by kind before anything that
// uses them: a content stream's dictionary may point at what it draws (PdfCraft's /PCAdded
// records an added image), and form fields and comments draw with fonts and images too.
for p in &pages {
let res = a.resolve_dict(p.dict.get(b"Resources"));
a.resources(&res, 0);
a.resources_of(p.dict.get(b"Resources"), 0);
}
let form = a.resolve_dict(cat.get(b"AcroForm"));
let form_resources = a.resolve_dict(form.get(b"DR"));
a.resources(&form_resources, 0);
a.resources_of(form.get(b"DR"), 0);
for p in &pages {
for x in annotations(doc, &p.dict) {
a.appearance_resources(&x);
+78 -20
View File
@@ -30,17 +30,21 @@ pub const MIN_FRAGMENTS: usize = 4;
/// Most pixels a merged image may have (memory).
const MAX_PIXELS: u64 = 80_000_000;
/// Fragment groups a page's search for runs may read per content operator, and at least. A run
/// that is not one picture is searched again from each of its groups, so without a limit images
/// drawn over one another take time quadratic in their number; past it, the rest of the page is
/// left as it is.
const SCAN_PER_OP: usize = 16;
/// Content operators a page's search for runs may read per content operator of the page, and at
/// least. A run that is not one picture is searched again from each of its groups, so without a
/// limit images drawn over one another take time quadratic in their number; past it, the rest of
/// the page is left as it is. Every operator of a group counts (its `cm` operators are composed
/// each time), not just the group; a group of four operators costs four, so the allowance is 16
/// groups per operator, as before, and groups with many `cm` operators pay for them.
const SCAN_PER_OP: usize = 64;
const MIN_SCAN: usize = 1 << 16;
#[cfg(test)]
thread_local! {
/// Fragment groups read, for the test that keeps a page's search linear in its content.
static GROUP_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Operators group reads walked, for the test that keeps the search linear in operators.
static OP_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// One fragment: its image, its box in the coordinates the run is drawn in, and its size.
@@ -53,21 +57,44 @@ struct Fragment {
/// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix
/// and the name drawn.
fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec<u8>)> {
fn group_at(ops: &[Op], i: usize, reads: &std::cell::Cell<usize>) -> Option<(usize, Matrix, Vec<u8>)> {
#[cfg(test)]
GROUP_READS.with(|c| c.set(c.get().saturating_add(1)));
if !ops.get(i)?.is("q") {
let mut looked = 0usize;
let found = group_scan(ops, i, &mut looked);
// Every operator looked at is charged to the page's search allowance, whether or not the
// group turns out to be one.
reads.set(reads.get().saturating_add(looked));
#[cfg(test)]
OP_READS.with(|c| c.set(c.get().saturating_add(looked)));
found
}
/// [`group_at`]'s search, counting in `looked` each operator it examines.
fn group_scan(ops: &[Op], i: usize, looked: &mut usize) -> Option<(usize, Matrix, Vec<u8>)> {
let q = ops.get(i)?;
*looked += 1;
if !q.is("q") {
return None;
}
let mut j = i + 1;
let mut m = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
while let Some(op) = ops.get(j).filter(|o| o.is("cm")) {
*looked += 1;
m = Matrix::from_operands(&op.operands)?.then(&m);
j += 1;
}
let draw = ops.get(j).filter(|o| o.is("Do"))?;
let draw = ops.get(j)?;
*looked += 1;
if !draw.is("Do") {
return None;
}
let name = draw.name(0)?.to_vec();
ops.get(j + 1).filter(|o| o.is("Q"))?;
let end = ops.get(j + 1)?;
*looked += 1;
if !end.is("Q") {
return None;
}
(j > i + 1).then_some((j + 2, m, name))
}
@@ -152,7 +179,7 @@ fn stacked(run: &mut [Fragment]) -> Option<Vec<std::ops::Range<usize>>> {
/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row
/// or strip by strip in one direction, up to a gap or a jump.
fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document, reads: &std::cell::Cell<usize>) -> Vec<Fragment> {
let mut run: Vec<Fragment> = Vec::new();
let mut row_x = [0.0f64, 0.0];
let mut cur_x = [0.0f64, 0.0];
@@ -163,7 +190,7 @@ fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &
let mut complete = 0usize;
let mut j = i;
while run.len() < limit {
let Some((next, m, name)) = group_at(ops, j) else { break };
let Some((next, m, name)) = group_at(ops, j, reads) else { break };
let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break };
let [a, _, _, d, e, f] = m.0;
let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height };
@@ -240,10 +267,10 @@ fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &
}
/// The op index after the first `count` fragment groups from `i`.
fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
fn run_end(ops: &[Op], i: usize, count: usize, reads: &std::cell::Cell<usize>) -> usize {
let mut j = i;
for _ in 0..count {
match group_at(ops, j) {
match group_at(ops, j, reads) {
Some((next, ..)) => j = next,
None => break,
}
@@ -386,9 +413,9 @@ fn merge_page(
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let (mut i, mut merged, mut k) = (0, 0, 0);
let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN);
let mut scanned = 0usize;
let reads = std::cell::Cell::new(0usize);
while i < ops.len() {
if scanned > budget {
if reads.get() > budget {
out.extend(ops.get(i..).unwrap_or_default().iter().cloned());
break;
}
@@ -396,7 +423,7 @@ fn merge_page(
let mut run = Vec::new();
let mut kind: Option<(Object, usize)> = None;
let mut j = i;
while let Some((next, m, name)) = group_at(&ops, j) {
while let Some((next, m, name)) = group_at(&ops, j, &reads) {
let Some((image, width, height, cs, n)) = fragment_image(doc, &xobjects, &name) else { break };
let [a, b, c, d, e, f] = m.0;
if b.abs() > 1e-9 || c.abs() > 1e-9 || !(a > 0.0 && d > 0.0) {
@@ -410,13 +437,11 @@ fn merge_page(
run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height });
j = next;
}
scanned = scanned.saturating_add(run.len()).saturating_add(1);
// Not one picture as a whole: a prefix drawn row by row or strip by strip may be.
let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None };
if run.len() >= MIN_FRAGMENTS && rows.is_none() {
run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc);
j = run_end(&ops, i, run.len());
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc, &reads);
j = run_end(&ops, i, run.len(), &reads);
rows = stacked(&mut run);
}
let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else {
@@ -773,4 +798,37 @@ mod tests {
assert_eq!((merged, report.merged_images), (0, 0));
}
}
/// The same with many `cm` operators in each group: the search reads a bounded number of operators
/// for each one of the page, however the matrices are spread over the groups.
#[test]
fn groups_with_many_matrices_are_charged_for_each_operator() {
let mut doc = Document::new_empty();
let mut image = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
image.set(k.as_bytes().to_vec(), v);
}
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0])));
let groups = 400;
let content = doc.add(Object::Stream(Stream::flate(
Dict::new(),
format!("q 10 0 0 10 0 0 cm {}/Im Do Q\n", "1 0 0 1 0 0 cm ".repeat(49)).repeat(groups).as_bytes(),
)));
let mut xobjects = Dict::new();
xobjects.set(b"Im".to_vec(), Object::Ref(image));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let mut page = Dict::new();
page.set(b"Contents".to_vec(), Object::Ref(content));
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(Object::Dict(page));
OP_READS.with(|c| c.set(0));
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX));
assert_eq!(merged.ok(), Some(0));
let reads = OP_READS.with(|c| c.get());
assert!(reads <= 80 * 53 * groups, "{reads} operator reads");
}
}
+6 -2
View File
@@ -52,8 +52,12 @@ fn tree_entries(doc: &Document, node: &Object) -> Vec<(Vec<u8>, Object)> {
}
}
}
if let Some(kids) = d.get(b"Kids").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) {
queue.extend(kids.into_iter().map(|k| (k, depth + 1)));
// A kids array shared by reference is read once, and only kids not yet seen are queued:
// nodes sharing one array can't multiply the queue.
if let Some(kids) =
d.get(b"Kids").filter(|k| !matches!(k, Object::Ref(r) if !seen.insert(*r))).map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned())
{
queue.extend(kids.into_iter().filter(|k| !matches!(k, Object::Ref(r) if seen.contains(r))).map(|k| (k, depth + 1)));
}
}
out
+64
View File
@@ -5613,6 +5613,70 @@ fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() {
}
}
/// A resource dictionary shared by reference between thousands of pages is walked once by the
/// space audit, not once per page.
#[test]
fn space_audit_walks_a_shared_resource_dictionary_once() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 4, 4, 3, false, None);
let mut x = Dict::new();
for i in 0..5_000 {
x.set(format!("Im{i}").into_bytes(), Object::Ref(img));
}
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
let shared = doc.add(Object::Dict(res));
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let mut kids = Vec::new();
for _ in 0..2_000 {
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
p.set(b"Resources".to_vec(), Object::Ref(shared));
kids.push(Object::Ref(doc.add(Object::Dict(p))));
}
doc.update_dict(pages, |d| {
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
d.set(b"Kids".to_vec(), Object::Array(kids));
})
.unwrap();
let started = std::time::Instant::now();
let audit = audit_space(&doc, 1 << 20);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
assert!(audit.iter().any(|u| u.category == SpaceCategory::Images && u.bytes > 0), "{audit:?}");
}
/// A name tree whose nodes all share one kids array (by reference) is read once per node, not
/// once per node per kid: the queue can't grow to the nodes times the kids.
#[test]
fn a_name_tree_sharing_one_kids_array_is_walked_once() {
let mut doc = Document::new_empty();
let p = page(&mut doc, &[], "BT ET");
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]);
let kids = doc.add(Object::Array(Vec::new()));
let mut nodes = Vec::new();
for i in 0..3_000 {
let mut node = Dict::new();
node.set(b"Kids".to_vec(), Object::Ref(kids));
node.set(b"Names".to_vec(), Object::Array(vec![s(&format!("d{i}")), fit.clone()]));
nodes.push(Object::Ref(doc.add(Object::Dict(node))));
}
doc.set(kids, Object::Array(nodes));
let mut tree = Dict::new();
tree.set(b"Kids".to_vec(), Object::Ref(kids));
let tree = doc.add(Object::Dict(tree));
let mut names = Dict::new();
names.set(b"Dests".to_vec(), Object::Ref(tree));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap();
let started = std::time::Instant::now();
let report = optimize(&mut doc, &Settings::default()).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
assert_eq!(report.unreferenced_dests, 3_000, "every node's destination was found, once");
}
/// Pictures run on several threads: what comes out doesn't depend on how many.
#[test]
fn the_same_file_comes_out_with_any_number_of_threads() {
+145 -45
View File
@@ -203,6 +203,7 @@ pub const WORDINGS: &[&str] = &[
"A graphics state has the rendering intent {}, which isn't a standard one",
"A graphics state has a rendering intent that isn't a name",
"The form asks viewers to make appearances (NeedAppearances)",
"Some content could not be read for device colour",
// Pages, actions, annotations and fields.
"Page {} has additional actions",
"A {} action isn't allowed",
@@ -229,7 +230,8 @@ pub struct Declared {
/// `pdfaid:part` and `pdfaid:conformance`, e.g. (2, "B").
pub pdfa: Option<(u8, String)>,
pub pdfua: Option<u8>,
/// Output intents' condition identifiers.
/// Output intents' condition identifiers: every intent the document lists (each object
/// once), each identifier cut to [`MAX_IDENTIFIER_BYTES`].
pub output_intents: Vec<String>,
}
@@ -276,10 +278,27 @@ pub fn declared(doc: &Document) -> Declared {
d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
for oi in list {
if let Some(od) = doc.resolve(&oi).as_dict() {
let id = od.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o));
if let Some(list) = list.as_deref().and_then(Object::as_array) {
// One intent object listed again is one declaration: its identifier is not copied once
// per listing. Every distinct one is kept (verification reads them all too), and only
// the start of an identifier longer than any real one.
let mut seen = std::collections::HashSet::new();
for oi in list.iter().filter(|oi| oi.as_ref().is_none_or(|r| seen.insert(r))) {
if let Some(od) = doc.resolve(oi).as_dict() {
let id = od
.get(b"OutputConditionIdentifier")
.and_then(|v| {
doc.resolve(v).as_string().map(|s| {
if s.bytes.len() <= MAX_IDENTIFIER_BYTES {
s.to_text()
} else {
// An even cut keeps UTF-16 text whole code units.
pdfcraft_cos::PdfString { bytes: s.bytes[..MAX_IDENTIFIER_BYTES & !1].to_vec(), hex: s.hex }.to_text()
}
})
})
.unwrap_or_default();
d.output_intents.push(id);
}
}
@@ -287,6 +306,14 @@ pub fn declared(doc: &Document) -> Declared {
d
}
/// The most bytes of an output condition identifier [`declared`] keeps (a real one is a few
/// dozen).
const MAX_IDENTIFIER_BYTES: usize = 1024;
/// Decoded bytes one verification reads looking for device colour, all content streams
/// together. Past it, content is not inspected, and the report says so.
const MAX_COLOUR_DECODED: usize = 1 << 30;
/// Every indirect object, with its reference.
fn objects(doc: &Document) -> Vec<(ObjRef, std::sync::Arc<Object>)> {
doc.object_numbers()
@@ -361,6 +388,9 @@ struct DeviceColour {
/// space, which PDF/A accepts whatever the output intent (ISO 19005-2 6.2.4.3).
default_rgb: bool,
default_cmyk: bool,
/// Content that could not be read (undecodable, or past [`MAX_COLOUR_DECODED`]) may use
/// device colour that was not seen.
incomplete: bool,
}
impl DeviceColour {
@@ -388,27 +418,35 @@ const MAX_DIRECT_DEPTH: u8 = 8;
/// The glyph procedures of every Type 3 font in `d`: `d` itself, or a font held directly inside
/// it (a font can sit inline in a resource dictionary).
fn type3_procs(doc: &Document, d: &Dict, depth: u8, out: &mut std::collections::HashSet<ObjRef>) {
fn type3_procs(doc: &Document, d: &Dict, depth: u8, read: &mut std::collections::HashSet<ObjRef>, out: &mut std::collections::HashSet<ObjRef>) {
if d.name(b"Subtype") == Some(b"Type3")
&& let Some(procs) = d.get(b"CharProcs").map(|c| doc.resolve(c))
&& let Some(procs) = procs.as_dict()
&& let Some(c) = d.get(b"CharProcs")
// One glyph dictionary shared by many fonts is read once: its entries are the same.
&& c.as_ref().is_none_or(|r| read.insert(r))
&& let Some(procs) = doc.resolve(c).as_dict()
{
out.extend(procs.iter().filter_map(|(_, p)| p.as_ref()));
}
for (_, v) in d.iter() {
type3_procs_within(doc, v, depth.saturating_add(1), out);
type3_procs_within(doc, v, depth.saturating_add(1), read, out);
}
}
fn type3_procs_within(doc: &Document, o: &Object, depth: u8, out: &mut std::collections::HashSet<ObjRef>) {
fn type3_procs_within(
doc: &Document,
o: &Object,
depth: u8,
read: &mut std::collections::HashSet<ObjRef>,
out: &mut std::collections::HashSet<ObjRef>,
) {
if depth > MAX_DIRECT_DEPTH {
return;
}
match o {
Object::Dict(d) => type3_procs(doc, d, depth, out),
Object::Dict(d) => type3_procs(doc, d, depth, read, out),
Object::Array(a) => {
for v in a {
type3_procs_within(doc, v, depth.saturating_add(1), out);
type3_procs_within(doc, v, depth.saturating_add(1), read, out);
}
}
_ => {}
@@ -430,9 +468,10 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
};
out.extend(contents.iter().filter_map(Object::as_ref));
}
let mut read = std::collections::HashSet::new();
for (_, o) in objs {
if let Some(d) = dict_of(o) {
type3_procs(doc, d, 0, &mut out);
type3_procs(doc, d, 0, &mut read, &mut out);
}
}
out
@@ -560,6 +599,7 @@ fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u
fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> DeviceColour {
let mut used = DeviceColour::default();
let referenced = referenced_content(doc, objs);
let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_COLOUR_DECODED, usize::MAX);
for (r, o) in objs {
let Some(d) = dict_of(o) else { continue };
// A resource dictionary's /ColorSpace, whether it is this object or the page's inline
@@ -586,9 +626,14 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
if !(used.gray && used.rgb && used.cmyk)
&& let Object::Stream(s) = &**o
&& (referenced.contains(r) || is_content(&s.dict))
&& let Ok(data) = s.decoded()
{
let _ = content_colour(&data, &mut used);
match budget.decode(s) {
Ok(data) => {
let _ = content_colour(&data, &mut used);
}
// Not read: it may draw in a device space nothing above saw.
Err(_) => used.incomplete = true,
}
}
}
used
@@ -830,6 +875,9 @@ fn check_output_intent(
issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool),
) {
let used = uses_device_colour(doc, objs);
if used.incomplete {
issue("6.2.3", "Some content could not be read for device colour", None, None, false);
}
match pdfa_intent_profile(doc) {
None if used.any() => {
issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk);
@@ -1215,22 +1263,56 @@ const MAX_ACTION_CHAIN: usize = 1024;
fn chain_forbidden(doc: &Document, a: &Dict) -> Option<String> {
let mut read = 0;
let mut seen = std::collections::HashSet::new();
// Actions written in an indirect `/Next` array, copied out of it.
let mut held: Vec<Dict> = Vec::new();
// Objects `/Next` refers to (an action, or an array of them), each read once and borrowed:
// one array shared by many chains is not copied per chain.
let mut held: Vec<std::sync::Arc<Object>> = Vec::new();
if let Some(s) = walk_chain(doc, a, &mut read, &mut seen, &mut held) {
return Some(s);
}
while let Some(d) = held.pop() {
if let Some(s) = walk_chain(doc, &d, &mut read, &mut seen, &mut held) {
return Some(s);
while let Some(o) = held.pop() {
let dicts: Vec<&Dict> = match &*o {
Object::Dict(d) => vec![d],
Object::Array(list) => {
// An array read is work too, with its entries: a chain of arrays alone must
// still reach the limit.
read = read.saturating_add(1 + list.len().min(MAX_ACTION_CHAIN));
if read > MAX_ACTION_CHAIN {
return Some("too deeply chained".into());
}
// More than the chain limit can't be read anyway.
hold_referenced(doc, list, &mut seen, &mut held);
list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).collect()
}
_ => continue,
};
for d in dicts {
if let Some(s) = walk_chain(doc, d, &mut read, &mut seen, &mut held) {
return Some(s);
}
}
}
None
}
/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. The
/// actions of each indirect `/Next` array (each read once) go to `held`.
fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collections::HashSet<ObjRef>, held: &mut Vec<Dict>) -> Option<String> {
/// Queue the objects the references in a `/Next` array name, each once.
fn hold_referenced(doc: &Document, list: &[Object], seen: &mut std::collections::HashSet<ObjRef>, held: &mut Vec<std::sync::Arc<Object>>) {
for r in list.iter().filter_map(Object::as_ref).take(MAX_ACTION_CHAIN) {
if seen.insert(r) {
// Resolved through any alias: a reference to a reference names its object's value.
held.push(doc.resolve(&Object::Ref(r)));
}
}
}
/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. What
/// an indirect `/Next`, or a reference inside a `/Next` array, names goes to `held`.
fn walk_chain(
doc: &Document,
a: &Dict,
read: &mut usize,
seen: &mut std::collections::HashSet<ObjRef>,
held: &mut Vec<std::sync::Arc<Object>>,
) -> Option<String> {
let mut stack = vec![a];
while let Some(d) = stack.pop() {
*read += 1;
@@ -1242,13 +1324,16 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec
}
match d.get(b"Next") {
Some(Object::Dict(next)) => stack.push(next),
Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)),
Some(Object::Ref(r)) if seen.insert(*r) => {
if let Object::Array(list) = &*doc.get(*r) {
// More than the chain limit can't be read anyway.
held.extend(list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).cloned());
Some(Object::Array(list)) => {
*read = read.saturating_add(list.len().min(MAX_ACTION_CHAIN));
// A wide array is refused before any of it is queued.
if *read > MAX_ACTION_CHAIN {
return Some("too deeply chained".into());
}
hold_referenced(doc, list, seen, held);
stack.extend(list.iter().filter_map(Object::as_dict));
}
Some(Object::Ref(r)) if seen.insert(*r) => held.push(doc.resolve(&Object::Ref(*r))),
_ => {}
}
}
@@ -1291,6 +1376,26 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool {
changed
}
/// Take the annotations `gone` off every page that lists them, each page's list rewritten once, and
/// replace their indirect objects with `Null`.
fn remove_from_pages(doc: &mut Document, gone: &std::collections::HashSet<ObjRef>) -> Result<(), pdfcraft_cos::CosError> {
if gone.is_empty() {
return Ok(());
}
for p in pdfcraft_model::pages(doc) {
let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
let listed = |a: &Object| a.as_ref().is_some_and(|r| gone.contains(&r));
if annots.iter().any(listed) {
let kept: Vec<Object> = annots.into_iter().filter(|a| !listed(a)).collect();
doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
}
}
for r in gone {
doc.set(*r, Object::Null);
}
Ok(())
}
/// Collects every indirect annotation reference listed in any page's `/Annots` array.
fn page_annot_refs(doc: &Document) -> std::collections::HashSet<ObjRef> {
let mut refs = std::collections::HashSet::new();
@@ -1304,19 +1409,6 @@ fn page_annot_refs(doc: &Document) -> std::collections::HashSet<ObjRef> {
refs
}
/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`.
fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> {
for p in pdfcraft_model::pages(doc) {
let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
if annots.iter().any(|a| a.as_ref() == Some(r)) {
let kept: Vec<Object> = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect();
doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
}
}
doc.set(r, Object::Null);
Ok(())
}
fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool {
let mut changed = strip_actions(doc, nd);
*actions += changed as usize;
@@ -1413,8 +1505,10 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
fixed.push("Cleared NeedAppearances".into());
}
// An sRGB output intent describes RGB and gray content only: a document with CMYK content
// needs a CMYK output condition, which only the user can choose (#667).
if !has_pdfa_intent(doc) && !uses_device_colour(doc, &objects(doc)).cmyk {
// needs a CMYK output condition, which only the user can choose (#667). Content that could
// not all be read may hold CMYK too, so it gets none either.
let colour = uses_device_colour(doc, &objects(doc));
if !has_pdfa_intent(doc) && !colour.cmyk && !colour.incomplete {
let mut icc = Dict::new();
icc.set(b"N".to_vec(), Object::Int(3));
let profile = doc.add(Object::Stream(Stream::flate(icc, &icc::srgb())));
@@ -1448,13 +1542,18 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
actions += changed as usize;
convert_direct_page_annots(doc, &p, &mut actions, &mut flags, &mut removed)?;
}
let mut forbidden_annots = std::collections::HashSet::new();
for (r, o) in objects(doc) {
let Some(d) = dict_of(&o).cloned() else { continue };
let ty = d.name(b"Type").map(<[u8]>::to_vec);
let sub = d.name(b"Subtype").map(<[u8]>::to_vec);
let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r));
let is_annot = matches!(&*o, Object::Dict(_))
// A widget is an annotation whether or not it says `/Type /Annot` (the form reader
// finds it by its field), so its actions go the same way.
&& (is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r)) || sub.as_deref() == Some(b"Widget"));
if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
remove_annot_from_pages(doc, r)?;
// Taken off its page below, all of them in one pass over the pages.
forbidden_annots.insert(r);
removed += 1;
continue;
}
@@ -1489,6 +1588,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
files += 1;
}
}
remove_from_pages(doc, &forbidden_annots)?;
// Forbidden actions left as objects of their own, or chained to by one, and those written
// directly in an /A elsewhere (an outline item's, say).
for (r, o) in objects(doc) {
+124
View File
@@ -452,6 +452,20 @@ fn forbidden_actions_chained_behind_allowed_ones_are_found_and_removed() {
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""),
// `/Next` refers to an array that holds the action directly.
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""),
// `/Next` refers to the action itself, which carries `/P` and `/K` as a structure
// element would: those keys must not exempt it.
build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
pages,
page,
&link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"),
"<< /S /JavaScript /JS (app.alert(1)) /P 3 0 R /K 0 >>",
],
"",
),
// A `/Next` array refers to the action.
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next [5 0 R] >>"), js], ""),
];
for (i, mut doc) in cases.into_iter().enumerate() {
let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed");
@@ -495,6 +509,116 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() {
}
}
/// A widget the form reader finds by its field is an annotation whether or not it writes
/// `/Type /Annot`: conversion takes its additional actions off all the same.
#[test]
fn a_widget_without_a_type_loses_its_additional_actions() {
let pages = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>";
let page = "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>";
let widget = "<< /Subtype /Widget /Rect [0 0 10 10] /F 4 /T (f) /AA << /K << /S /JavaScript /JS (1) >> >> >>";
let mut doc = build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, widget], "");
convert(&mut doc, Level::A2b).unwrap();
let w = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
assert!(w.as_dict().is_some_and(|d| d.get(b"AA").is_none()), "{w:?}");
}
/// Forbidden annotations on one page are taken off it in one pass, all of them.
#[test]
fn forbidden_annotations_leave_their_page_together() {
let n = 300;
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{} 2 0 R] >>", annots.join(" "));
let mut objs =
vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page];
objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Sound /Rect [0 0 0 0] /P 3 0 R >>".to_string()));
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let mut doc = build(&refs, "");
let r = convert(&mut doc, Level::A2b).unwrap();
assert!(r.fixed.iter().any(|f| f.starts_with(&format!("{n}: removed multimedia"))), "{:?}", r.fixed);
let left = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().map(Vec::len));
assert_eq!(left, Some(1));
}
/// Content that can't be read may draw in DeviceCMYK: convert then adds no sRGB output intent.
#[test]
fn convert_adds_no_srgb_intent_when_colour_could_not_all_be_read() {
let mut doc = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << >> >>",
// A /Filter that isn't a name can't be decoded: the content isn't read.
"<< /Length 9 /Filter 5 >>\nstream\nnot flate\nendstream",
],
"",
);
convert(&mut doc, Level::A2b).unwrap();
assert!(declared(&doc).output_intents.is_empty());
}
/// A `/Next` chain of arrays alone (each array naming the next) still reaches the chain limit,
/// so the walk's work is bounded however the arrays are linked.
#[test]
fn an_array_only_next_chain_is_too_deeply_chained() {
let mut doc = Document::new_empty();
let mut next = Object::Array(Vec::new());
for _ in 0..2 * MAX_ACTION_CHAIN {
next = Object::Array(vec![Object::Ref(doc.add(next))]);
}
let mut action = Dict::new();
action.set(b"S".to_vec(), Object::name("URI"));
action.set(b"Next".to_vec(), Object::Ref(doc.add(next)));
assert_eq!(chain_forbidden(&doc, &action).as_deref(), Some("too deeply chained"));
// A short chain of arrays is read through.
let mut short = Object::Array(Vec::new());
for _ in 0..10 {
short = Object::Array(vec![Object::Ref(doc.add(short))]);
}
action.set(b"Next".to_vec(), Object::Ref(doc.add(short)));
assert_eq!(chain_forbidden(&doc, &action), None);
}
/// A direct `/Next` array wider than the chain limit is refused as soon as it is charged, and a
/// `/Next` that reaches its array through a reference alias is read through it.
#[test]
fn a_wide_direct_next_array_is_refused_and_an_alias_is_followed() {
let uri = || {
let mut d = Dict::new();
d.set(b"S".to_vec(), Object::name("URI"));
d
};
let mut action = uri();
action.set(b"Next".to_vec(), Object::Array((0..=MAX_ACTION_CHAIN).map(|_| Object::Dict(uri())).collect()));
let doc = Document::new_empty();
assert_eq!(chain_forbidden(&doc, &action).as_deref(), Some("too deeply chained"));
// `/Next` -> 1 0 R -> 2 0 R -> [ << /S /JavaScript >> ]
let mut doc = Document::new_empty();
let mut js = Dict::new();
js.set(b"S".to_vec(), Object::name("JavaScript"));
let list = doc.add(Object::Array(vec![Object::Dict(js)]));
let alias = doc.add(Object::Ref(list));
let mut action = uri();
action.set(b"Next".to_vec(), Object::Ref(alias));
assert_eq!(chain_forbidden(&doc, &action).as_deref(), Some("JavaScript"));
}
/// Every distinct output intent is declared, past eight; an identifier longer than any real one
/// keeps its start.
#[test]
fn every_output_intent_is_declared_with_its_identifier_cut_to_the_bound() {
let long = "x".repeat(4 * MAX_IDENTIFIER_BYTES);
let intents: Vec<String> = (0..9)
.map(|i| {
format!("<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier ({}) >>", if i == 8 { long.clone() } else { format!("c{i}") })
})
.collect();
let catalog = format!("<< /Type /Catalog /Pages 2 0 R /OutputIntents [{}] >>", intents.join(" "));
let doc = build(&[catalog.as_str(), "<< /Type /Pages /Kids [] /Count 0 >>"], "");
let d = declared(&doc);
assert_eq!(d.output_intents.len(), 9);
assert_eq!(d.output_intents[8].len(), MAX_IDENTIFIER_BYTES);
}
#[test]
fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() {
// Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype
+66 -3
View File
@@ -93,6 +93,12 @@ struct Gs {
scale: f64,
leading: f64,
rise: f64,
/// The stroke parameters that widen a painted path beyond its construction points.
line_width: f64,
miter_limit: f64,
line_join: i64,
/// `J` / ExtGState `/LC`: a projecting square cap (2) reaches past the endpoint's corner.
line_cap: i64,
/// The tiling or shading pattern selected as the fill colour (`/Name scn`), and as the stroke
/// colour (`/Name SCN`). Part of the graphics state, so `q`/`Q` save and restore them with
/// everything else. A pattern's cell is a content stream of its own and this pass does not
@@ -394,6 +400,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
scale: 1.0,
leading: 0.0,
rise: 0.0,
line_width: 1.0,
miter_limit: 10.0,
line_join: 0,
line_cap: 0,
fill_pattern: None,
stroke_pattern: None,
type3_inherits: false,
@@ -438,6 +448,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>],
}
if !walk.path.is_empty() {
done.ops.append(&mut walk.path);
done.ops.append(&mut walk.pending);
}
processed.push((done.ops, done.changed));
}
@@ -505,6 +516,10 @@ struct Walk {
path: Vec<Op>,
path_box: Option<[f64; 4]>,
clip: bool,
/// Line-state operators (`w`, `M`, `j`, `J`, `d`, `gs`) met while a path is open: they apply to
/// the stroke that ends it, so the path stays whole for the hit test, and they are written
/// after it, kept even when the path goes.
pending: Vec<Op>,
/// Painting operators written again under a clip that cuts the regions out: unchanged as
/// operators, but what they paint is no longer all there.
clipped: HashSet<usize>,
@@ -526,6 +541,7 @@ impl Walk {
path: Vec::new(),
path_box: None,
clip: false,
pending: Vec::new(),
clipped: HashSet::new(),
out: Output::default(),
}
@@ -542,14 +558,20 @@ impl Walk {
self.paint_path(scope, op, piece);
return;
}
if !self.path.is_empty() && matches!(o, b"w" | b"M" | b"j" | b"J" | b"d" | b"gs") {
self.graphics_state(doc, scope, &op);
self.pending.push(op);
return;
}
if !self.path.is_empty() {
// A path not ended by a painting operator: pass it through.
piece.ops.append(&mut self.path);
piece.ops.append(&mut self.pending);
self.path_box = None;
self.clip = false;
}
let kept = match o {
b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => {
b"q" | b"Q" | b"cm" | b"Tf" | b"gs" | b"w" | b"M" | b"j" | b"J" => {
self.graphics_state(doc, scope, &op);
Some(op)
}
@@ -627,11 +649,35 @@ impl Walk {
self.path.push(op);
}
/// The box a stroke of the current path can paint, grown from its construction box `b` by
/// what the pen adds: half the line width (a width of 0 is one device pixel, taken as one
/// unit) scaled by the CTM's largest stretch, and never less than half a unit of page space
/// (a renderer paints a hairline, or a line a CTM shrinks, about a device pixel wide); times
/// the miter limit when the path has joins and they are mitred (a rectangle has four), or
/// √2 for a projecting square cap (its outer corner). Caps and joins never reach further, so
/// a hit test on it is conservative, and a horizontal or vertical line no longer has a
/// zero-area box.
fn stroke_box(&self, b: [f64; 4]) -> [f64; 4] {
let m = self.gs.ctm.0;
let scale = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2] + m[3] * m[3]).sqrt();
let joins = self.path.iter().filter(|op| matches!(op.op.as_slice(), b"l" | b"c" | b"v" | b"y" | b"h" | b"re")).count() >= 2
|| self.path.iter().any(|op| op.op.as_slice() == b"re");
let miter = if joins && self.gs.line_join == 0 { self.gs.miter_limit.max(1.0) } else { 1.0 };
let reach = if self.gs.line_cap == 2 { miter.max(std::f64::consts::SQRT_2) } else { miter };
let pad = (self.gs.line_width.max(1.0) / 2.0 * scale).max(0.5) * reach;
[b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad]
}
/// A painting operator ends the path; one under a region is removed (or kept as a clip).
fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) {
let strokes = matches!(op.op.as_slice(), b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*");
// A `W`/`W*` makes the path as constructed the clip; the stroke expansion only describes
// what the painting itself covers.
let path_box = self.path_box.take();
let bbox = path_box.map(|b| if strokes { self.stroke_box(b) } else { b });
let body = std::mem::take(&mut self.path);
let bbox = self.path_box.take();
let clips = std::mem::replace(&mut self.clip, false);
let pending = std::mem::take(&mut self.pending);
let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b));
if !hit {
// The paint survives, so the pattern it draws with is still in use. `n` paints
@@ -641,25 +687,30 @@ impl Walk {
let fills = matches!(o, b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*");
uses_patterns(&mut self.out, &self.gs, fills, strokes);
piece.ops.extend(body);
piece.ops.extend(pending);
piece.ops.push(op);
return;
}
let b = bbox.unwrap_or_default();
piece.changed = true;
scope.report.paths_removed += 1;
if scope.covered(b) && clips {
if scope.covered(path_box.unwrap_or(b)) && clips {
// Everything this clip lets through is under a mark, so an empty clip hides the same
// content. The original outline is confidential and must not stay in the stream.
piece.ops.extend(pending);
for o in ["re", "W", "n"] {
let operands = if o == "re" { vec![num(0.0); 4] } else { vec![] };
piece.ops.push(Op::new(o, operands));
}
} else {
piece.ops.extend(pending);
}
// Partial vector redaction must not retain underlying drawing commands
// behind clipping (F-008). Refuse partial clip retention and remove the path.
}
fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) {
let last = op.nums::<1>().map(|[v]| v);
match op.op.as_slice() {
b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)),
b"Q" => {
@@ -673,12 +724,24 @@ impl Walk {
}
}
b"Tf" => self.select_font(doc, scope, op),
// The operand before the operator, as the renderer reads it: `1 12 w` is a width of 12.
b"w" => self.gs.line_width = last.unwrap_or(self.gs.line_width),
b"M" => self.gs.miter_limit = last.unwrap_or(self.gs.miter_limit),
b"j" => self.gs.line_join = last.map_or(self.gs.line_join, |j| j as i64),
b"J" => self.gs.line_cap = last.map_or(self.gs.line_cap, |j| j as i64),
_ => {
if let Some((font, vertical, size)) = op.last_name().and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) {
(self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size);
// As for `Tf`: a Type 3 font without /Resources paints through this scope's.
self.gs.type3_inherits = op.last_name().is_some_and(|n| extgstate_type3_inherits(doc, &self.ext_gstates, n));
}
if let Some(state) = op.name(0).and_then(|n| self.ext_gstates.get(n)).and_then(|s| doc.resolve(s).as_dict().cloned()) {
let num = |key: &[u8]| state.get(key).and_then(|v| doc.resolve(v).as_f64());
self.gs.line_width = num(b"LW").unwrap_or(self.gs.line_width);
self.gs.miter_limit = num(b"ML").unwrap_or(self.gs.miter_limit);
self.gs.line_join = num(b"LJ").map_or(self.gs.line_join, |j| j as i64);
self.gs.line_cap = num(b"LC").map_or(self.gs.line_cap, |j| j as i64);
}
}
}
}
+18 -4
View File
@@ -20,7 +20,7 @@ impl<'a> Scope<'a> {
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
if !has_type(doc, d, b"OCMD") {
return false;
}
let groups: Vec<ObjRef> = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
@@ -102,7 +102,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
Object::Dict(d) if has_type(doc, d, b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
@@ -144,6 +144,11 @@ impl<'a> Scope<'a> {
}
*budget -= 1;
let reference = expression.as_ref();
// A reference to a reference is no listed group: the renderer rejects the alias, so it
// is not taken as visible because the object it names isn't hidden.
if reference.is_some_and(|r| matches!(*doc.get(r), Object::Ref(_))) {
return None;
}
if let Some(r) = reference
&& !path.insert(r)
{
@@ -151,7 +156,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
@@ -195,6 +200,9 @@ impl<'a> Scope<'a> {
if self.hidden_layers.contains(&r) {
return true;
}
if matches!(*doc.get(r), Object::Ref(_)) {
return true;
}
if !path.insert(r) {
return true;
}
@@ -202,7 +210,7 @@ impl<'a> Scope<'a> {
let resolved = doc.resolve(expression);
let found = match &*resolved {
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
@@ -256,3 +264,9 @@ pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Ve
}
(out, removed)
}
/// Is this dictionary's `/Type` the given name? An indirect `/Type` is followed, as a viewer
/// follows it: a group or membership dictionary must not escape classification through one.
fn has_type(doc: &Document, d: &Dict, name: &[u8]) -> bool {
d.get(b"Type").is_some_and(|t| crate::resolve_chain(doc, t).as_name() == Some(name))
}
+19
View File
@@ -58,6 +58,12 @@ pub enum RedactError {
NothingToApply,
#[error("page {0} has content that can't be read, so it can't be redacted safely")]
Unreadable(usize),
/// The page tree and the renderer count a different number of pages (a page repeated in
/// `/Kids`, say), so a mark's page can't be matched to the page the renderer drew.
#[error(
"the document's page tree and its rendered pages disagree on how many pages there are, so the redaction could not be verified; nothing was changed"
)]
AmbiguousPages,
/// Redact annotations with no readable area (neither usable `/QuadPoints` nor a usable `/Rect`).
/// Skipping them would leave the text under them in the file while the others report success.
#[error(
@@ -1137,6 +1143,19 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
Ok(targets.len())
}
/// `o` with a chain of references followed, as a viewer follows it, to at most 16 links (so a
/// reference cycle ends): hidden-layer state must not escape classification through one.
pub(crate) fn resolve_chain(doc: &pdfcraft_cos::Document, o: &pdfcraft_cos::Object) -> std::sync::Arc<pdfcraft_cos::Object> {
let mut o = doc.resolve(o);
for _ in 0..16 {
if !matches!(*o, pdfcraft_cos::Object::Ref(_)) {
break;
}
o = doc.resolve(&o);
}
o
}
/// The name to save a document under after [`apply`]: `_Redacted` before the extension, as
/// Acrobat appends it ("contract.pdf" gives "contract_Redacted.pdf"). The `.pdf` extension is
/// matched in any letter case. A name that already ends in `_Redacted` keeps it once.
+7 -6
View File
@@ -479,12 +479,13 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
o.and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default()
};
let all = refs(oc.get(b"OCGs"));
let (on, off) = (refs(d.get(b"ON")), refs(d.get(b"OFF")));
let mut hidden: Vec<ObjRef> = if d.name(b"BaseState") == Some(b"OFF") {
all.iter().copied().filter(|g| !on.contains(g)).collect()
} else {
off.into_iter().filter(|g| all.contains(g)).collect()
};
// sets: the lists are the document's, and a filter over every group would scan them each time
let (on, off): (std::collections::HashSet<ObjRef>, std::collections::HashSet<ObjRef>) =
(refs(d.get(b"ON")).into_iter().collect(), refs(d.get(b"OFF")).into_iter().collect());
// The default state is /BaseState, then /ON turns groups on and /OFF turns them off, so a
// group both lists name ends up off (§8.11.4.3, Table 101) — the viewer reads it the same way.
let base_off = d.get(b"BaseState").map(|b| crate::resolve_chain(doc, b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice()));
let mut hidden: Vec<ObjRef> = all.iter().copied().filter(|g| off.contains(g) || (base_off && !on.contains(g))).collect();
// Each rule, layer list and layer is read once, however often the rules name them: many rules
// can share one long list.
let known: std::collections::HashSet<ObjRef> = all.iter().copied().collect();
+145
View File
@@ -837,6 +837,22 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() {
assert!(c.contains("150 150 m"), "the line elsewhere stays");
}
/// A horizontal stroke's construction box has no area, so it overlapped nothing and stayed under
/// the mark. The painted stroke is as wide as the line: a 12 pt line centred 5 pt above the mark
/// reaches into it and goes; a 1 pt line there does not and stays.
#[test]
fn a_stroke_is_removed_by_its_painted_width() {
let src = b"12 w 10 60 m 100 60 l S 1 w 10 60 m 100 60 l S 4 w 10 50 m 100 50 l S";
let mut doc = one_page(src, "", vec![]);
mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.paths_removed, 2, "{r:?}");
let c = content(&doc, 0);
assert!(!c.contains("12 w\n10 60 m"), "the wide line reaches the mark: {c}");
assert!(c.contains("1 w\n10 60 m\n100 60 l\nS"), "the thin line beside it stays: {c}");
assert!(!c.contains("10 50 m"), "the line through the mark goes: {c}");
}
/// A tiling pattern's cell is a content stream of its own and can draw text. Redacting a shape
/// filled with such a pattern removed the fill from the page but left the pattern in the page's
/// resources, so its cell -- with the text that was under the mark -- stayed in the saved file
@@ -4151,6 +4167,8 @@ fn hidden_ocmd_policy_matches_the_default_configuration() {
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
// `/VE` names the hidden group through an alias object (10 0 R is a reference to 8 0 R).
(None, false, Some("alias"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
@@ -4188,9 +4206,11 @@ fn hidden_ocmd_policy_matches_the_default_configuration() {
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
Some("alias") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(10, 0))])),
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
doc.set(ObjRef::new(10, 0), Object::Ref(ObjRef::new(8, 0)));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
@@ -6670,6 +6690,131 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
}
}
/// A hairline under a shrinking CTM still paints about a device pixel: its pad must not shrink
/// with the CTM, or a mark on that ink misses it.
#[test]
fn a_hairline_under_a_shrinking_ctm_is_padded_by_half_a_unit() {
// Device line from (10, 10) to (50, 10); the mark lies on its painted pixel, above its axis.
let mut doc = one_page(b"0.01 0 0 0.01 0 0 cm 0 w 1000 1000 m 5000 1000 l S", "", vec![]);
mark(&mut doc, 0, &[[20.0, 10.2, 30.0, 10.4]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1);
}
/// One `re` has four mitred corners: it is padded by the miter limit like any joined path.
#[test]
fn a_rectangle_stroke_is_padded_for_its_mitred_corners() {
let mut doc = one_page(b"2 w 100 100 50 50 re S", "", vec![]);
// Past half the line width from the rectangle, inside the miter reach.
mark(&mut doc, 0, &[[155.0, 120.0, 160.0, 130.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1);
}
/// A projecting square cap's outer corner reaches lw/2 x sqrt(2) from the endpoint. Under a shear
/// it maps past a pad of lw/2 times the CTM's stretch (the review's example: corner at x = 12).
#[test]
fn a_square_cap_corner_under_a_shear_is_inside_the_pad() {
let mut doc = one_page(b"1 0 1 1 0 0 cm 2 J 2 w 0 0 m 10 0 l S", "", vec![]);
mark(&mut doc, 0, &[[11.8, 0.8, 11.9, 0.95]], "");
assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1);
}
/// A line width set between the path and its `S` still applies to that stroke, so the stroke is
/// tested at the width it is painted with; the width itself stays, the path goes.
#[test]
fn a_width_set_between_the_path_and_its_stroke_does_not_hide_it_from_the_test() {
let mut doc = one_page(b"10 60 m 100 60 l 12 w S 5 5 m 9 9 l S", "", vec![]);
mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.paths_removed, 1, "{r:?}");
let c = content(&doc, 0);
assert!(!c.contains("10 60 m") && !c.contains("100 60 l"), "the wide line reaches the mark: {c}");
assert!(c.contains("12 w"), "the width stays: {c}");
assert!(c.contains("5 5 m"), "the line elsewhere stays: {c}");
}
/// The renderer takes a line-state operator's operand from the end: `1 12 w` strokes 12 points wide,
/// so the stroke is tested at that width.
#[test]
fn a_surplus_operand_before_the_line_width_does_not_hide_the_stroke() {
let mut doc = one_page(b"1 12 w 10 60 m 100 60 l S", "", vec![]);
mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.paths_removed, 1, "{r:?}");
assert!(!content(&doc, 0).contains("100 60 l"));
}
/// One page drawing `SECRET` in the `/OC /L` block, with layer 7 0 R in the default
/// configuration `config` (objects 8 and up are `extra`).
fn layer_state_page(config: Vec<(&str, Object)>, resource: &str, extra: Vec<Vec<u8>>) -> Document {
let mut objects = vec![b"<< /Type /OCG /Name (Secret) >>".to_vec()];
objects.extend(extra);
let mut doc = one_page(b"/OC /L BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", &format!("/Properties << /L {resource} >>"), objects);
let config = Object::Dict(Dict::from_iter(config.into_iter().map(|(k, v)| (k.as_bytes().to_vec(), v))));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), (b"D".to_vec(), config)])),
);
})
.unwrap();
doc
}
fn assert_layer_secret_removed(mut doc: Document) {
use crate::sanitize::{Hidden, remove_hidden, scan};
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn an_indirect_base_state_off_hides_the_layer() {
assert_layer_secret_removed(layer_state_page(vec![("BaseState", Object::Ref(ObjRef::new(8, 0)))], "7 0 R", vec![b"/OFF".to_vec()]));
}
#[test]
fn a_chained_base_state_off_hides_the_layer() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::Ref(ObjRef::new(8, 0)))],
"7 0 R",
vec![b"9 0 R".to_vec(), b"/OFF".to_vec()],
));
}
#[test]
fn a_chained_membership_type_is_evaluated() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::name("ON"))],
"8 0 R",
vec![b"<< /Type 9 0 R /OCGs [7 0 R] /P /AllOff >>".to_vec(), b"10 0 R".to_vec(), b"/OCMD".to_vec()],
));
}
#[test]
fn off_wins_over_on_when_the_base_state_is_off() {
assert_layer_secret_removed(layer_state_page(
vec![
("BaseState", Object::name("OFF")),
("ON", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
("OFF", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
],
"7 0 R",
Vec::new(),
));
}
#[test]
fn a_membership_dictionary_with_an_indirect_type_is_evaluated() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::name("ON"))],
"8 0 R",
vec![b"<< /Type 9 0 R /OCGs [7 0 R] /P /AllOff >>".to_vec(), b"/OCMD".to_vec()],
));
}
/// An invisible OCR-style text layer drawn in a Type3 font (what the OCR output writes for
/// characters beyond WinAnsi) is redacted like any other text: its glyphs are hit-tested by
/// their real advances, removed, and the verifier passes.
@@ -390,6 +390,35 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() {
assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived");
}
/// Type 3 cleanup decoded a glyph program again for every font listing it: 2 000 fonts sharing
/// one large program read it 2 000 times. It is read once now, and the cleanup still keeps the
/// glyph every font shows.
#[test]
fn a_glyph_program_many_type3_fonts_share_is_read_once() {
const FONTS: usize = 2_000;
let names: String = (0..FONTS).map(|i| format!("/T{i} {} 0 R ", 6 + i)).collect();
let shows: String = (0..FONTS).map(|i| format!("/T{i} 10 Tf (A) Tj ")).collect();
let mut objs = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << {names}>> >> >>").into_bytes(),
stream("", format!("BT 10 100 Td {shows}ET").as_bytes()),
stream("", [b"SHARED_GLYPH ".as_slice(), &b"0 0 m 1 1 l S ".repeat(50_000)].concat()),
];
objs.extend((0..FONTS).map(|_| {
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [0] /Encoding << /Differences [65 /A] >> /CharProcs << /A 5 0 R >> >>".to_vec()
}));
let mut doc = pdf(objs);
// The glyphs are drawn at x 10 with no width, so a mark away from them leaves them all.
mark(&mut doc, 0, &[[200.0, 200.0, 250.0, 250.0]], "");
let started = std::time::Instant::now();
apply(&mut doc, None).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
let font = doc.get(ObjRef::new(6, 0));
let procs = doc.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
assert!(procs.as_dict().unwrap().contains(b"A"), "the glyph every font shows was unlinked");
}
/// A Type 3 font with glyphs A (its program object 7) and B (object 8), at object 6.
const TYPE3: &str = "<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> >>";
+35 -4
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::Pieces;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object};
use crate::{RedactError, page_streams};
@@ -130,6 +130,25 @@ struct UsedInGlyphs {
properties: HashSet<Vec<u8>>,
}
impl UsedInGlyphs {
fn merge(&mut self, other: &UsedInGlyphs) {
self.xobjects.extend(other.xobjects.iter().cloned());
self.colorspaces.extend(other.colorspaces.iter().cloned());
self.patterns.extend(other.patterns.iter().cloned());
self.shadings.extend(other.shadings.iter().cloned());
self.extgstates.extend(other.extgstates.iter().cloned());
self.fonts.extend(other.fonts.iter().cloned());
self.properties.extend(other.properties.iter().cloned());
}
}
/// What one Type 3 cleanup reads of glyph programs: the resource names each indirect program
/// uses, read once however many fonts share it, and one decoding allowance for all of them.
struct GlyphReads {
used: HashMap<ObjRef, UsedInGlyphs>,
budget: DecodeBudget,
}
fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> {
// One operator at a time, so a glyph program of millions of them is never held whole. A byte
// that can't be read anywhere in it still refuses, once the loop has read it all.
@@ -731,9 +750,10 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> {
}
// For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources.
let mut reads = GlyphReads { used: HashMap::new(), budget: DecodeBudget::page() };
for (font_key, font_dict) in &type3_fonts {
let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default();
clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?;
clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving, &mut reads)?;
}
Ok(())
@@ -808,6 +828,7 @@ fn clean_type3_font(
font_key: &FontKey,
font_dict: &Dict,
surviving: &HashSet<Vec<u8>>,
reads: &mut GlyphReads,
) -> Result<(), RedactError> {
let Some(char_procs_val) = font_dict.get(b"CharProcs") else {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into());
@@ -851,6 +872,10 @@ fn clean_type3_font(
let mut used_in_glyphs = UsedInGlyphs::default();
for (_, proc_val) in char_procs.iter() {
if let Some(used) = proc_val.as_ref().and_then(|r| reads.used.get(&r)) {
used_in_glyphs.merge(used);
continue;
}
let resolved = doc.resolve(proc_val);
let Object::Stream(s) = &*resolved else {
return Err(CosError::Syntax {
@@ -859,8 +884,14 @@ fn clean_type3_font(
}
.into());
};
let data = doc.with_direct_parms(s).decoded()?;
collect_used_resources_in_stream(&data, &mut used_in_glyphs)?;
// Past the allowance the cleanup can't be shown complete, and the redaction is refused.
let data = reads.budget.decode(&doc.with_direct_parms(s))?;
let mut used = UsedInGlyphs::default();
collect_used_resources_in_stream(&data, &mut used)?;
used_in_glyphs.merge(&used);
if let Some(r) = proc_val.as_ref() {
reads.used.insert(r, used);
}
}
if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) {
+140 -29
View File
@@ -144,12 +144,7 @@ impl<'a> Inspector<'a> {
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
}
if subtype == "Link" {
if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
info.links.push(link);
}
self.collect_link(info, (page, index), d, text_budget, &mut unfit_quads);
continue;
}
// Not comments: form widgets, pop-ups and non-markup annotations such as the
@@ -161,6 +156,20 @@ impl<'a> Inspector<'a> {
if comments_full {
continue;
}
// Text that trims to nothing is not kept, so `retained` never sees it: charge what is
// read, so many comments sharing one huge blank string stop being decoded over and over.
let raw: usize = [&b"T"[..], b"Contents", b"M", b"NM", b"State", b"DA"]
.iter()
.filter_map(|k| match d.get(k).map(|o| self.resolve(o)) {
Ok(Object::String(b, _) | Object::Name(b)) => Some(b.len()),
_ => None,
})
.fold(0, usize::saturating_add);
// (charged once: below, by the larger of these raw bytes and what the annotation keeps)
if kept.saturating_add(raw) > MAX_ANNOTATION_BYTES {
comments_full = true;
continue;
}
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
let c = match d.get(b"C").map(|o| self.resolve(o)) {
Ok(Object::Array(c)) => Some(c),
@@ -262,7 +271,7 @@ impl<'a> Inspector<'a> {
text_box,
popup,
};
kept = kept.saturating_add(retained(&annotation));
kept = kept.saturating_add(retained(&annotation).max(raw));
if kept > MAX_ANNOTATION_BYTES {
comments_full = true;
continue;
@@ -356,6 +365,23 @@ impl<'a> Inspector<'a> {
Some(Link { page, index, rect, quads, target })
}
/// Lists the link annotation `d`, and notes the JavaScript it runs. JavaScript is read from the
/// action, not the listed link: once the text budget is spent the action's name is not kept.
fn collect_link(&self, info: &mut DocInfo, at: (usize, usize), d: &Dictionary, text_budget: &mut usize, unfit_quads: &mut HashSet<ObjectId>) {
info.has_javascript |= self.runs_javascript(d);
if let Some(link) = self.link(at.0, at.1, d, info.uri_base.as_deref(), text_budget, unfit_quads) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
info.links.push(link);
}
}
/// Whether the link's own action is a JavaScript action.
fn runs_javascript(&self, d: &Dictionary) -> bool {
d.get(b"A").ok().and_then(|a| self.dict(a)).is_some_and(|a| self.name(a, b"S").as_deref() == Some("JavaScript"))
}
/// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in
/// this document; a remote go-to's names a page or destination of another file (ISO 32000-2
/// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here.
@@ -376,7 +402,7 @@ impl<'a> Inspector<'a> {
"SetOCGState" => self.layer_state(a),
"GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
"Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())),
other => LinkTarget::Other(other.to_string()),
other => LinkTarget::Other(spend(other.as_bytes(), text_budget, |b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default()),
})
}
@@ -492,6 +518,53 @@ impl<'a> Inspector<'a> {
// ── form fields ─────────────────────────────────────────────────────────────────────────
/// Whether `o` or one of its descendants under `/Kids` carries an action (`/A` or `/AA`). It
/// walks the same tree `field` does, bounded the same way: a field left out of the listing for
/// want of text allowance can still have its action on a widget several levels down.
pub(super) fn has_action(&self, o: &Object, depth: u32) -> bool {
self.has_action_seen(o, depth, &mut HashMap::new())
}
/// The walk behind [`Self::has_action`]. Each indirect descendant is read again only from a
/// shallower depth than before (the depth limit may have cut its subtree): a field tree whose
/// `/Kids` repeat the same objects would otherwise be walked exponentially, and this reads each
/// object at most once per depth.
fn has_action_seen(&self, o: &Object, depth: u32, seen: &mut HashMap<ObjectId, u32>) -> bool {
if depth > 32 {
return false;
}
if let Object::Reference(id) = o {
if seen.get(id).is_some_and(|&first| first <= depth) {
return false;
}
seen.insert(*id, depth);
}
let Some(d) = self.dict(o) else { return false };
if self.carries_action(d) {
return true;
}
let kids = match d.get(b"Kids").map(|k| self.resolve(k)) {
Ok(Object::Array(k)) => k,
_ => return false,
};
kids.iter().any(|k| self.has_action_seen(k, depth + 1, seen))
}
/// Whether `/A` or `/AA` here holds an action. An editor can leave an empty dictionary (or
/// array) behind, and that is no action: a document with none must not report JavaScript. An
/// `/A` is an action when it carries `/S`; an `/AA` is one when an event maps to such an action.
fn carries_action(&self, d: &Dictionary) -> bool {
let action = |o: &Object| matches!(self.resolve(o), Object::Dictionary(a) if a.get(b"S").is_ok());
[&b"A"[..], &b"AA"[..]].iter().any(|k| match d.get(k) {
Ok(o) => match self.resolve(o) {
Object::Dictionary(x) => x.get(b"S").is_ok() || x.iter().any(|(_, v)| action(v)),
Object::Array(a) => a.iter().any(action),
_ => false,
},
Err(_) => false,
})
}
pub(super) fn field(
&self,
o: &'a Object,
@@ -510,19 +583,35 @@ impl<'a> Inspector<'a> {
}
let Some(d) = self.dict(o) else { return };
let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None));
let partial = self.text(d, b"T");
let full = match (&partial, parent_name.is_empty()) {
(Some(t), true) => t.clone(),
(Some(t), false) => format!("{parent_name}.{t}"),
(None, _) => parent_name.to_string(),
};
let ft = self.name(d, b"FT").or(parent_ft);
let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff);
// Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal.
let mut kids: Vec<Object> = match d.get(b"Kids").map(|k| self.resolve(k)) {
Ok(Object::Array(k)) => k.clone(),
_ => Vec::new(),
};
// A field left unlisted for want of text allowance still has its actions counted, through
// its whole subtree: an action can sit on a descendant widget, not only on the field.
let unlisted = || {
if self.has_action(o, 0) {
self.field_actions.set(true);
}
};
// A /T that does not fit the allowance lists no field (not one under its parent's name).
let Ok(partial) = self.field_text_within(d, b"T") else {
unlisted();
return;
};
let full = match (&partial, parent_name.is_empty()) {
(Some(t), true) => t.clone(),
(Some(t), false) => format!("{parent_name}.{t}"),
(None, _) => parent_name.to_string(),
};
// Every field copies its parent's full name: charge it, and list no more once it is spent.
if !self.field_charge(full.len()) {
unlisted();
return;
}
let ft = self.name(d, b"FT").or(parent_ft);
let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff);
let (field_kids, widget_kids): (Vec<Object>, Vec<Object>) =
kids.iter().cloned().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok()));
let field_kids: Vec<_> = field_kids.iter().filter(|k| !self.not_a_child(walk, o, k)).collect();
@@ -570,17 +659,37 @@ impl<'a> Inspector<'a> {
_ => FieldKind::Unknown,
};
let value = match d.get(b"V").map(|v| self.resolve(v)) {
Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()),
Ok(Object::String(bytes, _)) => Some(text_string(bytes)),
Ok(Object::Array(a)) => Some(
a.iter()
.filter_map(|x| match self.resolve(x) {
Object::String(bytes, _) => Some(text_string(bytes)),
_ => None,
})
.collect::<Vec<_>>()
.join(", "),
),
Ok(Object::Name(n)) => self.field_spend(n, |n| String::from_utf8_lossy(n).into_owned()),
Ok(Object::String(bytes, _)) => self.field_spend(bytes, text_string),
Ok(Object::Array(a)) => {
let mut shown = Vec::new();
let mut dropped = false;
for x in a {
let text = match self.resolve(x) {
Object::String(bytes, _) => self.field_spend(bytes, text_string),
// A name entry (`/V [/Opt1 (b)]`) is a selection too, as the forms reader
// takes it; leaving it out reported a partial selection as complete.
Object::Name(n) => self.field_spend(n, |n| String::from_utf8_lossy(n).into_owned()),
_ => continue,
};
// The ", " between selections is charged to the allowance like the text.
match text {
Some(text) if shown.is_empty() || self.field_charge(2) => shown.push(text),
_ => dropped = true,
}
}
// A selected value the allowance could not hold shows as an omission rather than
// as part of a complete selection (its few bytes are charged, fit or not).
let mut value = shown.join(", ");
if dropped {
self.field_charge(5);
if !value.is_empty() {
value.push_str(", ");
}
value.push('…');
}
Some(value)
}
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
_ => None,
};
@@ -602,9 +711,11 @@ impl<'a> Inspector<'a> {
.and_then(|p| self.page_of(p))
.or_else(|| self.find_widget_page(o, &kids))
.or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied()));
let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok());
let has_actions = self.carries_action(d)
|| widget.is_some_and(|w| self.carries_action(w))
|| kids.iter().any(|k| self.dict(k).is_some_and(|w| self.carries_action(w)));
let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r)));
out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect });
out.push(Field { name: full, kind, value, page, tooltip: self.field_text(d, b"TU"), has_actions, rect });
}
/// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself
+62 -6
View File
@@ -155,16 +155,34 @@ pub(super) struct Inspector<'a> {
/// has no `/P`. Searching every page's `/Annots` for each such field was O(fields ×
/// annotations): a second on a form of 8,000 fields.
pub(super) annotation_pages: OnceCell<HashMap<ObjectId, usize>>,
/// What is left of [`MAX_FIELD_TEXT_BYTES`]: names, values and tooltips of form fields and
/// layer names, which many entries can share one string of.
pub(super) field_text: std::cell::Cell<usize>,
/// Set when a field with an action went unlisted because the text allowance was spent, so the
/// document still reports JavaScript.
pub(super) field_actions: std::cell::Cell<bool>,
/// The first page widget naming each field as its `/Parent`, built when a field has no widget
/// among its `/Kids`.
pub(super) parent_widgets: OnceCell<HashMap<ObjectId, ObjectId>>,
}
/// Bytes of field and layer text one inspection reads and keeps together.
pub(super) const MAX_FIELD_TEXT_BYTES: usize = 64 << 20;
impl<'a> Inspector<'a> {
pub(super) fn new(doc: &'a dyn Structure) -> Self {
let page_ids = doc.page_ids();
let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() };
let mut me = Self {
doc,
page_ids,
page_index,
named: HashMap::new(),
annotation_pages: OnceCell::new(),
field_text: MAX_FIELD_TEXT_BYTES.into(),
field_actions: false.into(),
parent_widgets: OnceCell::new(),
};
let mut named = HashMap::new();
if let Some(tree) =
doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o))
@@ -260,7 +278,7 @@ impl<'a> Inspector<'a> {
}
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
info.has_javascript |= info.fields.iter().any(|f| f.has_actions) || self.field_actions.get();
let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false);
if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) {
// A form with no fields is taken to be laid out from its template, unless its
@@ -351,11 +369,49 @@ impl<'a> Inspector<'a> {
/// Whitespace-trimmed metadata, spent from `budget` as [`spend`] does, so text that trims
/// short or to nothing still spends its raw length.
/// [`Self::budgeted_text`] against the shared field and layer text allowance.
pub(super) fn field_text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
let mut left = self.field_text.get();
let text = self.budgeted_text(d, key, &mut left);
self.field_text.set(left);
text
}
/// Converts `bytes` against the field and layer text allowance, as [`spend`] does.
pub(super) fn field_spend(&self, bytes: &[u8], convert: impl FnOnce(&[u8]) -> String) -> Option<String> {
let mut left = self.field_text.get();
let text = spend(bytes, &mut left, convert);
self.field_text.set(left);
text
}
/// Spends `n` bytes of the field and layer text allowance; `false` when too few are left.
pub(super) fn field_charge(&self, n: usize) -> bool {
let left = self.field_text.get();
self.field_text.set(left.saturating_sub(n));
n <= left
}
pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
let o = self.resolve(d.get(key).ok()?);
let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None };
let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?;
(!text.is_empty()).then_some(text)
self.budgeted_text_within(d, key, budget).ok().flatten()
}
/// [`Self::budgeted_text`], but `Err` when the text is there and does not fit `budget`
/// (against `Ok(None)`: no text, or none that trims to anything).
pub(super) fn budgeted_text_within(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Result<Option<String>, ()> {
let Ok(o) = d.get(key) else { return Ok(None) };
let o = self.resolve(o);
let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return Ok(None) };
let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default()).ok_or(())?;
Ok((!text.is_empty()).then_some(text))
}
/// [`Self::field_text`], but `Err` when the text does not fit the allowance.
pub(super) fn field_text_within(&self, d: &Dictionary, key: &[u8]) -> Result<Option<String>, ()> {
let mut left = self.field_text.get();
let text = self.budgeted_text_within(d, key, &mut left);
self.field_text.set(left);
text
}
pub(super) fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
+1 -1
View File
@@ -250,7 +250,7 @@ impl<'a> Inspector<'a> {
None => {}
}
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
let title = self.text(d, b"Title").unwrap_or_default();
let title = self.field_text(d, b"Title").unwrap_or_default();
let color = match d.get(b"C").map(|o| self.resolve(o)) {
Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())),
_ => None,
+6 -1
View File
@@ -121,9 +121,14 @@ impl<'a> Inspector<'a> {
.unwrap_or_default();
let auto = config.map(|c| self.view_auto_state(c)).unwrap_or_default();
let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return };
let mut seen = HashSet::new();
for g in ocgs {
let Some(d) = self.dict(g) else { continue };
let id = g.as_reference().ok();
// A group listed twice is one layer (and its name is read once).
if id.is_some_and(|id| !seen.insert(id)) {
continue;
}
// The View auto state, when it manages the group, then the configuration.
let visible = match id {
Some(id) if let Some(&state) = auto.get(&id) => state,
@@ -132,7 +137,7 @@ impl<'a> Inspector<'a> {
_ => !base_off,
};
let Some(id) = id else { continue };
out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible });
out.push(Layer { id, name: self.field_text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible });
}
}
+80
View File
@@ -1161,6 +1161,50 @@ fn repeated_annotations_are_listed_once_within_a_byte_budget() {
assert!(notes.iter().map(retained).sum::<usize>() <= MAX_ANNOTATION_BYTES);
}
/// Blank text is not kept, so only reading it is charged: many comments sharing one large blank
/// string stop being built at the budget instead of decoding it once each.
#[test]
fn comments_sharing_a_blank_string_stop_at_the_read_budget() {
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200);
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 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 200 200] /Annots [{small}] >> endobj\n\
7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
" ".repeat(64 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(!info.annotations.is_empty() && info.annotations.len() < 1200, "{}", info.annotations.len());
}
/// Many fields sharing one large tooltip, and one layer listed many times, are read within a byte
/// allowance rather than once each.
#[test]
fn fields_sharing_a_tooltip_and_repeated_layers_stop_at_the_text_budget() {
let fields = (0..1300).map(|i| format!("<< /FT /Tx /T (f{i}) /TU 7 0 R /V 7 0 R >> ")).collect::<String>();
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{fields}] >> \
/OCProperties << /OCGs [{}] >> >> 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 200 200] >> endobj\n\
6 0 obj << /Type /OCG /Name (Ink) >> endobj\n\
7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
"6 0 R ".repeat(1000),
"x".repeat(32 << 10),
);
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
let kept: usize = info.fields.iter().map(|f| f.tooltip.as_ref().map_or(0, String::len) + f.value.as_ref().map_or(0, String::len)).sum();
assert!(kept > 0 && kept <= super::core::MAX_FIELD_TEXT_BYTES, "{kept}");
assert!(info.fields.iter().any(|f| f.tooltip.is_none()), "text past the allowance is left out");
assert_eq!(info.layers.len(), 1);
}
/// A link reached through an indirect object that holds only a reference to it is the same
/// annotation as the link itself, and so is a page array reached the same way: each is listed once.
#[test]
@@ -2686,6 +2730,42 @@ fn inspection_keeps_no_private_page_data() {
assert_eq!(report(with), report(without), "what inspection reports does not change");
}
/// A field with several widgets reports an action on any of them, not only the first, and so does
/// the document's JavaScript flag.
#[test]
fn a_field_reports_an_action_on_a_later_widget() {
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [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 200 200] >> endobj\n\
4 0 obj << /FT /Tx /T (f) /Kids [5 0 R 6 0 R] >> endobj\n\
5 0 obj << /Subtype /Widget /Rect [0 0 1 1] >> endobj\n\
6 0 obj << /Subtype /Widget /Rect [0 0 1 1] /A << /S /JavaScript /JS (app.alert(1)) >> >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF";
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
let mut info = DocInfo { pages: vec![page], ..Default::default() };
Inspector::new(&doc).fill(&mut info);
assert!(info.fields.iter().any(|f| f.name == "f" && f.has_actions), "{:?}", info.fields);
assert!(info.has_javascript);
}
/// A shared descendant first reached at the depth limit (its action-bearing child cut) is read
/// again from a shallower path, so the action is still found.
#[test]
fn an_action_below_a_shared_field_first_met_deep_is_found_from_a_shallower_path() {
// 1 is the root, 2..=32 a chain of 31 fields, 33 the shared field, 34 holds the action,
// 35 a short path to 33.
let mut bodies = vec!["<< /Kids [2 0 R 35 0 R] >>".to_string()];
for i in 2..=32 {
bodies.push(format!("<< /Kids [{} 0 R] >>", i + 1));
}
bodies.push("<< /Kids [34 0 R] >>".into());
bodies.push("<< /A << /S /JavaScript /JS (x) >> >>".into());
bodies.push("<< /Kids [33 0 R] >>".into());
let doc = Document::load_mem(&pdf_of(&bodies)).expect("loads");
assert!(Inspector::new(&doc).has_action(&Object::Reference((1, 0)), 0));
}
/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its
/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field
/// keeps its other kids and is listed with them.
+15 -1
View File
@@ -431,8 +431,12 @@ pub fn list(doc: &Document, bytes: &[u8], trust: &TrustStore) -> Vec<SignatureIn
/// structure and validation against `bytes` and `trust` are read again on every call.
pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &DigestCache) -> Vec<SignatureInfo> {
let pages = annot_pages(doc);
let mut out = Vec::new();
let mut out: Vec<SignatureInfo> = Vec::new();
let mut field_values: HashSet<ObjRef> = HashSet::new();
// Fields that name one signature value are judged once: validation does not depend on the
// field, and repeating it per field would repeat the digest, chain and evidence work.
// The row of `out` that holds each value's verdict: a later field copies it, not a map of copies.
let mut judged: HashMap<ObjRef, usize> = HashMap::new();
let (fields, whole) = walk_sig_fields(doc);
for f in fields {
if let Some(v_ref) = f.dict.get(b"V").and_then(Object::as_ref) {
@@ -445,6 +449,13 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
.filter(|r| r.len() == 4)
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
let visible = widget.is_some_and(|(_, w)| widget_shown(doc, w));
// A repeat of a judged value reads nothing of it: the signature dictionary is not copied.
let shared = f.dict.get(b"V").and_then(Object::as_ref).filter(|_| f.dict.get(b"V").is_some_and(|v| doc.resolve(v).as_dict().is_some()));
if let Some(first) = shared.and_then(|r| judged.get(&r)).and_then(|&i| out.get(i)) {
let own = SignatureInfo { field: f.name.clone(), signed: true, page, rect, visible, ..first.clone() };
out.push(own);
continue;
}
let v = f.signature(doc);
let mut info = SignatureInfo {
field: f.name.clone(),
@@ -477,6 +488,9 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
if let Some(v) = v {
validate_into(doc, bytes, trust, &v, &mut info, cache);
}
if let Some(r) = shared {
judged.insert(r, out.len());
}
out.push(info);
}
// Standalone document timestamps (ISO 32000-2 §12.8.2.2) live outside AcroForm fields.
+17 -3
View File
@@ -371,7 +371,11 @@ pub(crate) const MAX_DSS_BYTES: usize = 64 << 20;
pub(crate) struct DssEvidence<'a> {
doc: &'a Document,
items: std::vec::IntoIter<Object>,
seen: HashSet<ObjRef>,
/// Object numbers read: `Document::get` resolves a reference by number, so `5 0 R` and `5 1 R`
/// are one stream.
seen: HashSet<u32>,
/// Full references already tried, resolved or not.
tried: HashSet<ObjRef>,
taken: usize,
left: usize,
/// A cap stopped the array being read in full.
@@ -381,7 +385,7 @@ pub(crate) struct DssEvidence<'a> {
impl<'a> DssEvidence<'a> {
pub(crate) fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self {
let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false }
DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), tried: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false }
}
}
@@ -395,11 +399,21 @@ impl Iterator for DssEvidence<'_> {
// Repeats are skipped before the caps count anything, so repeating one stream cannot use
// them up; the scan of the array itself is bounded by the parser's cap on array length.
for item in self.items.by_ref() {
if item.as_ref().is_some_and(|r| !self.seen.insert(r)) {
// Resolved first: a reference to a generation the slot doesn't hold reads as null and
// must not make a later, valid reference to the same object number a repeat.
// A repeat is skipped before anything is resolved: a long chain of repeats costs no
// lookups. The number is recorded only once the reference gave a stream.
// Each full reference is tried once, so one that never reaches a stream (a cycle) costs
// its lookups once, however often it is listed; `seen` keeps object numbers apart so a
// dead generation can't hide a live one.
if item.as_ref().is_some_and(|r| self.seen.contains(&r.num) || !self.tried.insert(r)) {
continue;
}
let o = self.doc.resolve(&item);
let Object::Stream(s) = o.as_ref() else { continue };
if let Some(r) = item.as_ref() {
self.seen.insert(r.num);
}
if self.taken >= MAX_DSS_ENTRIES {
self.cut = true;
return None;
+13
View File
@@ -373,6 +373,19 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() {
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
}
/// A reference to a generation the object doesn't have reads as null: it is no repeat of the valid
/// reference to the same object number that follows it.
#[test]
fn dss_evidence_skips_a_dead_reference_without_hiding_the_live_one() {
let mut doc = fixture(&[]);
let live = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence")));
let dead = ObjRef::new(live.num, live.generation + 1);
let mut dss = Dict::new();
dss.set(b"CRLs".to_vec(), Object::Array(vec![Object::Ref(dead), Object::Ref(live)]));
let read: Vec<Vec<u8>> = DssEvidence::new(&doc, &dss, b"CRLs").collect();
assert_eq!(read, vec![b"evidence".to_vec()]);
}
#[test]
fn dss_evidence_decodes_each_stream_once_within_its_caps() {
// One small stream listed 1000 times, then streams that inflate past the evidence cap.
+47
View File
@@ -325,6 +325,21 @@ fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() {
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
}
/// A signature widget taken off the page's inline /Annots array is covered by no permission (the
/// indirect-array case is the test above), and stays a change whatever level the signature has.
#[test]
fn removing_a_widget_from_an_inline_annots_array_is_not_a_comment_change() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let inline = open(&signed).get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()).unwrap();
assert!(matches!(inline, Object::Array(ref a) if !a.is_empty()), "the page keeps its widgets inline");
let stripped = edit_after(&signed, |doc| doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![]))).unwrap());
let s = signatures(&open(&stripped), &stripped, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
}
/// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned:
/// it is listed as unsigned and can be signed by name.
#[test]
@@ -3354,6 +3369,38 @@ fn signing_a_field_with_a_null_value_after_another_signature_keeps_the_first_val
);
}
/// Fields that name one signature value are listed each, under their own name, with the verdict
/// the value earned once.
#[test]
fn fields_sharing_a_signature_value_are_listed_with_one_verdict() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let v = open(&signed).get(pdfcraft_cos::ObjRef { num: 6, generation: 0 }).as_dict().and_then(|d| d.get(b"V").cloned()).unwrap();
let shared = edit_after(&signed, |doc| {
let mut w = pdfcraft_cos::Dict::new();
w.set(b"Type".to_vec(), Object::name("Annot"));
w.set(b"Subtype".to_vec(), Object::name("Widget"));
w.set(b"FT".to_vec(), Object::name("Sig"));
w.set(b"T".to_vec(), PdfString::text("Twin"));
w.set(b"V".to_vec(), v.clone());
w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4]));
let w = doc.add(Object::Dict(w));
let root = doc.root().unwrap();
let af = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()).unwrap();
let af = doc.resolve(&af).as_dict().cloned().unwrap();
let mut fields = af.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap();
fields.push(Object::Ref(w));
let mut af = af;
af.set(b"Fields".to_vec(), Object::Array(fields));
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap();
});
let list = signatures(&open(&shared), &shared, &trust);
let (a, b) = (list.iter().find(|s| s.field == "Approval").unwrap(), list.iter().find(|s| s.field == "Twin").unwrap());
assert!(a.signed && b.signed);
assert_eq!((a.status, &a.modification, &a.signer, a.signed_len), (b.status, &b.modification, &b.signer, b.signed_len));
}
#[test]
fn the_catalogs_perms_do_not_change_under_no_changes() {
// /Perms names the certification signature (DocMDP). Under P=1 nothing may change, so a later
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.pdfcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+13 -3
View File
@@ -148,13 +148,21 @@ pub enum DataOp {
impl DataNode {
/// The node at `path`, made (with empty siblings before it) where missing; `None` past
/// the index cap.
fn ensure(&mut self, path: &[(String, usize)]) -> Option<&mut DataNode> {
fn ensure(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut DataNode> {
let mut node = self;
for (name, idx) in path {
if *idx >= MAX_INDEX {
return None;
}
let have = node.children.iter().filter(|c| &c.name == name).count();
// Every skipped sibling owns a copy of the name: bounded as `Build::at` bounds a write.
// The budget is the whole path's, not each level's: a deep path that is affordable at
// every level taken alone would otherwise allocate its way past the bound.
let need = (*idx + 1).saturating_sub(have).saturating_mul(name.len());
if need > *budget {
return None;
}
*budget -= need;
for _ in have..=*idx {
node.children.push(DataNode { name: name.clone(), ..Default::default() });
}
@@ -166,9 +174,11 @@ impl DataNode {
/// Apply `op` to this tree (the root is the `xfa:data` element), as [`write_data_ops`]
/// will to the packet, so scripts that run before it is written see the change.
pub fn apply(&mut self, op: &DataOp) {
// One budget for the op's whole path, as `Build::at` bounds a write.
let mut budget = build::MAX_NEW_NAME_BYTES;
match op {
DataOp::Value { path, text } => {
if let Some(n) = self.ensure(path) {
if let Some(n) = self.ensure(path, &mut budget) {
// A multi-select list's picks, one `value` each (see `value_at`), told apart
// as the packet writer does: `value` leaves only, or a group holding none.
let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) };
@@ -189,7 +199,7 @@ impl DataNode {
if let Some(&last) = count.checked_sub(1).as_ref() {
let mut path = parent.clone();
path.push((name.clone(), last.min(MAX_INDEX.saturating_sub(1))));
let _ = self.ensure(&path);
let _ = self.ensure(&path, &mut budget);
}
}
DataOp::Remove(path) => {
+1 -1
View File
@@ -38,7 +38,7 @@ pub(super) const MAX_INDEX: usize = 10_000;
/// Most bytes of names the nodes one write creates hold together: every sibling an index
/// skips over gets its own copy of the name.
const MAX_NEW_NAME_BYTES: usize = 4 << 20;
pub(super) const MAX_NEW_NAME_BYTES: usize = 4 << 20;
/// What one write may still create: nodes and bytes of their names.
pub(super) struct NewNodes {
+58 -22
View File
@@ -1,26 +1,38 @@
use super::import::datasets_packet;
use super::merge::Op;
use super::path::{data_path, parse_datasets};
use super::path::{data_path, parse_datasets_packet};
use super::values::{Want, path_of, text_of, with_template};
use super::write::{datasets_stream_ref, rewrite};
use super::{DataNode, DataPath, Document, MAX_DEPTH, Node, ObjRef, Object, SomIndices, Template, Ui, XfaError};
/// How deep the AcroForm field tree is walked for password fields: as deep as the form's own
/// field walk (`pdfcraft_forms::fields`), which is what editing and dataset synchronization see.
const MAX_ACRO_DEPTH: usize = 64;
/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14,
/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm
/// dictionary.
fn acro_password_fields(doc: &Document) -> Vec<(String, String)> {
/// dictionary. `None` when the walk was cut short at [`MAX_ACRO_DEPTH`]: which branch is cut
/// depends on the order the tree is visited in, so a field the form's own walk reaches may have
/// been missed, and the caller can't tell that no password field is left.
fn acro_password_fields(doc: &Document) -> Option<Vec<(String, String)>> {
const PASSWORD_FLAG: i64 = 1 << 13;
let Some(root) = doc.root() else { return Vec::new() };
let Some(root) = doc.root() else { return Some(Vec::new()) };
let catalog = doc.get(root);
let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() };
let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Some(Vec::new()) };
let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else {
return Vec::new();
return Some(Vec::new());
};
struct AcroWalk<'a> {
doc: &'a Document,
seen: std::collections::HashSet<ObjRef>,
/// Each field reached, with what it was reached with: its parent's name, type, flags and
/// default, which decide what it classifies as.
seen: std::collections::HashMap<ObjRef, Reach>,
/// Fields past the depth limit, with what they were reached with. One reached again from
/// another root in the same way was classified there; any other is not fully read.
cut: Vec<(ObjRef, Reach)>,
out: Vec<(String, String)>,
}
type Reach = (String, Option<Vec<u8>>, i64, Option<String>);
impl AcroWalk<'_> {
fn walk(
&mut self,
@@ -31,12 +43,19 @@ fn acro_password_fields(doc: &Document) -> Vec<(String, String)> {
inherited_dv: Option<&str>,
depth: usize,
) {
if depth > 32 {
if depth > MAX_ACRO_DEPTH {
let reach: Reach = (parent_name.to_string(), inherited_ft.map(<[u8]>::to_vec), inherited_ff, inherited_dv.map(str::to_string));
self.cut.extend(items.iter().filter_map(Object::as_ref).map(|r| (r, reach.clone())));
return;
}
for item in items {
let Some(r) = item.as_ref() else { continue };
if !self.seen.insert(r) {
let reach: Reach = (parent_name.to_string(), inherited_ft.map(<[u8]>::to_vec), inherited_ff, inherited_dv.map(str::to_string));
// The reach a field was classified with stays: a later visit that skips it classified
// nothing, so it must not stand in for the cut-off one.
if let std::collections::hash_map::Entry::Vacant(slot) = self.seen.entry(r) {
slot.insert(reach);
} else {
continue;
}
let obj = self.doc.get(r);
@@ -65,20 +84,35 @@ fn acro_password_fields(doc: &Document) -> Vec<(String, String)> {
}
}
}
let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() };
let mut w = AcroWalk { doc, seen: std::collections::HashMap::new(), cut: Vec::new(), out: Vec::new() };
w.walk(&top, "", None, 0, None, 0);
w.out
// Decided once every root has been visited: a branch cut deep down may be reached shallower
// from a later root, and is then read in full.
let incomplete = w.cut.iter().any(|(r, reach)| w.seen.get(r) != Some(reach));
(!incomplete).then_some(w.out)
}
/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their
/// defaults (or empty when they have no default).
fn password_dataset_wants(doc: &Document) -> Vec<Want> {
///
/// The packet is read as the datasets writer reads it ([`parse_datasets_packet`]): a packet the
/// writer can't read holds no value it wrote, so there is nothing typed in it to clear. An error
/// when the password fields can't all be found (a field tree deeper than the field walk reaches):
/// the save is refused rather than written with a typed password left in the data.
fn password_dataset_wants(doc: &Document) -> Result<Vec<Want>, XfaError> {
if datasets_stream_ref(doc).is_none() {
return Vec::new();
return Ok(Vec::new());
}
let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() };
let Some(data) = parse_datasets(&packet) else { return Vec::new() };
with_template(doc, None, |tpl| {
// Checked before the packet is read: a packet that can't be parsed leaves nothing to clear, but
// a form whose fields can't all be found is refused whatever the packet holds.
let Some(acro_fields) = acro_password_fields(doc) else {
return Err(XfaError::Malformed(
"the form's fields nest too deeply to find every password field, so typed passwords could not be removed from its XFA data".into(),
));
};
let Ok(Some(packet)) = datasets_packet(doc) else { return Ok(Vec::new()) };
let Some(data) = parse_datasets_packet(&packet) else { return Ok(Vec::new()) };
Ok(with_template(doc, None, |tpl| {
let mut candidates: Vec<(String, DataPath, String)> = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(t) = tpl {
@@ -153,7 +187,7 @@ fn password_dataset_wants(doc: &Document) -> Vec<Want> {
}
}
}
for (som, default) in acro_password_fields(doc) {
for (som, default) in acro_fields {
let path = path_of(tpl, &som);
if !path.is_empty() && seen.insert(path.clone()) {
candidates.push((som, path, default));
@@ -166,27 +200,29 @@ fn password_dataset_wants(doc: &Document) -> Vec<Want> {
(!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false })
})
.collect()
})
}))
}
/// Whether any XFA password field (`<passwordEdit/>` or an AcroForm text field with the Password
/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944).
/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). A form whose
/// password fields can't all be found counts as holding some, so that clearing them
/// ([`clear_password_values`]) reports why the document can't be written without them.
pub fn has_password_values(doc: &Document) -> bool {
!password_dataset_wants(doc).is_empty()
!password_dataset_wants(doc).is_ok_and(|w| w.is_empty())
}
/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default
/// where the field has one, else emptying the element), replacing the datasets stream in place
/// (#3944). Returns how many password fields were cleared in the datasets.
pub fn clear_password_values(doc: &mut Document) -> Result<usize, XfaError> {
let wants = password_dataset_wants(doc);
let wants = password_dataset_wants(doc)?;
if wants.is_empty() {
return Ok(0);
}
let count = wants.len();
let reuse = datasets_stream_ref(doc);
let report = rewrite(doc, &[Op::Values(&wants)], reuse)?;
if !report.written || !password_dataset_wants(doc).is_empty() {
if !report.written || !password_dataset_wants(doc)?.is_empty() {
let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str);
return Err(XfaError::Malformed(reason.to_string()));
}
+27
View File
@@ -24,6 +24,33 @@ pub fn parse_datasets(xdp: &str) -> Option<DataNode> {
Some(to_node(data, 0))
}
/// The `xfa:data` element of a datasets packet as the datasets writer reads it before changing it:
/// a packet of an array may use the `xfa` and `xdp` prefixes its XDP envelope declares, so it is
/// read inside an element declaring them when it doesn't parse alone, and the datasets element in
/// the XFA data namespace is preferred over another with the same local name. Reading the packet
/// any other way could miss the values the writer put in it.
pub(super) fn parse_datasets_packet(text: &str) -> Option<DataNode> {
if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH {
return None;
}
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() };
let wrapped;
let doc = match roxmltree::Document::parse_with_options(text, opts()) {
Ok(d) => d,
Err(_) => {
wrapped = format!("{}{text}{}", super::merge::WRAP_OPEN, super::merge::WRAP_CLOSE);
roxmltree::Document::parse_with_options(&wrapped, opts()).ok()?
}
};
let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name;
let datasets = doc
.descendants()
.find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(super::XFA_DATA_NS))
.or_else(|| doc.descendants().find(|n| is(n, "datasets")))?;
let data = datasets.children().find(|n| is(n, "data"))?;
Some(to_node(data, 0))
}
/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`.
/// Unnamed containers (`#subform`) have no data node.
pub fn som_to_path(som: &str) -> DataPath {
+23 -1
View File
@@ -82,6 +82,11 @@ pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result<Datas
/// pass, which asks for the instances again).
const MAX_ENSURED: usize = 200_000;
/// Most bytes of path those queued instances hold together: each one owns a copy of its parent
/// path (segment buffers included, twice while duplicates are dropped) and its name, so a long
/// ancestor would otherwise be multiplied by the instance count. Ordinary paths are tens of bytes.
const MAX_ENSURED_BYTES: usize = 128 << 20;
// Instance nodes write_data_ops has queued on this thread, for tests that bound its work.
#[cfg(test)]
thread_local! {
@@ -99,7 +104,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
let mut run: Vec<Want> = Vec::new();
// Instances queued in this run, by parent and name: asking for more only queues the rest.
let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new();
let (mut ensured, mut capped) = (0usize, false);
let (mut ensured, mut ensured_bytes, mut capped, mut capped_bytes) = (0usize, 0usize, false, false);
let flush = |run: &mut Vec<Want>, steps: &mut Vec<Vec<Want>>, removes: &mut Vec<Option<DataPath>>| {
if !run.is_empty() {
let mut seen = std::collections::HashSet::new();
@@ -117,12 +122,24 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
}
DataOp::Instances { parent, name, count } => {
let done = queued.entry((parent, name.as_str())).or_insert(0);
// The path (its segments' buffers and their text) is held by the queued step, which a
// clone then `push` can leave with up to twice the room, and cloned again into
// `flush`'s dedup set, beside the label and the step itself.
let segment = std::mem::size_of::<(String, usize)>();
let path_bytes =
parent.iter().fold(segment.saturating_add(name.len()), |n, (p, _)| n.saturating_add(segment).saturating_add(p.len()));
let per_instance = path_bytes.saturating_mul(3).saturating_add(name.len()).saturating_add(16 + std::mem::size_of::<Want>());
while *done < (*count).min(MAX_INDEX) {
if ensured >= MAX_ENSURED {
capped = true;
break;
}
if ensured_bytes.saturating_add(per_instance) > MAX_ENSURED_BYTES {
capped_bytes = true;
break;
}
ensured += 1;
ensured_bytes += per_instance;
#[cfg(test)]
ENSURED.with(|n| n.set(n.get() + 1));
let k = *done;
@@ -154,6 +171,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
if capped {
written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often"));
}
if capped_bytes {
written
.warnings
.push(format!("XFA data instances not written past {MAX_ENSURED_BYTES} bytes of names: the rows were added and removed too often"));
}
Ok(written)
}
+93
View File
@@ -7114,6 +7114,99 @@ fn a_container_is_as_wide_as_a_turned_draw_stands() {
}
}
/// The static fixture with its text field (`form1[0].page1[0].name[0]`) flagged Password and
/// `levels` unnamed fields between `form1[0]` and `page1[0]`, which add no SOM segment.
fn static_password_doc(levels: usize, data: &str) -> Document {
let mut doc = Document::open(Arc::new(static_shell(data))).unwrap();
doc.update_dict(pdfcraft_cos::ObjRef::new(8, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1 << 13))).unwrap();
let mut next = pdfcraft_cos::ObjRef::new(7, 0);
for _ in 0..levels {
let mut d = Dict::new();
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(next)]));
next = doc.add(Object::Dict(d));
}
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(next)]))).unwrap();
doc
}
/// #3944: a Password-flagged AcroForm field of a static XFA form nested deeper than 32 levels (as
/// deep as the form's own field walk reaches) has its typed value cleared from the datasets.
#[test]
fn a_deeply_nested_acroform_password_field_is_cleared_from_the_datasets() {
let data = "<form1><page1><name>hunter2</name></page1></form1>";
let mut doc = static_password_doc(40, data);
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
assert!(!String::from_utf8_lossy(&xfa_bytes(&doc)).contains("hunter2"));
}
/// A field tree deeper than the walk reaches can't be shown to hold no password field: clearing
/// refuses (so the save does) rather than leave a typed password in the datasets.
#[test]
fn a_field_tree_too_deep_to_check_for_passwords_refuses_to_clear() {
let mut doc = static_password_doc(80, "<form1><page1><name>hunter2</name></page1></form1>");
assert!(has_password_values(&doc));
assert!(clear_password_values(&mut doc).is_err());
// A shallow form without a typed value has nothing to clear.
let mut doc = static_password_doc(0, "<form1><page1><name></name></page1></form1>");
assert!(!has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 0);
}
/// A datasets packet of an XFA array may use the `xfa` prefix its preamble declares: the typed
/// password in it is still found and cleared, as the datasets writer reads that packet too.
#[test]
fn a_password_in_a_datasets_packet_using_the_preamble_prefix_is_cleared() {
let mut doc = static_password_doc(0, "");
let pre = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">"#;
let tpl = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form1"/></template>"#;
let ds = "<xfa:datasets><xfa:data><form1><page1><name>hunter2</name></page1></form1></xfa:data></xfa:datasets>";
let parts: Vec<Object> = [("preamble", pre), ("template", tpl), ("datasets", ds), ("postamble", "</xdp:xdp>")]
.into_iter()
.flat_map(|(name, text)| {
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), text.as_bytes())));
[Object::String(pdfcraft_cos::PdfString::literal(name.as_bytes().to_vec())), Object::Ref(r)]
})
.collect();
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap();
acro.set(b"XFA".to_vec(), Object::Array(parts));
c.set(b"AcroForm".to_vec(), Object::Dict(acro));
})
.unwrap();
assert!(crate::data::parse_datasets(ds).is_none(), "the packet doesn't parse alone");
assert!(has_password_values(&doc));
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
assert!(!has_password_values(&doc));
}
/// Every sibling a SOM index skips owns a copy of the element name: the in-memory builder refuses
/// a path whose copies would pass the name-byte bound the writer holds itself to, and creates
/// nothing for it.
#[test]
fn the_in_memory_data_builder_bounds_copied_name_bytes() {
let mut root = DataNode::default();
// 1 KiB x 9,999 skipped siblings is about 10 MB, over the 4 MiB bound.
let long = "n".repeat(1 << 10);
root.apply(&DataOp::Value { path: vec![(long.clone(), 9_999)], text: "x".into() });
assert!(root.children.is_empty());
// Within the bound, the same shape still builds (and keeps indexes).
root.apply(&DataOp::Value { path: vec![(long, 100)], text: "x".into() });
assert_eq!(root.children.len(), 101);
}
/// Each queued instance owns a copy of its parent path: a long ancestor stops the queue at the
/// byte allowance, reported as capped, instead of being multiplied by the instance count.
#[test]
fn queued_instances_are_bounded_by_the_path_bytes_they_copy() {
let mut doc = Document::new_empty();
let parent: DataPath = vec![("a".repeat(16 << 20), 0), ("b".repeat(16 << 20), 0)];
let written = crate::data::write_data_ops(&mut doc, &[DataOp::Instances { parent, name: "row".into(), count: 100 }], None).unwrap();
assert!(written.warnings.iter().any(|w| w.contains("not written past")), "{:?}", written.warnings);
}
/// A page break splits an empty multi-line field that grows with its text (XFA 3.3 pp. 291–292:
/// text splits between lines, and a field's `keep intact` is `none`): it starts where it is, its
/// widget runs to the bottom of the content area, and the rest of its box, closed on every side,
@@ -17,6 +17,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: pdfcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: pdfcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland