Merge upstream/main into fix/coderabbit-c4-core

This commit is contained in:
aiongg
2026-10-22 20:29:34 +00:00
31 changed files with 1421 additions and 259 deletions
Generated
+37 -36
View File
@@ -3936,7 +3936,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-quit",
@@ -3962,7 +3962,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3974,7 +3974,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3985,7 +3985,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3996,7 +3996,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4036,18 +4036,19 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.11.0"
version = "0.12.0"
[[package]]
name = "pdfcraft-content"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4062,7 +4063,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4078,7 +4079,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"md-5",
@@ -4090,7 +4091,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4105,7 +4106,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4146,14 +4147,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"proptest",
@@ -4163,7 +4164,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4173,7 +4174,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4186,11 +4187,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.11.0"
version = "0.12.0"
[[package]]
name = "pdfcraft-js"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4198,7 +4199,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4211,14 +4212,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4228,7 +4229,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4241,11 +4242,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.11.0"
version = "0.12.0"
[[package]]
name = "pdfcraft-organize"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4256,7 +4257,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4266,7 +4267,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4277,7 +4278,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4289,7 +4290,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4305,7 +4306,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4324,7 +4325,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4359,11 +4360,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.11.0"
version = "0.12.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4408,7 +4409,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -4420,7 +4421,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4434,7 +4435,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
+34 -34
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
pdfcraft-model = { path = "crates/model", version = "0.11.0" }
pdfcraft-content = { path = "crates/content", version = "0.11.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
pdfcraft-print = { path = "crates/print", version = "0.11.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
pdfcraft-js = { path = "crates/js", version = "0.11.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
pdfcraft-export = { path = "crates/export", version = "0.11.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
pdfcraft-create = { path = "crates/create", version = "0.11.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
pdfcraft-render = { path = "crates/render", version = "0.11.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.12.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.12.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.12.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.12.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.12.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.12.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.12.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" }
pdfcraft-model = { path = "crates/model", version = "0.12.0" }
pdfcraft-content = { path = "crates/content", version = "0.12.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.12.0" }
pdfcraft-print = { path = "crates/print", version = "0.12.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.12.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" }
pdfcraft-js = { path = "crates/js", version = "0.12.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.12.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" }
pdfcraft-export = { path = "crates/export", version = "0.12.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.12.0" }
pdfcraft-create = { path = "crates/create", version = "0.12.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.12.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" }
pdfcraft-render = { path = "crates/render", version = "0.12.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.12.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.12.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false }
# pdf
hayro = "0.7.1"
+69 -16
View File
@@ -182,17 +182,51 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
}
}
/// Where the marked content `ids` is drawn on a page (images, forms and paths).
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked
/// for, and the bounds of what has been drawn in it so far.
struct Mark {
id: Option<i64>,
drawn: Option<[f64; 4]>,
}
/// Adds `b` to what the innermost open sequence has drawn.
fn draw(marks: &mut [Mark], b: [f64; 4]) {
if let Some(m) = marks.last_mut() {
m.drawn = Some(union(m.drawn, b));
}
}
/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it.
fn close(marks: &mut Vec<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f64; 4]>) {
let Some(m) = marks.pop() else { return };
if m.id.is_some() {
*matching = matching.saturating_sub(1);
}
let Some(b) = m.drawn else { return };
if let Some(id) = m.id {
let e = out.entry(id).or_insert(b);
*e = union(Some(*e), b);
}
draw(marks, b);
}
/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> HashMap<i64, [f64; 4]> {
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let props = page_properties(doc, page);
#[cfg(test)]
crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1));
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
let mut marks: Vec<bool> = Vec::new();
let mut bbox = None;
// The open marked-content sequences, and how many of them are one of `ids`.
let mut marks: Vec<Mark> = Vec::new();
let mut matching = 0usize;
let mut out = HashMap::new();
let mut path: Vec<(f64, f64)> = Vec::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let inside = marks.iter().any(|m| *m);
crate::count_mark_reads(1);
let inside = matching > 0;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
@@ -206,11 +240,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
*t = m.then(&top);
}
}
b"BMC" => marks.push(false),
b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
b"EMC" => {
marks.pop();
b"BMC" => marks.push(Mark { id: None, drawn: None }),
b"BDC" => {
let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n));
matching += usize::from(id.is_some());
marks.push(Mark { id, drawn: None });
}
b"EMC" => close(&mut marks, &mut matching, &mut out),
b"m" | b"l" => {
if let Some([x, y]) = op.nums::<2>() {
path.push(top.apply(x, y));
@@ -231,7 +267,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
if inside && !matches!(op.op.as_slice(), b"n") {
for (x, y) in &path {
bbox = Some(union(bbox, [*x, *y, *x, *y]));
draw(&mut marks, [*x, *y, *x, *y]);
}
}
path.clear();
@@ -251,13 +287,16 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
bbox = Some(union(bbox, unit));
draw(&mut marks, unit);
}
b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
_ => {}
}
}
bbox
while !marks.is_empty() {
close(&mut marks, &mut matching, &mut out);
}
out
}
/// The figures, in document order.
@@ -266,14 +305,28 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let index: HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
let figures: Vec<(Element, Option<usize>)> = elements(doc)
.into_iter()
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
(e, page)
})
.collect();
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
for (e, page) in &figures {
if let Some(p) = page {
wanted.entry(*p).or_default().extend(mcids(&e.dict));
}
}
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect();
figures
.into_iter()
.map(|(e, page)| {
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let ids: HashSet<i64> = mcids(&e.dict).into_iter().collect();
let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
let on_page = page.and_then(|p| bounds.get(&p));
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
Figure { obj: e.object, page, alt, bbox }
})
.collect()
+19 -14
View File
@@ -182,6 +182,7 @@ 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();
// 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();
// Where each id's last glyph ended, in user space.
@@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let mut unsaved = 0usize;
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let id = stack.iter().rev().find_map(|m| *m);
crate::count_mark_reads(1);
let id = stack.last().copied().flatten();
match op.op.as_slice() {
b"BMC" => stack.push(None),
b"BDC" => stack.push(match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
b"BMC" => stack.push(id),
b"BDC" => stack.push(
match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
}
_ => None,
}
_ => None,
}),
.or(id),
),
b"EMC" => {
stack.pop();
}
@@ -280,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
};
for (code, len) in m.codes(&s.bytes) {
let trm = tm.then(&gs.ctm);
// A glyph's text is only built while its id still takes more.
if let Some(id) = id
&& out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4)
&& let Some(glyph) = m.text_of(code)
{
let text = out.entry(id).or_default();
if text.len() < MAX_TITLE * 4 {
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
text.push(' ');
}
text.push_str(&glyph);
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
text.push(' ');
}
text.push_str(&glyph);
}
let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 };
tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm);
+16
View File
@@ -21,6 +21,22 @@ mod report;
mod structure;
mod tags;
#[cfg(test)]
thread_local! {
/// Marked-content stack entries read to decide which marks enclose an operator, for the tests
/// that keep that constant per operator.
static MARK_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Page content passes made to find figure bounds.
static BOUNDS_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` marked-content stack reads (tests only).
#[inline]
fn count_mark_reads(_n: usize) {
#[cfg(test)]
MARK_READS.with(|c| c.set(c.get().saturating_add(_n)));
}
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
+61
View File
@@ -1023,6 +1023,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
ops + " ET"
}
/// Marked-content stack reads while `f` runs.
fn mark_reads(f: impl FnOnce()) -> usize {
MARK_READS.with(|c| c.set(0));
f();
MARK_READS.with(|c| c.get())
}
/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside.
fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict {
use pdfcraft_cos::{Dict, Stream};
let mut data = "/Figure << /MCID 0 >> BDC ".to_string();
data.push_str(&"/X BMC ".repeat(depth));
data.push_str(&"0 0 1 1 re f ".repeat(ops));
data.push_str(&"EMC ".repeat(depth + 1));
let content = doc.add(Stream::flate(Dict::new(), data.as_bytes()));
[(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect()
}
#[test]
fn marked_text_reads_its_enclosing_id_in_constant_steps() {
let mut doc = Document::new_empty();
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
let reads = mark_reads(|| {
crate::headings::mcid_texts(&doc, &page);
});
// About 15 000 operators; a scan of the stack per operator reads millions of entries.
assert!(reads <= 4 * 15_000, "{reads} stack reads");
}
#[test]
fn figure_bounds_read_the_marked_content_state_in_constant_steps() {
let mut doc = Document::new_empty();
let page = deeply_nested_page(&mut doc, 5_000, 5_000);
// No mark encloses figure 7, so a scan of the stack per operator reads all of it.
let reads = mark_reads(|| {
crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7]));
});
assert!(reads <= 4 * 15_000, "{reads} stack reads");
}
#[test]
fn figure_bounds_take_one_pass_over_each_page() {
let n = 50;
let content: Vec<String> = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
stream(&content.join(" ")),
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
];
objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>")));
let doc = pdf(&objs, "");
BOUNDS_PASSES.with(|c| c.set(0));
let f = figures(&doc);
assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes");
assert_eq!(f.len(), n);
assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0])));
}
/// One tagged page whose H1 headings show `contents` (one marked-content sequence each).
fn heading_page(contents: &[String]) -> Document {
let marked: Vec<String> = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();
+3
View File
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end
# where the renderer ends them.
hayro-syntax = { workspace = true }
+7 -4
View File
@@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a
panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker
and the `Patch` API of architecture §6 grow here.
Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their
sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes
that look like the terminator. Filtered images and resource-defined colour spaces retain
the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too.
An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser
and the renderer read the same operators after the image, and redaction removes what the page
actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI`
passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in
or Indexed colour spaces, and image masks, by their sample layout (including per-row byte
padding), other images by the tolerant delimiter heuristic. Such an image is counted in
`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws.
+98 -22
View File
@@ -60,6 +60,10 @@ pub struct Parsed {
pub ops: Vec<Op>,
/// Bytes that could not be read as operands or operators.
pub skipped: usize,
/// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it
/// accepts). Their end here is a guess, so the operators after them may not be the ones the
/// page draws.
pub ambiguous_images: usize,
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
/// depends on the number of pieces, not of comments.
@@ -155,19 +159,27 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
start.get_or_insert(at);
operands.push(Object::Null);
}
b"BI" => match inline_image(data, lx.pos) {
Ok((dict, img, end)) => {
lx.pos = end;
out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
operands.clear();
b"BI" => {
let image = inline_image(data, at, lx.pos);
out.ambiguous_images += usize::from(image.ambiguous);
let end = image.end;
lx.pos = end;
match image.image {
Some((dict, img)) => {
out.ops.push(Op {
op: b"BI".to_vec(),
operands: Vec::new(),
inline: Some((dict, img)),
span: start.take().unwrap_or(at)..end,
});
}
None => {
out.skipped += end.saturating_sub(at);
start = None;
}
}
Err(end) => {
out.skipped += end.saturating_sub(at);
lx.pos = end;
start = None;
operands.clear();
}
},
operands.clear();
}
_ => {
let s = start.take().unwrap_or(at);
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
@@ -211,11 +223,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range<usize>, ends: &[usize], bo
}
}
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
/// An inline image as [`inline_image`] reads it.
struct Inline {
/// Its parameters and data; `None` when the parameters can't be read.
image: Option<(Dict, Vec<u8>)>,
/// The position after its `EI` (or the end of the stream).
end: usize,
/// The renderer's tokenizer reads no image here, so `end` is this parser's guess.
ambiguous: bool,
}
/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`).
///
/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a
/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the
/// page draws after the image, and redaction must see the same ones, so the end is taken from
/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only
/// when that tokenizer reads no image does this parser fall back to its own rules, and the
/// image is marked ambiguous.
fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline {
let mut lx = Lexer::new(data, pos);
let mut dict = Dict::new();
let mut readable = true;
let mut stop = pos;
loop {
lx.skip_ws();
if lx.eat_keyword(b"ID") {
@@ -229,22 +259,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
let Some((k, v)) = entry else {
// A stream that ends inside the parameters is cut short, not unreadable: keep what was read.
if at >= data.len() {
return Ok((dict, Vec::new(), data.len()));
return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false };
}
// Without an `EI` after it, the image runs to the end of the stream.
return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end));
readable = false;
stop = at;
break;
};
dict.set(k, v);
}
if let Some((start, end_data, end)) = renderer_image_end(data, bi) {
// Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte
// before that `EI` separates it from the data.
let mut samples = data.get(start..end_data).unwrap_or_default();
if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI"))
&& let Some((last, rest)) = samples.split_last()
&& is_ws(*last)
{
samples = rest;
}
return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false };
}
if !readable {
// Without an `EI` after it, the image runs to the end of the stream.
return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true };
}
// One whitespace byte separates ID from the data.
let begin = (lx.pos + 1).min(data.len());
let end = unfiltered_image_end(data, begin, &dict)
.or_else(|| declared_image_end(data, begin, &dict))
.or_else(|| image_data_end(data, begin, filter_eod(&dict)));
Ok(match end {
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
None => (dict, data[begin..].to_vec(), data.len()),
})
let (image, end) = match end {
Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end),
None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()),
};
Inline { image: Some((dict, image)), end, ambiguous: true }
}
/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and
/// end of its data and the position after its `EI`. `None` when it reads no image there (its
/// parameters don't read, or no `EI` passes its checks).
fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> {
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::{InlineImage, TypedInstruction};
let tail = data.get(bi..)?;
let mut iter = TypedIter::new(tail);
let (start, len) = match iter.next()? {
TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() {
// The data is a slice of `tail`: its offset is where it starts.
std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()),
std::borrow::Cow::Owned(_) => return None,
},
_ => return None,
};
let end_data = start.checked_add(len)?;
// `committed` is past the whitespace after `EI`; the image ends at the `EI`.
let mut end = iter.committed();
while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) {
end -= 1;
}
if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") {
return None;
}
Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?))
}
/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any.
+51 -7
View File
@@ -148,10 +148,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
assert_eq!(parsed.ops.len(), 3);
// Indexed samples permit at most 8 bits, even though device spaces permit 16.
// Indexed samples permit at most 8 bits, even though device spaces permit 16, so the
// length is unknown, and the renderer takes the second `EI`.
let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16");
let parsed = inline_pixels(invalid.as_bytes(), b" EI ");
assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty());
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
}
}
@@ -173,7 +174,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() {
}
#[test]
fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() {
for parameters in [
b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(),
b"/W 4 /H 1 /BPC 8 /CS /ResourceColor",
@@ -188,12 +189,20 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK",
b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G",
] {
// What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes
// the later one.
let parsed = inline_pixels(parameters, b"! EI !!~>");
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters));
let label = String::from_utf8_lossy(parameters);
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}");
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"BI", b"Q"], "{label}");
assert_eq!(parsed.ambiguous_images, 0, "{label}");
}
// A short payload still uses the tolerant recovery, including when there is no EI.
for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] {
assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc");
// A short payload is read whole; without an `EI`, the renderer reads no image and this
// parser's end is a guess.
for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] {
let parsed = parse(src);
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
assert_eq!(parsed.ambiguous_images, ambiguous);
}
}
@@ -505,3 +514,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() {
assert_eq!(img, b"!!!!>EI !!~>");
}
}
/// The operators the renderer's tokenizer reads from `data`.
fn renderer_operators(data: &[u8]) -> Vec<Vec<u8>> {
let mut iter = hayro_syntax::content::UntypedIter::new(data);
let mut out = Vec::new();
while let Some(instruction) = iter.next() {
out.push(instruction.operator.as_ref().to_vec());
}
out
}
/// Inline images end where the renderer ends them, so both read the same operators after one:
/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the
/// image, and an `EI` inside compressed bytes (#3579).
#[test]
fn inline_images_end_where_the_renderer_ends_them() {
let flate: Vec<u8> = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec();
stored.extend_from_slice(&flate);
stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
let fixtures: [&[u8]; 6] = [
b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET",
b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI",
b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q",
b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q",
&stored,
];
for data in fixtures {
let parsed = parse(data);
let ours: Vec<Vec<u8>> = parsed.ops.iter().map(|op| op.op.clone()).collect();
assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data));
assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
}
}
+9 -6
View File
@@ -17,7 +17,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
@@ -601,7 +601,7 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
/// (appended, after wrapping the original content in q/Q).
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
place_tagged(doc, page, kind.tag(), content, behind)
place_tagged(doc, page, kind.tag(), content, behind).map(drop)
}
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
@@ -609,7 +609,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
/// Returns the new content stream.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<ObjRef, EditError> {
let mut list = contents(doc, page)?;
// The behind-marks at the start: backgrounds in the order added, then watermarks.
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
@@ -625,7 +626,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
}
};
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
let mark = Object::Ref(mark_ref);
if behind {
// Right after the backgrounds already there, so a new background is painted over the
// older ones (#3591) and a watermark over all of them. Not after the watermarks: on a
@@ -646,7 +648,7 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
list.push(mark);
}
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
Ok(mark_ref)
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
@@ -664,7 +666,8 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
/// Returns the new content stream.
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
let all = page_list(doc);
check(&[page], all.len())?;
add_resources(doc, &all[page], None, Some(&content))?;
+11 -6
View File
@@ -985,16 +985,17 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<String> {
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`,
/// /Widths, whose advances the interpreter approximates; a CMap
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its
/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
/// the document as it was.
fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
overlays: &[pdfcraft_cos::ObjRef],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
@@ -1011,8 +1012,7 @@ fn shown_under_redactions(
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| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect();
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()))?;
@@ -2356,6 +2356,9 @@ struct EditCtx {
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
renamed_fields: Vec<(String, String)>,
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
/// leaves out.
redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
}
impl EditCtx {
@@ -2382,6 +2385,7 @@ impl EditCtx {
seed,
count: 0,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
}
}
@@ -2736,7 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
}
Edit::ApplyRedactions { pages } => {
pdfcraft_redact::apply(doc, pages.as_deref())?;
cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays;
}
Edit::ClearRedactions => {
pdfcraft_redact::clear_marks(doc, None)?;
@@ -3798,6 +3802,7 @@ impl Session {
cx.js = Some(js::JsRunner::new(&next, &name));
}
cx.renamed_fields.clear();
cx.redaction_overlays.clear();
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
@@ -3806,7 +3811,7 @@ impl Session {
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, &doc.info.pages, doc.password.as_deref()))
guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, 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
+67 -17
View File
@@ -2253,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() {
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
one_line_page_with(resources, "", body)
}
/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary.
fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
@@ -2289,31 +2294,76 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
s.apply(id, mark).unwrap();
}
/// Redaction removes text by the positions its own interpreter works out. Here the font and size
/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does
/// not model, so it places no glyph under the mark the user drew over the word as displayed: it
/// used to report success and leave the word in the file. The renderer is asked now, and the edit
/// is refused.
/// Redaction removes text by the positions its own interpreter works out, and the renderer has
/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark
/// fails the edit. The check is run here on a page nothing was removed from, as if the
/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left
/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too.
#[test]
fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() {
fn the_renderer_check_refuses_text_still_shown_under_a_mark() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap();
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 refused = |cos: &pdfcraft_cos::Document| {
let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None);
matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0)
};
assert!(refused(&cos));
// The page's contents as an array, as they are once an apply has added its overlay.
let page = pdfcraft_model::pages(&cos).swap_remove(0);
let contents = page.dict.get(b"Contents").cloned().unwrap();
cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap();
assert!(refused(&cos), "the labelled input stream was not checked");
}
/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it
/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so
/// a mark drawn over the word as shown takes it out of the file.
#[test]
fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page(
"/Font << /F1 4 0 R >>",
"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
);
let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
assert!(!page_texts(&s, id).concat().contains("SECRET"));
let saved = s.save_bytes(id).unwrap();
let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET");
assert!(!held(&saved), "the saved file holds the redacted word");
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
assert!(
!reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
"a saved stream holds the redacted word"
);
}
/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer
/// draws it, so a mark drawn over it as displayed takes it out of the file.
#[test]
fn a_word_in_a_font_set_by_an_extgstate_is_redacted() {
let line = "Public words then SECRET42 and more";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let refused = s.apply(id, Edit::ApplyRedactions { pages: None });
assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
// Nothing was changed: the mark is still pending and the page still shows the line.
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
assert_eq!(page_texts(&s, id), [line]);
// The same line with its font set by `Tf`: the mark is applied and the word is gone.
let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET"));
let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let shown = page_texts(&s, id).swap_remove(0);
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
let saved = s.save_bytes(id).unwrap();
let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42");
assert!(!held(&saved), "the saved file holds the redacted word");
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
assert!(
!reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
"a saved stream holds the redacted word"
);
}
#[test]
+70 -5
View File
@@ -8,6 +8,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
mod pptx;
mod zip;
@@ -85,14 +87,33 @@ enum Item<'a> {
PageBreak,
}
#[cfg(test)]
thread_local! {
/// Entries read to find a font size's or a colour's slot, for the tests that keep it constant.
static LOOKUP_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts `n` entries read by a lookup (tests only).
#[inline]
fn count_lookup_steps(_n: usize) {
#[cfg(test)]
LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n)));
}
/// The body text size: the size most of the document's characters are set in.
fn body_size(pages: &[Page]) -> f64 {
// Sizes in first-seen order (which breaks ties), and where each one is in it.
let mut by: Vec<(i64, usize)> = Vec::new();
let mut slot: HashMap<i64, usize> = HashMap::new();
for b in pages.iter().flat_map(|p| &p.blocks) {
let k = (b.size * 2.0).round() as i64;
match by.iter_mut().find(|e| e.0 == k) {
Some(e) => e.1 += b.text.len(),
None => by.push((k, b.text.len())),
count_lookup_steps(1);
let i = *slot.entry(k).or_insert_with(|| {
by.push((k, 0));
by.len() - 1
});
if let Some(e) = by.get_mut(i) {
e.1 = e.1.saturating_add(b.text.len());
}
}
by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0)
@@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String {
let items = items(pages);
// The colour table: entry 0 is the default (auto) colour, then each non-black colour once.
let mut colors: Vec<[u8; 3]> = Vec::new();
let mut slot: HashMap<[u8; 3], usize> = HashMap::new();
let mut note = |c: [f64; 3]| {
let c = rgb8(c);
if c != [0, 0, 0] && !colors.contains(&c) {
count_lookup_steps(1);
if c != [0, 0, 0] && !slot.contains_key(&c) {
slot.insert(c, colors.len());
colors.push(c);
}
};
@@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String {
// `\cfN` for a colour (nothing for black, which keeps the default).
let cf = |c: [f64; 3]| {
let c = rgb8(c);
colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
count_lookup_steps(1);
slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
};
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
if !colors.is_empty() {
@@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String {
mod tests {
use super::*;
/// A page of `n` one-word blocks, each in its own size and colour.
fn varied_page(n: usize) -> Page {
let blocks = (0..n)
.map(|i| Block {
text: "word".into(),
rect: [72.0, i as f64, 100.0, i as f64 + 1.0],
size: 4.0 + i as f64 * 0.5,
bold: false,
italic: false,
color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
rotation: 0.0,
})
.collect();
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
}
fn lookup_steps(f: impl FnOnce()) -> usize {
LOOKUP_STEPS.with(|c| c.set(0));
f();
LOOKUP_STEPS.with(|c| c.get())
}
#[test]
fn many_distinct_sizes_are_counted_in_constant_steps_each() {
let pages = [varied_page(3000)];
let steps = lookup_steps(|| {
body_size(&pages);
});
assert!(steps <= 2 * 3000, "{steps} steps");
}
#[test]
fn many_distinct_colours_take_constant_steps_each() {
let pages = [varied_page(3000)];
let mut out = String::new();
let steps = lookup_steps(|| out = rtf(&pages));
assert!(steps <= 4 * 3000, "{steps} steps");
assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot");
}
fn page() -> Page {
let b = |t: &str, y: f64, size: f64, bold: bool| Block {
text: t.into(),
+35 -4
View File
@@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
}
/// [`wrap`], measuring with `measure` (for example [`cjk_width`]).
///
/// A line measures as the sum of its words, the spaces between them and the characters of a broken
/// word, each measured once, so the work stays linear in `text` however much fits on a line.
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec<String> {
let mut lines = Vec::new();
let space = measure(" ", size);
let mut buf = [0u8; 4];
// A CRLF pair is one break, a lone CR or LF is one break each.
for para in text.replace("\r\n", "\n").split(['\n', '\r']) {
let mut line = String::new();
let mut line_width = 0.0;
// No word is on the line yet; an empty word (a leading space) still starts it.
let mut fresh = true;
for word in para.split(' ') {
let candidate = if fresh { word.to_string() } else { format!("{line} {word}") };
if measure(&candidate, size) <= width || fresh && measure(word, size) <= width {
line = candidate;
let word_width = measure(word, size);
let candidate = if fresh { word_width } else { line_width + space + word_width };
if candidate <= width {
if !fresh {
line.push(' ');
}
line.push_str(word);
line_width = candidate;
fresh = false;
continue;
}
@@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
} else {
lines.push(std::mem::take(&mut line));
}
line_width = 0.0;
for ch in word.chars() {
if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
let ch_width = measure(ch.encode_utf8(&mut buf), size);
if !line.is_empty() && line_width + ch_width > width {
lines.push(std::mem::take(&mut line));
line_width = 0.0;
}
line.push(ch);
line_width += ch_width;
}
fresh = line.is_empty();
}
@@ -442,4 +457,20 @@ mod tests {
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
}
/// Wrapping measures each word and character a bounded number of times, however many fit on a line.
#[test]
fn wrapping_measures_each_word_a_few_times() {
let measured = std::cell::Cell::new(0usize);
let measure = |s: &str, size: f64| {
measured.set(measured.get() + s.len());
helvetica_width(s, size)
};
let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000));
let lines = wrap_with(&text, 1.0, 13_000.0, measure);
assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another");
assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get());
let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width);
assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}");
}
}
+162 -19
View File
@@ -33,9 +33,9 @@ pub struct Metrics {
widths: Vec<f64>,
/// Composite fonts: CID → width, and CID ranges with one width.
cid_widths: HashMap<u32, f64>,
cid_ranges: Vec<(u32, u32, f64)>,
cid_ranges: Spans<f64>,
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
cid_map: Vec<(u32, u32, u32)>,
cid_map: Spans<u32>,
default: f64,
/// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that
/// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph.
@@ -598,8 +598,8 @@ impl Metrics {
first: 0,
widths: Vec::new(),
cid_widths: HashMap::new(),
cid_ranges: Vec::new(),
cid_map: Vec::new(),
cid_ranges: Spans::default(),
cid_map: Spans::default(),
default: 500.0,
explicit_default: false,
std14: Some(crate::Std14::Helvetica),
@@ -685,7 +685,7 @@ impl Metrics {
if !spaces.is_empty() {
m.codes = Codes::Ranges(spaces);
}
m.cid_map = cids;
m.cid_map = Spans::new(cids);
}
let desc = font
.get(b"DescendantFonts")
@@ -698,6 +698,7 @@ impl Metrics {
m.explicit_default = true;
}
if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) {
let mut ranges = Vec::new();
let mut i = 0;
while i < w.len() {
let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break };
@@ -713,14 +714,17 @@ impl Metrics {
i += 2;
}
Some(c1) => {
if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) {
m.cid_ranges.push((c0, c1 as u32, v));
if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64()))
&& ranges.len() < MAX_CMAP_ENTRIES
{
ranges.push((c0, c1 as u32, v));
}
i += 3;
}
None => break,
}
}
m.cid_ranges = Spans::new(ranges);
}
descriptor = dict(doc, desc.get(b"FontDescriptor"));
} else {
@@ -791,14 +795,14 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo)))
}
/// The advance of `code` in text space per unit of font size.
pub fn width(&self, code: u32) -> f64 {
if self.composite {
let cid = self.cid(code);
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2));
let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2));
return w.unwrap_or(self.default) * self.scale;
}
if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
@@ -858,7 +862,7 @@ impl Metrics {
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
|| self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
|| self.cid_ranges.find(cid).is_some()
|| !self.subset
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1)
// No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW
@@ -905,9 +909,13 @@ impl Metrics {
}
}
/// The UTF-16 code units of CMap hex bytes.
/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up
/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one.
const MAX_DESTINATION_UNITS: usize = 256;
/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them.
fn code_units(b: &[u8]) -> Vec<u16> {
b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
}
/// UTF-16BE (with surrogates) from CMap hex bytes.
@@ -954,16 +962,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi:
true
}
#[cfg(test)]
thread_local! {
/// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic.
static SPAN_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Counts one span record read (tests only).
#[inline]
fn count_span_reads() {
#[cfg(test)]
SPAN_READS.with(|c| c.set(c.get().saturating_add(1)));
}
/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code
/// wins, indexed so that a lookup is one binary search.
#[derive(Clone, Debug, Default, PartialEq)]
struct Spans<T> {
spans: Vec<(u32, u32, T)>,
/// The span covering each stretch of codes; see [`stretches`].
cover: Vec<(u64, Option<usize>)>,
}
impl<T> Spans<T> {
fn new(spans: Vec<(u32, u32, T)>) -> Self {
let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect();
Spans { cover: stretches(&bounds, false), spans }
}
fn is_empty(&self) -> bool {
self.spans.is_empty()
}
/// The first-declared span covering `code`.
fn find(&self, code: u32) -> Option<&(u32, u32, T)> {
let step = self.cover.partition_point(|&(start, _)| {
count_span_reads();
start <= u64::from(code)
});
let &(_, record) = self.cover.get(step.checked_sub(1)?)?;
self.spans.get(record?)
}
}
/// For each stretch of codes, from its first code on, the latest-declared range covering it (`None`
/// where no range does), sorted by code; a lookup is one binary search over it.
fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect();
stretches(&spans, true)
}
/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where
/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared.
/// At most `u32::MAX` spans are indexed.
fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option<usize>)> {
// Heap keys, largest for the winning record; the mapping is its own inverse.
let key = |record: u32| if later_wins { record } else { !record };
// Openings (first code, record) and closings (one past the last code), each in code order. The cover
// can only change at one of their codes, so the sweep visits those codes in order.
let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect();
let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect();
opens.sort_unstable();
let mut closes: Vec<u64> = ranges.iter().map(|r| u64::from(r.hi) + 1).collect();
let mut closes: Vec<u64> = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect();
closes.sort_unstable();
// Records that have opened and not yet closed; the top is the latest declared one.
// Keys of the records that have opened and not yet closed; the top is the winning one.
let mut open: BinaryHeap<u32> = BinaryHeap::new();
let mut cover: Vec<(u64, Option<usize>)> = Vec::new();
let (mut next_open, mut next_close) = (0, 0);
@@ -977,16 +1038,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option<usize>)> {
while let Some(&(lo, record)) = opens.get(next_open)
&& u64::from(lo) <= p
{
open.push(record);
open.push(key(record));
next_open += 1;
}
while closes.get(next_close).is_some_and(|&end| end <= p) {
next_close += 1;
}
while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) {
while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) {
open.pop();
}
let winner = open.peek().map(|&top| top as usize);
let winner = open.peek().map(|&top| key(top) as usize);
if cover.last().map(|&(_, w)| w) != Some(winner) {
cover.push((p, winner));
}
@@ -1262,6 +1323,88 @@ mod tests {
use super::*;
/// Span records read while `f` runs.
fn span_reads(f: impl FnOnce()) -> usize {
SPAN_READS.with(|c| c.set(0));
f();
SPAN_READS.with(|c| c.get())
}
/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`.
fn composite(doc: &mut Document, encoding: &str, w: Vec<Object>, to_unicode: &str) -> Dict {
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
desc.set(b"W".to_vec(), Object::Array(w));
let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes()))));
let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes()))));
font(
doc,
vec![
("Subtype", Object::name("Type0")),
("Encoding", enc),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", tu),
],
)
}
/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all.
#[test]
fn widths_of_a_code_no_range_covers_take_few_steps() {
let mut doc = Document::new_empty();
let w: Vec<Object> = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect();
let f = composite(&mut doc, "", w, "");
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.width(4), 0.5);
let reads = span_reads(|| {
for _ in 0..1000 {
assert_eq!(m.width(3), 1.0);
}
});
assert!(reads <= 1000 * 64, "{reads} span reads");
}
/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it.
#[test]
fn overlapping_spans_resolve_to_the_first_declared() {
let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]);
let found = |code| spans.find(code).map(|s| s.2);
let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))];
for (code, value) in want {
assert_eq!(found(code), value, "code {code}");
}
}
/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map.
#[test]
fn many_unicode_entries_look_up_their_cids_in_few_steps() {
let n = 2000;
let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect();
let encoding = format!("1 begincodespacerange <0000> <FFFF> endcodespacerange {n} begincidchar {cidchars} endcidchar");
let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect();
let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar");
let mut doc = Document::new_empty();
let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode);
let reads = span_reads(|| {
Metrics::from_dict(&doc, &f);
});
assert!(reads <= n * 64, "{reads} span reads");
}
/// Every glyph of a sequential range builds its text from the destination, so a long one is cut
/// to the CMap limit once, when it is read.
#[test]
fn unicode_destinations_are_cut_to_the_cmap_limit() {
let long = "0041".repeat(4096);
let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar");
let mut u = Unicode::default();
parse_to_unicode(cmap.as_bytes(), &mut u);
for code in [0x10, 0x20, 0x30, 0x40] {
let text = u.text(code).unwrap_or_default();
assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len());
}
}
/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one.
#[test]
fn low_code_tables_end_with_the_last_covered_code() {
@@ -1718,7 +1861,7 @@ mod tests {
let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <zz> <0043>] endbfrange");
assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C")));
let mut m = Metrics::fallback();
(_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange");
m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange").1);
assert_eq!(m.cid(5), u32::MAX);
let mut doc = Document::new_empty();
let mut enc = Dict::new();
+25
View File
@@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
}
}
}
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
// widgets reachable for a full save.
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
}
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
/// by reference.
fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) };
let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default();
let kept: Vec<Object> = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect();
if kept.len() == co.len() {
return Ok(());
}
match (co_obj.as_ref(), af_obj.as_ref()) {
(Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)),
(_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?,
(_, None) => {
af.set(b"CO".to_vec(), Object::Array(kept));
doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
}
}
Ok(())
}
+55
View File
@@ -29,6 +29,19 @@ 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;
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) };
}
/// One fragment: its image, its box in the coordinates the run is drawn in, and its size.
struct Fragment {
image: ObjRef,
@@ -40,6 +53,8 @@ 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>)> {
#[cfg(test)]
GROUP_READS.with(|c| c.set(c.get().saturating_add(1)));
if !ops.get(i)?.is("q") {
return None;
}
@@ -226,7 +241,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
let ops = parsed.ops;
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;
while i < ops.len() {
if scanned > budget {
out.extend(ops.get(i..).unwrap_or_default().iter().cloned());
break;
}
// The longest run of fragment groups from here with one colour space.
let mut run = Vec::new();
let mut kind: Option<(Object, usize)> = None;
@@ -245,10 +266,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
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: the rows drawn one under the other from the start may be.
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
run = drawn_in_sequence(&ops, i, &xobjects, doc);
j = run_end(&ops, i, run.len());
scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2);
}
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
// Not a run: keep this op and look again from the next one.
@@ -304,6 +327,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
#[test]
@@ -318,4 +343,34 @@ mod tests {
];
assert!(merge(&Document::new_empty(), &run, 3).is_none());
}
/// Many images drawn on top of each other are not one picture; looking for runs among them reads
/// a bounded number of groups per operator, not the rest of the page from every group.
#[test]
fn rejected_runs_read_a_bounded_number_of_groups() {
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 = 4000;
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".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));
GROUP_READS.with(|c| c.set(0));
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true);
assert_eq!(merged.ok(), Some(0));
let reads = GROUP_READS.with(|c| c.get());
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
}
}
+39 -1
View File
@@ -227,6 +227,19 @@ fn is_dct(s: &Stream) -> Option<bool> {
}
}
#[cfg(test)]
thread_local! {
/// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding.
static DECODED_PIXELS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Counts `n` decoded pixels (tests only).
#[inline]
fn count_decoded(_n: u64) {
#[cfg(test)]
DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n)));
}
/// 8-bit samples of an image (`n` components), decoded.
pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?);
@@ -235,7 +248,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
}
let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?;
if is_dct(s)? {
let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?;
use image::ImageDecoder as _;
// The JPEG's own size is what gets decoded: one other than the dictionary's is refused from
// its header, before any pixels are.
let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?;
if decoder.dimensions() != (w, h) {
return None;
}
let img = image::DynamicImage::from_decoder(decoder).ok()?;
count_decoded(u64::from(img.width()) * u64::from(img.height()));
// Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them).
let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() };
(v.len() == bytes).then_some(v)
@@ -432,4 +453,21 @@ mod dimension_tests {
let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]);
assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4]));
}
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
#[test]
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default();
let stream = |w: i64, h: i64| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(w));
dict.set(b"Height".to_vec(), Object::Int(h));
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Stream::from_raw(dict, data.clone())
};
DECODED_PIXELS.with(|c| c.set(0));
assert!(pixels(&stream(10, 10), 1).is_none());
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
}
+16 -6
View File
@@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app
- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't
re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an
area are removed. Images with separate `/SMask` or `/Mask` data are removed whole;
area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes
to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole;
cleared copies drop `/Alternates` so alternate pixels cannot survive;
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
@@ -31,8 +32,13 @@ clear_marks(&mut doc, None)?; // remove marks without app
page as a `/PCMark /Redaction` stream.
A verification pass re-reads each redacted page and fails the operation if any glyph or inline
image is still under an area. Content that can't be decoded makes the operation fail rather than
leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable
image is still under an area. It checks every content stream the page had, whatever its
dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped.
Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a
form XObject starts from the text state of the content that draws it. Content that can't be
decoded makes the operation fail rather than leave it unredacted, and so does an inline image the
renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators
resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable
`/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of
skipping that mark. The count covers the marks on the pages being applied.
@@ -50,15 +56,19 @@ as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed c
A tree where that can't be done (an element written in place, or one too large to walk) makes
`apply` fail with `RedactError::Tags` rather than leave the annotation reachable.
Replacement descriptions are removed only from marked-content spans changed by cleanup.
Unchanged spans retain their descriptions; unused property entries are retired during redaction
Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans
changed by cleanup, from every structure element above them (found through both `/P` and `/K`),
and from elements that lose an annotation reference, which also lose `/T`. An element written in
place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged
spans retain their descriptions; unused property entries are retired during redaction
and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still
used by surviving pages or forms are kept.
A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference;
a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's
`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed
XObject reachable. Pages that aren't redacted are left untouched.
XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own
content doesn't draw, for the same reason. Pages that aren't redacted are left untouched.
## Remove hidden information / Sanitize
+5 -1
View File
@@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
let bytes = row.checked_mul(h)?;
let row_bits = row.checked_mul(8)?;
bytes.checked_mul(8)?;
let mut data = s.decoded().ok()?;
// Decode no further than the raster can need: a filter stage may legitimately produce up to
// twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that
// can only be a decompression bomb; the image is then removed whole.
let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?;
let mut data = s.decoded_within(allowance).ok()?;
if data.len() < bytes {
return None;
}
+81 -10
View File
@@ -74,6 +74,10 @@ pub(crate) struct Output {
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
/// in force: the operation must fail closed rather than leave that text under the mark.
pub vertical: bool,
/// The content holds an inline image the renderer reads no image for
/// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not
/// be what the page shows, so a redaction must not trust it.
pub ambiguous: bool,
pub tag_changes: Vec<(ObjRef, ObjRef, HashSet<i64>, HashSet<i64>)>,
pub error: Option<CosError>,
}
@@ -529,8 +533,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope
}
}
// A surviving form can own the same old resource dictionary as a rewritten page. Its
// unused descriptions remain reachable too, even after every page holder is cleaned.
unlink_form_properties(doc, &holder_nodes, retired_properties)?;
// unused descriptions and doomed XObjects remain reachable too, even after every page
// holder is cleaned.
unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?;
Ok(())
}
@@ -567,8 +572,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec<u8>], c
Ok(pruned)
}
fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> {
if retired.is_empty() {
/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from
/// the page-tree `holders`, each unless the form's own content (or a form inheriting its
/// resources) still uses the name. Fails closed when that cannot be determined.
fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet<ObjRef>, retired: &[Object]) -> Result<(), CosError> {
if retired.is_empty() && doomed.is_empty() {
return Ok(());
}
let mut pending: Vec<Dict> = holders
@@ -593,7 +601,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj
let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
let names: Vec<Vec<u8>> =
res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect();
if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? {
let mut xo = res_dict(doc, &res, b"XObject");
let doomed_names: Vec<Vec<u8>> =
xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
if names.is_empty() && doomed_names.is_empty() {
pending.push(res);
continue;
}
let data = form.decoded()?;
let mut pruned = false;
for name in doomed_names {
match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) {
Some(true) => {}
Some(false) => pruned |= xo.remove(&name).is_some(),
None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }),
}
}
if pruned {
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?;
if pruned {
doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?;
}
pending.push(res);
@@ -690,6 +718,34 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) ->
Ok(())
}
/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf`
/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the
/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte).
fn extgstate_font(
doc: &Document,
scope: &mut Scope<'_>,
ext_gstates: &Dict,
name: &[u8],
fallback: &Rc<Metrics>,
) -> Option<(Rc<Metrics>, bool, f64)> {
let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?;
let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?;
let [font, size] = entry.as_slice() else { return None };
let size = doc.resolve(size).as_f64()?;
let font_dict = doc.resolve(font).as_dict().cloned();
let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d));
let key = [b"\0".as_slice(), name].concat();
let metrics = match scope.fonts.get(&key) {
Some(m) => m.clone(),
None => {
let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone());
scope.fonts.insert(key, m.clone());
m
}
};
Some((metrics, vertical, size))
}
/// One glyph of a shown string.
struct Glyph {
bytes: std::ops::Range<usize>,
@@ -700,11 +756,9 @@ struct Glyph {
/// Process a scope's content streams (state flows from one stream to the next).
pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>], resources: &Dict, ctm: Matrix) -> Output {
let mut out = Output::default();
let fallback = Rc::new(Metrics::fallback());
let mut gs = Gs {
let gs = Gs {
ctm,
font: fallback.clone(),
font: Rc::new(Metrics::fallback()),
vertical: false,
size: 0.0,
char_spacing: 0.0,
@@ -714,9 +768,19 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
leading: 0.0,
rise: 0.0,
};
process_from(doc, scope, streams, resources, gs)
}
/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state
/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed
/// with the caller's font, size and spacing, as the renderer places it.
fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>], resources: &Dict, mut gs: Gs) -> Output {
let mut out = Output::default();
let fallback = Rc::new(Metrics::fallback());
let mut stack: Vec<Gs> = Vec::new();
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let fonts_res = res_dict(doc, resources, b"Font");
let ext_gstates = res_dict(doc, resources, b"ExtGState");
let mut xobjects = res_dict(doc, resources, b"XObject");
// The current path: its operators, its bounding box in user space, and whether it clips.
let mut path: Vec<Op> = Vec::new();
@@ -729,6 +793,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// next. Parse them joined, then give each operator back to the piece its keyword is in.
let joined = Pieces::join(streams);
let mut parsed = joined.parse();
out.ambiguous = parsed.ambiguous_images > 0;
// A rewritten piece loses its comments, so keep where they began and ended: a comment that
// ran on into the next piece still hides that piece's first line.
let comment_edges = joined.comment_edges(&parsed);
@@ -865,6 +930,11 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
};
}
}
b"gs" => {
if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) {
(gs.font, gs.vertical, gs.size) = (font, vertical, size);
}
}
b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0),
b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0),
b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0,
@@ -1223,11 +1293,12 @@ fn xobject(
let res = own.clone().unwrap_or_else(|| resources.clone());
let saved_fonts = std::mem::take(&mut scope.fonts);
scope.depth += 1;
let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm);
let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() });
scope.depth -= 1;
scope.fonts = saved_fonts;
out.residue += inner.residue;
out.vertical |= inner.vertical;
out.ambiguous |= inner.ambiguous;
out.doomed.extend_from_slice(&inner.doomed);
out.retired_properties.extend(inner.retired_properties);
out.tag_changes.extend(inner.tag_changes);
+14 -8
View File
@@ -143,6 +143,10 @@ pub struct Report {
/// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field
/// values, and from which a relayout could rebuild them) were removed.
pub xfa_removed: bool,
/// The content streams this apply added to draw the marks' boxes and labels. They are the
/// only streams a check for text left under the marks may skip: an input stream labelled
/// like an overlay (`/PCMark /Redaction`) is checked like any other.
pub overlays: Vec<ObjRef>,
}
fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
@@ -407,6 +411,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
if out.vertical {
return Err(RedactError::VerticalText(pi + 1));
}
// An inline image the renderer reads no image for (no `EI` it accepts, or parameters it
// can't read): where the page's operators resume after it is a guess, so neither removal
// nor the verify pass can be trusted on this page.
if out.ambiguous {
return Err(RedactError::Unreadable(pi + 1));
}
doomed_xobjects.extend_from_slice(&out.doomed);
retired_properties.extend(out.retired_properties);
let mut new_list = list.clone();
@@ -576,7 +586,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// 4. The boxes.
for (pi, content) in overlays {
if !content.is_empty() {
pdfcraft_edit::stamp(doc, pi, "Redaction", content)?;
report.overlays.push(pdfcraft_edit::stamp(doc, pi, "Redaction", content)?);
}
}
@@ -590,13 +600,9 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
let page = pdfcraft_model::pages(doc).swap_remove(pi);
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
let (list, data) = page_streams(doc, &page.dict, pi)?;
// The overlay stream (last) draws text of its own: the overlay label is allowed.
let original: Vec<Vec<u8>> = list
.iter()
.zip(data)
.filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction"))
.map(|(_, d)| d)
.collect();
// The overlay stream draws text of its own: the overlay label is allowed.
let original: Vec<Vec<u8>> =
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut scratch = Report::default();
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch);
+182 -41
View File
@@ -6,15 +6,49 @@
//! references to marked content that is now empty are dropped from the element.
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::RedactError;
/// Each MCID's operators (serialized), and whether anything is still drawn inside it.
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u8>, bool)> {
let mut out: HashMap<i64, (Vec<u8>, bool)> = HashMap::new();
let mut stack: Vec<Option<i64>> = Vec::new();
/// The marked content of one content stream. Every operator inside some MCID is serialized once
/// into `ops`, and each MCID holds the ranges of `ops` its sequences span (marked-content
/// operators are not written, so a sequence is one range however deeply it nests), with whether
/// anything is drawn inside it. Copying the operators into every enclosing MCID instead would
/// hold `depth` copies of nested content.
#[derive(Default)]
struct Marked {
ops: Vec<u8>,
ids: HashMap<i64, (Vec<Range<usize>>, bool)>,
}
impl Marked {
/// The bytes of `ranges`, which are ranges of `ops`.
fn bytes<'a>(&'a self, ranges: &'a [Range<usize>]) -> impl Iterator<Item = &'a u8> + 'a {
ranges.iter().flat_map(|r| self.ops.get(r.clone()).unwrap_or_default())
}
}
/// Most bytes [`touched`] compares for one stream. Nested sequences share their operators, so the
/// comparisons can add up to the content's size times its nesting depth; past this, the MCIDs
/// left are taken as changed (their descriptions go) rather than compared.
const MAX_COMPARED: usize = 1 << 28;
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked {
let mut out = Marked::default();
// Open sequences: the MCID with where its content starts in `ops` and the drawing operators
// written before it, or `None` for a sequence without one.
let mut stack: Vec<Option<(i64, usize, usize)>> = Vec::new();
let (mut open_ids, mut drawn) = (0usize, 0usize);
let close = |out: &mut Marked, open: Option<(i64, usize, usize)>, drawn: usize| {
if let Some((id, start, drawn_before)) = open {
let end = out.ops.len();
let e = out.ids.entry(id).or_default();
e.0.push(start..end);
e.1 |= drawn > drawn_before;
}
};
for op in pdfcraft_content::parse(data).ops {
match op.op.as_slice() {
b"BDC" => {
@@ -28,9 +62,10 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u
_ => None,
};
if let Some(id) = id {
out.entry(id).or_default();
out.ids.entry(id).or_default();
open_ids += 1;
}
stack.push(id);
stack.push(id.map(|id| (id, out.ops.len(), drawn)));
continue;
}
b"BMC" => {
@@ -38,11 +73,17 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u
continue;
}
b"EMC" => {
stack.pop();
if let Some(open) = stack.pop() {
open_ids = open_ids.saturating_sub(usize::from(open.is_some()));
close(&mut out, open, drawn);
}
continue;
}
_ => {}
}
if open_ids == 0 {
continue;
}
let draws = match op.op.as_slice() {
b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()),
b"TJ" => {
@@ -51,13 +92,12 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"Do" | b"BI" => true,
_ => false,
};
let mut bytes = Vec::new();
pdfcraft_content::write_op(&op, &mut bytes);
for id in stack.iter().flatten() {
let e = out.entry(*id).or_default();
e.0.extend_from_slice(&bytes);
e.1 |= draws;
}
pdfcraft_content::write_op(&op, &mut out.ops);
drawn += usize::from(draws);
}
// Sequences the stream leaves open run to its end.
while let Some(open) = stack.pop() {
close(&mut out, open, drawn);
}
out
}
@@ -67,10 +107,17 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[
let (b, a) = (by_mcid(doc, resources, before), by_mcid(doc, resources, after));
let mut changed = HashSet::new();
let mut empty = HashSet::new();
for (id, (ops, _)) in &b {
match a.get(id) {
let mut budget = MAX_COMPARED;
let len = |ranges: &[Range<usize>]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add);
for (id, (old, _)) in &b.ids {
match a.ids.get(id) {
Some((new, draws)) => {
if new != ops {
let n = len(old);
let same = n == len(new) && n <= budget && {
budget -= n;
b.bytes(old).eq(a.bytes(new))
};
if !same {
changed.insert(*id);
}
if !draws {
@@ -136,20 +183,21 @@ pub(crate) fn clean_within(
// also from the parent tree and the ID tree: one listed only there still owns marked content
// (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits
// from its ancestors before a tree reaches it without one.
let mut todo: Vec<(Object, Option<ObjRef>)> =
[&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect();
let mut todo: Vec<(Object, Option<ObjRef>, Above)> =
[&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None, Above::default())).collect();
let mut seen = HashSet::new();
let mut pages = HashMap::new();
let mut parents = Parents::new();
let mut hits: Vec<(ObjRef, Dict, Option<ObjRef>, bool)> = Vec::new();
// Whether marked content `x` (a kid on page `pg`) is one this cleanup must change.
let touches = |doc: &Document, x: &Object, pg: Option<ObjRef>| {
mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m))
};
while let Some((k, pg)) = todo.pop() {
while let Some((k, pg, above)) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))),
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg, above))),
// A number or name tree node written in place.
Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))),
Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))),
// An element written in place: its kids are followed, but if it owns marked content
// this cleanup changes, only rewriting what holds it could change it.
Object::Dict(d) => {
@@ -161,9 +209,11 @@ pub(crate) fn clean_within(
if kids.iter().any(|x| touches(doc, x, pg)) {
return Err(RedactError::Tags);
}
todo.extend(kids.into_iter().map(|x| (x, pg)));
let above = above.through(&d);
todo.extend(kids.into_iter().map(|x| (x, pg, above)));
}
Object::Ref(r) => {
parents.record(r, above);
if !seen.insert(r) {
continue;
}
@@ -172,13 +222,13 @@ pub(crate) fn clean_within(
}
let object = doc.get(r);
if let Object::Array(a) = &*object {
todo.extend(a.iter().cloned().map(|x| (x, pg)));
todo.extend(a.iter().cloned().map(|x| (x, pg, above)));
continue;
}
let Some(d) = object.as_dict().cloned() else { continue };
if !d.contains(b"S") {
// A parent tree or ID tree node: its values may be elements.
todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg)));
todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above)));
continue;
}
let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
@@ -191,11 +241,12 @@ pub(crate) fn clean_within(
if sensitive || remap {
hits.push((r, d.clone(), pg, sensitive));
}
todo.extend(kids.into_iter().map(|x| (x, pg)));
todo.extend(kids.into_iter().map(|x| (x, pg, Above::element(r))));
}
_ => {}
}
}
let sensitive: Vec<ObjRef> = hits.iter().filter(|h| h.3).map(|h| h.0).collect();
let mut n = 0;
for (r, d, pg, sensitive) in hits {
let kids = d.get(b"K").map(|k| doc.resolve(k));
@@ -244,9 +295,88 @@ pub(crate) fn clean_within(
})?;
n += usize::from(sensitive && doc.get(r).as_dict() != Some(&d));
}
// An element above may describe the changed content too (`/ActualText` on a span's parent
// covers the span, §14.9.4).
n += clear_ancestors(doc, &sensitive, &parents, limit)?;
Ok(n)
}
/// Where the walk reached an object from: the nearest element above it that is an object of its
/// own, and whether an element written in place between them carries a description (which only
/// rewriting the object holding it could remove).
#[derive(Clone, Copy, Default)]
struct Above {
element: Option<ObjRef>,
described_in_place: bool,
}
impl Above {
fn element(r: ObjRef) -> Self {
Self { element: Some(r), described_in_place: false }
}
/// Below an element written in place.
fn through(self, d: &Dict) -> Self {
Self { described_in_place: self.described_in_place || DESCRIPTIONS.iter().any(|k| d.contains(k)), ..self }
}
}
/// The keys of an element that describe its content.
const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"];
/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with.
struct Parents(HashMap<ObjRef, Vec<Above>>);
impl Parents {
fn new() -> Self {
Self(HashMap::new())
}
fn record(&mut self, r: ObjRef, above: Above) {
if above.element.is_some() || above.described_in_place {
self.0.entry(r).or_default().push(above);
}
}
}
/// Remove the descriptions of every element above `elements`, through both `/P` and the walk's
/// `parents`; returns how many elements changed. [`RedactError::Tags`] when an element written in
/// place above them carries one, or when there are more than `limit` ancestors.
fn clear_ancestors(doc: &mut Document, elements: &[ObjRef], parents: &Parents, limit: usize) -> Result<usize, RedactError> {
let mut todo: Vec<ObjRef> = elements.to_vec();
let mut seen: HashSet<ObjRef> = elements.iter().copied().collect();
let mut cleared = 0;
while let Some(r) = todo.pop() {
let mut up: Vec<ObjRef> = doc.get(r).as_dict().and_then(|d| d.get(b"P")).and_then(Object::as_ref).into_iter().collect();
for above in parents.0.get(&r).into_iter().flatten() {
if above.described_in_place {
return Err(RedactError::Tags);
}
up.extend(above.element);
}
for a in up {
if !seen.insert(a) {
continue;
}
if seen.len() > limit {
return Err(RedactError::Tags);
}
// The structure tree root (no /S) ends the climb.
let Some(d) = doc.get(a).as_dict().filter(|d| d.contains(b"S")).cloned() else { continue };
if DESCRIPTIONS.iter().any(|k| d.contains(k)) {
doc.update_dict(a, |e| {
for key in DESCRIPTIONS {
e.remove(key);
}
})?;
cleared += 1;
}
todo.push(a);
}
}
Ok(cleared)
}
/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following
/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than
/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps
@@ -337,12 +467,14 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
};
// Elements are reached from the root's /K, and also from the parent tree and the ID tree:
// one listed only there (left behind by an editor, say) still points at its annotation.
let mut todo: Vec<Object> = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect();
let mut todo: Vec<(Object, Above)> =
[&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, Above::default())).collect();
let mut seen = HashSet::new();
let mut parents = Parents::new();
let mut hits: Vec<ObjRef> = Vec::new();
while let Some(k) = todo.pop() {
while let Some((k, above)) = todo.pop() {
match k {
Object::Array(a) => todo.extend(a),
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, above))),
// A structure element written in place: its kids are followed; one that tags a
// removed annotation can't be changed here (see above).
Object::Dict(d) if d.contains(b"S") => {
@@ -350,11 +482,13 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
if kids.iter().any(|x| refers_to(doc, x, gone)) {
return Err(RedactError::Tags);
}
todo.extend(kids);
let above = above.through(&d);
todo.extend(kids.into_iter().map(|x| (x, above)));
}
// A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids.
Object::Dict(d) => todo.extend(tree_node_entries(&d)),
Object::Dict(d) => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, above))),
Object::Ref(r) => {
parents.record(r, above);
if !seen.insert(r) {
continue;
}
@@ -363,26 +497,26 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
}
let object = doc.get(r);
if let Object::Array(a) = &*object {
todo.extend(a.iter().cloned());
todo.extend(a.iter().cloned().map(|x| (x, above)));
continue;
}
let Some(d) = object.as_dict() else { continue };
if !d.contains(b"S") {
// A parent tree or ID tree node: its values may be elements.
todo.extend(tree_node_entries(d));
todo.extend(tree_node_entries(d).into_iter().map(|x| (x, above)));
continue;
}
let kids = kids_of(doc, d);
if kids.iter().any(|x| refers_to(doc, x, gone)) {
hits.push(r);
}
todo.extend(kids);
todo.extend(kids.into_iter().map(|x| (x, Above::element(r))));
}
_ => {}
}
}
let mut n = 0;
for r in hits {
for &r in &hits {
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
let kids = d.get(b"K").map(|k| doc.resolve(k));
let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_)));
@@ -402,14 +536,21 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
} else {
e.remove(b"K");
}
// An element left with nothing to tag keeps no text describing what it tagged:
// a link's alternate text often repeats the URL, an annotation's its comment.
if keep.is_empty() {
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
e.remove(key);
}
// An element that lost an annotation keeps no text describing it, even with other
// kids left: a link's alternate text often repeats the URL, an annotation's its
// comment. Elements above it may describe it too.
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
e.remove(key);
}
})?;
}
clear_ancestors(doc, &hits, &parents, limit)?;
Ok(n)
}
/// Bytes held for comparing marked content, for tests that bound it.
#[cfg(test)]
pub(crate) fn retained_for_comparison(doc: &Document, resources: &Dict, data: &[u8]) -> usize {
let marked = by_mcid(doc, resources, data);
marked.ops.len() + marked.ids.values().map(|(ranges, _)| ranges.len() * std::mem::size_of::<Range<usize>>()).sum::<usize>()
}
+5
View File
@@ -2461,7 +2461,12 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
}
}
mod descriptions;
mod inline_images;
mod limits;
mod property_retirement;
mod surviving_references;
mod text_state;
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
/// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not
+64
View File
@@ -0,0 +1,64 @@
//! Descriptions in the structure tree (alternate text, actual text, expansions) go with the
//! content they describe, on the element that owns it and on every element above it.
use super::surviving_references::full_save_holds;
use super::*;
/// A tagged page: "SECRET" (MCID 0) inside a section whose descriptions repeat it, and "Public"
/// (MCID 1). `paragraph_parent` is the secret paragraph's /P.
fn sectioned(paragraph_parent: &str) -> Document {
let content = b"/P <</MCID 0>> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC";
pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", content),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(),
b"<< /S /Document /P 7 0 R /K [11 0 R 10 0 R] >>".to_vec(),
format!("<< /S /P /P {paragraph_parent} /Pg 3 0 R /K 0 >>").into_bytes(),
b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(),
b"<< /S /Sect /P 8 0 R /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /K 9 0 R >>".to_vec(),
])
}
#[test]
fn an_ancestor_describing_redacted_content_loses_its_descriptions() {
// The paragraph names its section as parent, or (wrongly) the document: the section is found
// either way, by its /K.
for parent in ["11 0 R", "8 0 R"] {
let mut doc = sectioned(parent);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the redacted word survived (paragraph /P {parent})");
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
assert!(public.contains(b"ActualText"), "an element beside it keeps its own");
}
}
#[test]
fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annotation() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>"
.to_vec(),
stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
FONT.replace("95 0 R", "12 0 R").into_bytes(),
b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(),
b"null".to_vec(),
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(),
b"<< /Type /StructElem /S /P /P 9 0 R /ActualText (See LEAK_PARENT) /K 11 0 R >>".to_vec(),
// The link element tags its text and the annotation; its descriptions repeat the address.
b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /Alt (LEAK_ALT) /ActualText (LEAK_ACTUAL) /E (LEAK_E) /T (LEAK_T) /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
widths(),
]);
let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap();
assert!(done.contains(&(Hidden::LinksActionsScripts, 1)), "{done:?}");
assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the removed link survived");
let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap();
assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text");
}
+34
View File
@@ -0,0 +1,34 @@
//! Inline image data ends where the renderer ends it, so the operators after it are the ones the
//! page draws.
use super::*;
/// The image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` straight after `7F`. The renderer ends
/// the image there (text follows, so what comes after reads as content) and runs the `cm`,
/// drawing SECRET at x = 210, not 10.
const FAKE_EI: &[u8] = b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET";
#[test]
fn text_after_an_inline_image_is_redacted_where_the_renderer_draws_it() {
let mut doc = one_page(FAKE_EI, "", vec![]);
assert_eq!(under(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]]), 0, "nothing is drawn at x = 10");
mark(&mut doc, 0, &[[205.0, 95.0, 245.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
assert!(!content(&doc, 0).contains("SECRET"));
}
#[test]
fn an_inline_image_with_one_possible_end_is_redacted() {
let mut doc = one_page(b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", "", vec![]);
mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
assert!(!content(&doc, 0).contains("SECRET"));
}
/// Without an `EI` the renderer accepts, where the operators resume is a guess: refused.
#[test]
fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F", "", vec![]);
mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], "");
assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1)));
}
+55
View File
@@ -0,0 +1,55 @@
//! Work and memory stay bounded by what the page declares, whatever the file holds.
use super::*;
/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`.
fn half_covered_image(decoded: &[u8]) -> Report {
let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8";
let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]);
let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() };
doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded)));
mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], "");
apply(&mut doc, None).unwrap()
}
#[test]
fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() {
let r = half_covered_image(&[255; 4]);
assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}");
// 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from
// the raster, and the image goes whole.
let mut bomb = vec![255; 4];
bomb.resize(4 << 20, 0);
let r = half_covered_image(&bomb);
assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}");
}
/// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing
/// operators.
fn nested_marked_content(depth: usize) -> Vec<u8> {
let mut data = Vec::new();
for id in 0..depth {
data.extend_from_slice(format!("/P <</MCID {id}>> BDC\n").as_bytes());
}
data.extend(b"0 0 1 1 re f\n".repeat(depth));
data.extend(b"EMC\n".repeat(depth));
data
}
#[test]
fn comparing_deeply_nested_marked_content_holds_each_operator_once() {
let doc = one_page(b"", "", vec![]);
let data = nested_marked_content(2000);
let held = tags::retained_for_comparison(&doc, &Dict::new(), &data);
assert!(held <= 2 * data.len(), "{held} bytes held for {} bytes of content", data.len());
}
#[test]
fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() {
let doc = one_page(b"", "", vec![]);
let before = b"/P <</MCID 0>> BDC /P <</MCID 1>> BDC BT (A) Tj ET EMC 0 0 1 1 re f EMC /P <</MCID 2>> BDC BT (B) Tj ET EMC";
let after = b"/P <</MCID 0>> BDC /P <</MCID 1>> BDC BT ET EMC 0 0 1 1 re f EMC /P <</MCID 2>> BDC BT (B) Tj ET EMC";
let (changed, empty) = tags::touched(&doc, &Dict::new(), before, after);
assert_eq!(changed, HashSet::from([0, 1]));
assert_eq!(empty, HashSet::from([1]));
}
@@ -0,0 +1,68 @@
//! Redacted content must leave the file: no other reference may keep it reachable by a full save.
use super::*;
/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream.
pub(super) fn full_save_holds(doc: &Document, secret: &[u8]) -> bool {
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret);
if found(&bytes) {
return true;
}
let saved = Document::open(Arc::new(bytes)).unwrap();
saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d))))
}
#[test]
fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() {
let mut doc = one_page(b"", "", vec![]);
pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap();
pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap();
let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj;
let root = doc.root().unwrap();
let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap();
doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap();
mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().fields, 1);
assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable");
}
/// A surviving form shares the page's resource dictionary, which lists the image the mark
/// removes; the form never draws it.
#[test]
fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(),
stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"),
]);
mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1);
assert!(content(&doc, 0).contains("Do"), "the form is still drawn");
assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable");
}
/// The same form drawing the image itself keeps it: the page still shows it there.
#[test]
fn an_image_a_surviving_form_draws_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(),
stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"),
]);
mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1);
assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark");
}
+27
View File
@@ -0,0 +1,27 @@
//! Glyphs are placed with the whole text state, wherever it was set.
use super::*;
const SECRET_AT_10_100: [f64; 4] = [5.0, 95.0, 45.0, 110.0];
#[test]
fn a_font_set_by_an_extgstate_places_its_glyphs() {
let mut doc = one_page(b"BT /GS1 gs 10 100 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Type /ExtGState /Font [5 0 R 10] >> >>", vec![]);
// "ET" spans x 30–40.
assert_eq!(under(&mut doc, 0, &[[30.0, 95.0, 40.0, 110.0]]), 2);
mark(&mut doc, 0, &[SECRET_AT_10_100], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
assert!(!content(&doc, 0).contains("SECRET"));
}
#[test]
fn a_form_inherits_the_text_state_of_the_page_that_draws_it() {
// The form shows text with the font, size and spacing the page set before `Do`.
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT 10 100 Td (SECRET) Tj ET");
let mut doc = one_page(b"/F1 10 Tf 1 Tc /Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
// Glyphs 5 pt wide, 1 pt apart: the last one spans x 40–45.
assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 46.0, 110.0]]), 1);
mark(&mut doc, 0, &[SECRET_AT_10_100], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}");
}
+2 -2
View File
@@ -5709,8 +5709,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::redaction_is_refused_when_the_page_still_shows_text_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"]
notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text whose font is set through an ExtGState or taken by a form from its caller, some predefined CMaps and rotated Type 3 fonts are not removed yet, so those documents cannot be redacted. Removed images and forms are unlinked from page-tree resources (own, inherited or shared), not yet from a form's own resources, annotation appearances or patterns"
evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"]
notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns"
[[feature]]
id = "protect.redact-glyph-removal"