Merge remote-tracking branch 'upstream/main' into fix/xfa-edit-picture-alternatives

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Eric Kaden
2026-10-22 22:55:12 -04:00
56 changed files with 2065 additions and 356 deletions
+26 -6
View File
@@ -665,17 +665,20 @@ pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
/// Cycles end at the seen check and at most a million nodes are visited.
/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once,
/// and at most a million nodes and four million `/Kids` entries are read.
struct PageWalk<'a> {
doc: &'a Document,
seen: std::collections::HashSet<ObjRef>,
pending: Vec<(std::sync::Arc<Object>, usize)>,
/// `/Kids` entries read so far.
examined: usize,
}
impl<'a> PageWalk<'a> {
fn new(doc: &'a Document, top: ObjRef) -> Self {
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 }
}
}
@@ -690,8 +693,9 @@ impl Iterator for PageWalk<'_> {
};
let node = kid.as_ref();
*next += 1;
self.examined += 1;
let Some(node) = node else { continue };
if self.seen.len() >= 1_000_000 {
if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 {
self.pending.clear();
return None;
}
@@ -700,8 +704,15 @@ impl Iterator for PageWalk<'_> {
}
let obj = self.doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
match d.get(b"Kids") {
// A `/Kids` array object shared by several nodes is read under the first: under
// the others it would only list nodes already seen.
Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => {
if self.seen.insert(*array) {
self.pending.push((self.doc.get(*array), 0));
}
}
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)),
_ => return Some(node),
}
}
@@ -717,7 +728,16 @@ fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
fn annots(doc: &Document, page: ObjRef) -> Vec<Object> {
let obj = doc.get(page);
let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() };
doc.resolve(a).as_array().cloned().unwrap_or_default()
let list = doc.resolve(a).as_array().cloned().unwrap_or_default();
#[cfg(test)]
ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len()));
list
}
#[cfg(test)]
thread_local! {
/// Entries copied out of pages' `/Annots` arrays.
static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect.
+40 -7
View File
@@ -255,6 +255,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> {
/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`.
pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<usize, AnnotError> {
let p = page_ref(doc, page)?;
let obj = new_link(doc, p, rect, action, style)?;
let mut list = annots(doc, p);
list.push(Object::Ref(obj));
let index = list.len() - 1;
set_annots(doc, p, list)?;
Ok(index)
}
/// A link annotation object for page `p`, not yet on the page's `/Annots`.
fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<ObjRef, AnnotError> {
let r = checked_rect(rect)?;
if matches!(action, LinkAction::Other(_)) {
return Err(AnnotError::Invalid("choose a page or a web address".into()));
@@ -267,12 +277,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction,
d.set(b"F".to_vec(), Object::Int(4));
write_style(&mut d, style);
write_action(doc, &mut d, action)?;
let obj = doc.add(Object::Dict(d));
let mut list = annots(doc, p);
list.push(Object::Ref(obj));
let index = list.len() - 1;
set_annots(doc, p, list)?;
Ok(index)
Ok(doc.add(Object::Dict(d)))
}
fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
@@ -437,12 +442,40 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
/// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per
/// line rectangle). Returns how many links were added.
pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result<usize, AnnotError> {
let pages = page_refs(doc)?;
// Check the whole batch first, so a bad entry fails it before any link object is created.
for (page, rects, uri) in links {
pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?;
if uri.trim().is_empty() {
return Err(AnnotError::Invalid("type the web address first".into()));
}
for r in rects {
checked_rect(*r)?;
}
}
// Each page's `/Annots` is extended once, with its new links in the order given.
let mut added: Vec<(ObjRef, Vec<Object>)> = Vec::new();
let mut slot: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
let mut n = 0;
for (page, rects, uri) in links {
let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?;
let action = LinkAction::Uri(uri.clone());
for r in rects {
add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?;
let obj = new_link(doc, p, *r, &action, style)?;
let k = *slot.entry(p).or_insert_with(|| {
added.push((p, Vec::new()));
added.len() - 1
});
if let Some((_, list)) = added.get_mut(k) {
list.push(Object::Ref(obj));
}
n += 1;
}
}
for (p, new) in added {
let mut list = annots(doc, p);
list.extend(new);
set_annots(doc, p, list)?;
}
Ok(n)
}
+55
View File
@@ -1505,6 +1505,61 @@ fn typed_signature_uses_nonzero_winding_fill() {
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
}
/// Many page tree nodes sharing one `/Kids` array read it once, not once per node.
#[test]
fn page_walk_reads_a_shared_kids_array_once() {
let mut doc = fixture();
let root = ObjRef { num: 2, generation: 0 };
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect()));
let nodes: Vec<Object> = (0..1_000)
.map(|_| {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Pages"));
d.set(b"Kids".to_vec(), Object::Ref(shared));
Object::Ref(doc.add(d))
})
.collect();
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap();
let mut walk = PageWalk::new(&doc, root);
let pages: Vec<ObjRef> = walk.by_ref().collect();
assert_eq!(pages, [first, second]);
assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined);
}
/// Adding many links to a page extends its `/Annots` once, not once per link.
#[test]
fn many_links_extend_a_page_annotation_list_once() {
let mut doc = fixture();
let before = annots(&doc, page_refs(&doc).unwrap()[0]).len();
let n = 2_000;
let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect();
ANNOTS_ENTRIES_COPIED.with(|c| c.set(0));
let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap();
let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get);
assert_eq!(added, n);
assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n);
assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links");
}
/// A batch with one bad entry adds nothing: no link objects are left behind unattached.
#[test]
fn a_link_batch_with_a_bad_entry_creates_no_objects() {
let mut doc = fixture();
let page = page_refs(&doc).unwrap()[0];
let before = (annots(&doc, page).len(), doc.scan_objects().count());
let good = [10.0, 10.0, 50.0, 20.0];
let style = links::LinkStyle::default();
for bad in [
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good, [f64::NAN, 0.0, 10.0, 10.0]], "https://example.com".into())],
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good], " ".into())],
vec![(0, vec![good], "https://example.com".to_string()), (99, vec![good], "https://example.com".into())],
] {
assert!(links::add_many(&mut doc, &bad, &style).is_err());
assert_eq!((annots(&doc, page).len(), doc.scan_objects().count()), before);
}
}
fn doc_of(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
+6 -1
View File
@@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden:
less than 40% of its words (a sliver of a long value) is not flagged.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word
comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no
findings".
A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with
an empty `covered_text`: the audit hands out no text that copying would refuse.
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
+3 -2
View File
@@ -147,10 +147,11 @@ impl<'a> Walker<'a> {
match op.op.as_slice() {
b"q" => stack.push(gs.clone()),
b"Q" => {
// The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still
// fills the rectangle, whose corners are already in user space.
if let Some(g) = stack.pop() {
*gs = g;
}
path = Path::Empty;
}
b"cm" => {
let before = gs.ctm;
@@ -261,7 +262,7 @@ impl<'a> Walker<'a> {
let resolved = self.doc.resolve(entry);
let Object::Stream(stream) = &*resolved else { return Ok(()) };
let dict = &stream.dict;
if dict.name(b"Subtype") != Some(b"Form") {
if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") {
return Ok(());
}
let matrix = dict
+113 -34
View File
@@ -65,7 +65,7 @@ pub struct AuditFinding {
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
pub rect: [f64; 4],
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
/// Possibly empty when nothing decodable remains under the cover.
/// Empty when the document's permissions withhold copying (see [`audit_redactions`]).
pub covered_text: String,
}
@@ -81,6 +81,10 @@ pub enum AuditError {
TooManyForms,
#[error("page has a malformed /Annots entry")]
BadAnnots,
#[error("page has more covers and text lines than the audit compares")]
TooManyComparisons,
#[error("covered text exceeds the audit's report budget")]
TooMuchOutput,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
#[error(transparent)]
@@ -97,47 +101,110 @@ const WORD_FRACTION: f64 = 0.5;
const COVER_CONTRAST: f64 = 3.0;
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
/// content that cannot be decoded is returned as an error, never as an empty finding list.
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
/// returned as an error, never as an empty finding list.
///
/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are
/// still reported but their `covered_text` is empty: the audit hands out no text that copying
/// would refuse.
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
audit(doc, Limits::DEFAULT)
}
/// Work and output bounds for one audit.
#[derive(Clone, Copy)]
struct Limits {
/// Cover-against-line comparisons per page, counting a line once per word box it has (at
/// least once), since [`covers`] walks them.
comparisons: usize,
/// Bytes of covered text the findings report.
covered_text: usize,
}
impl Limits {
/// Every cover is compared with every line of its page, and each finding repeats the text
/// it covers, so both grow with the square of the content: a few megabytes of covers over
/// one long line would otherwise take hours and gigabytes.
const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 };
}
/// The work and report the audit has spent against its [`Limits`].
struct Budget {
limits: Limits,
comparisons: usize,
/// What comparing one cover with every line of the current page costs.
page_cost: usize,
covered_text: usize,
}
impl Budget {
/// Resets the comparison count for a page with these `lines`.
fn start_page(&mut self, lines: &[TextLine]) {
self.comparisons = 0;
self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1)));
}
/// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show
/// no text. Without `copying` the text is empty and never assembled: one long line under many
/// covers would otherwise be copied once per cover, outside both bounds.
fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result<Option<String>, AuditError> {
self.comparisons = self.comparisons.saturating_add(self.page_cost);
if self.comparisons > self.limits.comparisons {
return Err(AuditError::TooManyComparisons);
}
if !copying {
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
}
let mut covered = String::new();
for line in lines.iter().filter(|l| hides(l)) {
if !covered.is_empty() {
covered.push(' ');
}
covered.push_str(line.text.trim());
}
Ok((!covered.trim().is_empty()).then_some(covered))
}
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
/// reports against the output bound.
fn report_text(&mut self, covered: String, copying: bool) -> Result<String, AuditError> {
let reported = if copying { covered } else { String::new() };
self.covered_text = self.covered_text.saturating_add(reported.len());
if self.covered_text > self.limits.covered_text {
return Err(AuditError::TooMuchOutput);
}
Ok(reported)
}
}
fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError> {
let copying = doc.permissions().is_none_or(|p| p.copy());
let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 };
let mut out = Vec::new();
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
// The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty
// or substitute one must not hide text a cover sits on.
let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?;
budget.start_page(&lines);
// 1. Opaque filled rectangles over extractable text.
for r in cover::filled_rects(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if is_cover(&r, line) {
if !covered.is_empty() {
covered.push(' ');
}
covered.push_str(line.text.trim());
}
}
if !covered.trim().is_empty() {
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? {
let covered_text = budget.report_text(covered, copying)?;
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text });
}
}
// 2. Annotation covers over extractable text.
for annot in annots(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
let hidden = match &annot {
AnnotationCover::Redact(rect) => covers(rect, line),
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
};
if hidden {
if !covered.is_empty() {
covered.push(' ');
}
covered.push_str(line.text.trim());
}
}
if !covered.trim().is_empty() {
if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot {
AnnotationCover::Redact(rect) => covers(rect, line),
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
})? {
let (kind, rect) = match annot {
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
};
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
let covered_text = budget.report_text(covered, copying)?;
out.push(AuditFinding { page: n, kind, rect, covered_text });
}
}
}
@@ -145,11 +212,13 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
}
/// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly
/// after the line, overlaps it substantially, and is dark enough against the line's color to
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
/// contrast check and are not flagged.
/// after the start of the line, overlaps it substantially, and is dark enough against the line's
/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail
/// the contrast check and are not flagged. A line can join runs painted before and after the
/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable
/// too.
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
}
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
@@ -231,7 +300,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
match dict.name(b"Subtype") {
match subtype(doc, &dict)?.as_deref() {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
@@ -251,6 +320,16 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
Ok(out)
}
/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be
/// loaded fails the audit: the subtype decides whether the object is checked at all.
fn subtype(doc: &Document, dict: &Dict) -> Result<Option<Vec<u8>>, AuditError> {
let name = match dict.get(b"Subtype") {
Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec),
other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec),
};
Ok(name)
}
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
if !values.iter().all(|v| v.is_finite()) {
+145
View File
@@ -593,6 +593,151 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
}
}
/// The current path is not graphics state: a rectangle built before `Q` and filled after it
/// paints the box, so it is a cover.
#[test]
fn rectangle_filled_after_restore_is_a_cover() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
}
/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact`
/// annotation and a covering form XObject named that way are still checked.
#[test]
fn indirect_subtype_is_resolved() {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n");
b.add(FONT);
b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>");
b.add("/Redact");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]");
b.add(FONT);
b.add("/Form");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
}
/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover
/// still hides the words painted before it.
#[test]
fn later_text_on_the_line_does_not_clear_an_earlier_cover() {
let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n";
let doc = open(one_page(content, ""));
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
let findings = audit_redactions(&doc).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
}
/// Many covers over the same long line repeat its text in every finding: a small file must not
/// expand into an unbounded report. Past the budget the audit fails rather than truncate.
#[test]
fn covered_text_across_findings_is_bounded() {
let text = "X".repeat(60_000);
let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes();
for i in 0..1_200 {
content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes());
}
let result = audit_redactions(&open(one_page(&content, "")));
assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len()));
}
/// Covers compared against lines are bounded per page, so content that is quadratic in its
/// covers and lines fails the audit instead of running for hours. A line costs one comparison per
/// word: 20 covers against 20 two-word lines are 800.
#[test]
fn cover_and_line_comparisons_are_bounded() {
let mut content = Vec::new();
for i in 0..20 {
content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes());
}
for _ in 0..20 {
content.extend_from_slice(b"1 g 0 0 10 10 re f\n");
}
let doc = open(one_page(&content, ""));
let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX };
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX };
assert!(crate::audit(&doc, limits).expect("within the budget").is_empty());
}
/// Covered text is document text: a document whose permissions withhold copying reports where
/// its covers are but not what they hide, unless it was opened with the owner password.
#[test]
fn copy_restricted_documents_withhold_covered_text() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
let mut doc = open(one_page(content, ""));
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [7; 32],
})
.expect("encrypt");
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save"));
let user = Document::open_with_password(bytes.clone(), None).expect("open");
let findings = audit_redactions(&user).expect("audit must run");
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
assert_eq!(findings[0].covered_text, "");
// The output bound counts the text reported, so withheld text costs nothing.
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
let findings = audit_redactions(&owner).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
assert!(crate::audit(&owner, exact).is_ok());
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
}
/// Withheld text is never assembled: covers over one long line would otherwise copy the line once
/// per cover, a volume neither the comparison nor the output bound charges.
#[test]
fn withheld_covered_text_is_not_assembled() {
let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", ""));
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 };
budget.start_page(&lines);
let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget");
assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it");
assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None);
let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget");
assert_eq!(copied.as_deref(), Some("SECRET DATA 123"));
}
/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must
/// not hide the glyphs a cover sits on.
#[test]
fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() {
for actual in ["()", "(nothing here)"] {
let content =
format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n");
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{actual}: {findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text);
}
}
#[test]
fn repeated_stream_references_share_one_decode_budget() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
+148 -92
View File
@@ -86,108 +86,164 @@ pub fn parse(data: &[u8]) -> Parsed {
parse_pieces(data, &[])
}
/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A
/// caller that stops early or keeps only a summary never holds every operator of the stream.
pub fn ops(data: &[u8]) -> Ops<'_> {
Ops::new(data, &[])
}
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
/// crosses.
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
let mut boundary = 0;
let mut lx = Lexer::new(data, 0);
let mut operands: Vec<Object> = Vec::new();
let mut start: Option<usize> = None;
loop {
let trivia = lx.pos;
lx.skip_ws();
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
// string, a name, an array or dictionary operand or inline image data is not one.
if boundary < ends.len() {
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
let mut reader = Ops::new(data, ends);
let ops = reader.by_ref().collect();
Parsed { ops, skipped: reader.skipped, ambiguous_images: reader.ambiguous_images, comment_edges: reader.comment_edges }
}
/// The iterator [`ops`] returns.
pub struct Ops<'a> {
data: &'a [u8],
lx: Lexer<'a>,
operands: Vec<Object>,
start: Option<usize>,
skipped: usize,
ambiguous_images: usize,
ends: &'a [usize],
boundary: usize,
comment_edges: Vec<(bool, bool)>,
}
impl<'a> Ops<'a> {
fn new(data: &'a [u8], ends: &'a [usize]) -> Self {
Ops {
data,
lx: Lexer::new(data, 0),
operands: Vec::new(),
start: None,
skipped: 0,
ambiguous_images: 0,
ends,
boundary: 0,
comment_edges: vec![(false, false); ends.len()],
}
let Some(&b) = data.get(lx.pos) else { break };
let at = lx.pos;
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
if operand_start {
match lx.object() {
Ok(o) => {
start.get_or_insert(at);
operands.push(o);
}
Err(_) if b == b'(' => {
// An unterminated string runs to the end of the stream.
out.skipped += data.len() - at;
lx.pos = data.len();
}
Err(_) => {
let read = lx.pos.saturating_sub(at);
#[cfg(test)]
tests::RESCANNED.with(|c| c.set(c.get() + read));
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
if number && read > MAX_RETRIED_TOKEN {
// A long unreadable number is skipped whole: retrying from each of its
// bytes would read the rest of it again every time.
out.skipped += read;
} else {
// Skip the offending byte and carry on.
lx.pos = at + 1;
out.skipped += 1;
}
}
}
/// Bytes read so far that could not be read as operands or operators.
pub fn skipped(&self) -> usize {
self.skipped
}
/// Inline images read so far whose end is this parser's guess (as in
/// [`Parsed::ambiguous_images`]).
pub fn ambiguous_images(&self) -> usize {
self.ambiguous_images
}
}
impl Iterator for Ops<'_> {
type Item = Op;
fn next(&mut self) -> Option<Op> {
let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self;
let (data, ends): (&[u8], &[usize]) = (data, ends);
loop {
let trivia = lx.pos;
lx.skip_ws();
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
// string, a name, an array or dictionary operand or inline image data is not one.
if *boundary < ends.len() {
comment_edges(data, trivia..lx.pos, ends, boundary, edges);
}
continue;
}
if is_delim(b) {
// A stray `)`, `>`, `]`, `{`, `}`.
lx.pos += 1;
out.skipped += 1;
continue;
}
let kw = lx.token();
if kw.is_empty() {
lx.pos = at + 1;
out.skipped += 1;
continue;
}
match kw {
b"true" => {
start.get_or_insert(at);
operands.push(Object::Bool(true));
}
b"false" => {
start.get_or_insert(at);
operands.push(Object::Bool(false));
}
b"null" => {
start.get_or_insert(at);
operands.push(Object::Null);
}
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;
}
}
let Some(&b) = data.get(lx.pos) else {
// Operands left without an operator.
*skipped += operands.len();
operands.clear();
return None;
};
let at = lx.pos;
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
if operand_start {
match lx.object() {
Ok(o) => {
start.get_or_insert(at);
operands.push(o);
}
Err(_) if b == b'(' => {
// An unterminated string runs to the end of the stream.
*skipped += data.len() - at;
lx.pos = data.len();
}
Err(_) => {
let read = lx.pos.saturating_sub(at);
#[cfg(test)]
tests::RESCANNED.with(|c| c.set(c.get() + read));
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
if number && read > MAX_RETRIED_TOKEN {
// A long unreadable number is skipped whole: retrying from each of its
// bytes would read the rest of it again every time.
*skipped += read;
} else {
// Skip the offending byte and carry on.
lx.pos = at + 1;
*skipped += 1;
}
}
}
continue;
}
_ => {
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 });
if is_delim(b) {
// A stray `)`, `>`, `]`, `{`, `}`.
lx.pos += 1;
*skipped += 1;
continue;
}
let kw = lx.token();
if kw.is_empty() {
lx.pos = at + 1;
*skipped += 1;
continue;
}
match kw {
b"true" => {
start.get_or_insert(at);
operands.push(Object::Bool(true));
}
b"false" => {
start.get_or_insert(at);
operands.push(Object::Bool(false));
}
b"null" => {
start.get_or_insert(at);
operands.push(Object::Null);
}
b"BI" => {
let image = inline_image(data, at, lx.pos);
*ambiguous_images += usize::from(image.ambiguous);
let end = image.end;
lx.pos = end;
operands.clear();
match image.image {
Some((dict, img)) => {
return Some(Op {
op: b"BI".to_vec(),
operands: Vec::new(),
inline: Some((dict, img)),
span: start.take().unwrap_or(at)..end,
});
}
None => {
*skipped += end.saturating_sub(at);
*start = None;
}
}
}
_ => {
let s = start.take().unwrap_or(at);
return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos });
}
}
}
}
out.skipped += operands.len();
out
}
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
+3
View File
@@ -203,6 +203,9 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them()
let parsed = parse(src);
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
assert_eq!(parsed.ambiguous_images, ambiguous);
let mut reader = ops(src);
reader.by_ref().for_each(drop);
assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too");
}
}
+36 -9
View File
@@ -393,17 +393,38 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded {
/// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read:
/// 72 dpi, as for images without one.
fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embedded, CreateError> {
// A GIF's first frame is painted on a canvas the size of its logical screen, however small
// the frame: a few bytes can ask for hundreds of MB.
if format == image::ImageFormat::Gif
&& let Some(&[w0, w1, h0, h1]) = bytes.get(6..10)
&& u64::from(u16::from_le_bytes([w0, w1])) * u64::from(u16::from_le_bytes([h0, h1])) > MAX_GIF_PIXELS
{
return Err(CreateError::Image(name.into(), "the GIF's screen is larger than 4096×4096 pixels".into()));
}
let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?;
let rgba = img.to_rgba8();
let rgba = img.into_rgba8();
Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))
}
/// Largest GIF logical screen, in pixels (4096×4096).
const MAX_GIF_PIXELS: u64 = 1 << 24;
/// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665,
/// twice over.
const MAX_DECODED_BYTES: usize = 2 << 30;
/// Every page of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until
/// they are embedded, and many small directories can share one compressed strip. Saturates to the
/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`.
const MAX_DECODED_TOTAL: usize = MAX_DECODED_BYTES.saturating_mul(2);
/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs).
fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL)
}
/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes.
fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result<Vec<Embedded>, CreateError> {
use tiff::ColorType as C;
use tiff::decoder::{Decoder, DecodingResult, Limits};
use tiff::tags::Tag;
@@ -415,6 +436,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
limits.decoding_buffer_size = MAX_DECODED_BYTES;
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits);
let mut out = Vec::new();
let mut kept = 0usize;
loop {
let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?;
let ct = dec.colortype().map_err(|e| bad(e.to_string()))?;
@@ -469,8 +491,12 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
}
other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))),
};
kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len()));
if kept > budget {
return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20)));
}
out.push(page);
if !dec.more_images() {
if out.len() >= wanted || !dec.more_images() {
break;
}
dec.next_image().map_err(|e| bad(e.to_string()))?;
@@ -526,14 +552,15 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
}
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Detect the image format from its bytes; a TIFF may hold several pages, of which the first
/// `wanted` are read.
fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
tiff_pages(name, bytes)
tiff_pages(name, bytes, wanted)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
@@ -548,7 +575,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
/// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the
/// object and the image's natural size in points (from its resolution).
pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> {
let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let img = embed(name, bytes, 1)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
Ok((add_image(doc, img, &mut Vec::new()), size))
}
@@ -580,7 +607,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
}
let mut doc = Document::new_empty();
let mut profiles = Vec::new();
for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
let dpi = match resolution {
ImageResolution::Embedded => img.dpi,
ImageResolution::Dpi(dpi) => (dpi, dpi),
+44
View File
@@ -1206,3 +1206,47 @@ fn a_cyclic_indexed_colour_space_is_refused() {
assert!(out.images.is_empty());
assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped);
}
/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read.
fn gray_tiff(pages: usize, unreadable: bool) -> Vec<u8> {
let mut tif = std::io::Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap();
for _ in 0..pages {
enc.write_image::<tiff::encoder::colortype::Gray8>(64, 64, &[128; 64 * 64]).unwrap();
}
if unreadable {
enc.write_image::<tiff::encoder::colortype::RGB32Float>(1, 1, &[0.5; 3]).unwrap();
}
tif.into_inner()
}
/// Many small IFDs can each decode to a large page: the pages a TIFF keeps in memory together
/// are bounded, and embedding one image reads only its first page.
#[test]
fn tiff_pages_are_decoded_within_a_total_budget() {
let tif = gray_tiff(3, false);
assert!(matches!(tiff_pages_within("t.tif", &tif, usize::MAX, 10_000), Err(CreateError::Image(..))), "12 KiB of pages past a 10 KB budget");
assert_eq!(tiff_pages_within("t.tif", &tif, usize::MAX, 20_000).unwrap().len(), 3);
let tif = gray_tiff(1, true);
let mut doc = Document::new_empty();
assert!(image_xobject(&mut doc, "t.tif", &tif).is_ok(), "the unreadable last page is never decoded");
}
/// A one-pixel GIF on a `w`×`h` logical screen: the decoder paints it on a canvas that size.
fn gif_on_screen(w: u16, h: u16) -> Vec<u8> {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&w.to_le_bytes());
gif.extend_from_slice(&h.to_le_bytes());
gif.extend_from_slice(&[0x80, 0, 0, 0xFF, 0xFF, 0xFF, 0, 0, 0]);
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 1, 0, 0x3B]);
gif
}
/// The decoder's own limit lets a 74-byte GIF ask for a canvas of hundreds of MB, which the
/// conversion then copies; a screen past [`MAX_GIF_PIXELS`] is refused before decoding.
#[test]
fn a_tiny_gif_on_a_huge_screen_is_refused() {
assert!(matches!(from_images(&[("big.gif".into(), gif_on_screen(4200, 4100))]), Err(CreateError::Image(..))));
let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap());
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]);
}
+66 -1
View File
@@ -14,6 +14,12 @@ const QUALITY: u8 = 92;
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
const MAX_PIXELS: u64 = 64 << 20;
#[cfg(test)]
thread_local! {
/// How many JPEGs [`turned`] handed to the decoder on this thread.
static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
/// everything else, and any JPEG this can't read, is returned unchanged.
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
@@ -24,9 +30,11 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
}
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS {
return None;
}
#[cfg(test)]
DECODES.with(|c| c.set(c.get().saturating_add(1)));
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
let orientation = decoder.orientation().ok()?;
let (w, h) = decoder.dimensions();
@@ -53,6 +61,41 @@ fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
Some(out)
}
/// Most Extended XMP chunks a JPEG handed to the decoder may carry (64 MB of metadata). The
/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number.
const MAX_EXTENDED_XMP_CHUNKS: usize = 1024;
/// How many Extended XMP chunks (APP1 segments) come before the image data.
fn extended_xmp_chunks(bytes: &[u8]) -> usize {
const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
let (mut i, mut chunks) = (2usize, 0usize);
while let Some(&byte) = bytes.get(i) {
// Stray bytes between segments, which the decoder skips too.
if byte != 0xFF {
i += 1;
continue;
}
let Some(&marker) = bytes.get(i.saturating_add(1)) else { break };
match marker {
// Fill bytes before a marker.
0xFF => i += 1,
// Markers without a segment.
0x00 | 0x01 | 0xD0..=0xD7 => i += 2,
// The image data starts (or ends): no headers after it.
0xDA | 0xD9 => break,
_ => {
let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break };
let len = usize::from(u16::from_be_bytes([hi, lo]));
if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) {
chunks += 1;
}
i = i.saturating_add(2).saturating_add(len.max(2));
}
}
}
chunks
}
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
let mut i = 2usize;
@@ -94,4 +137,26 @@ mod tests {
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() {
// Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every
// chunk so far after each marker, quadratic in their number.
let chunk = |n: usize| {
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
let mut out = vec![0xFF, 0xE1];
out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
out.extend_from_slice(&seg);
out.repeat(n)
};
let photo = phone_jpeg(6, 144);
let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat();
DECODES.with(|c| c.set(0));
assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo");
assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_)));
assert_eq!(DECODES.with(std::cell::Cell::get), 1, "only the ordinary photo reached the decoder");
}
}
+13 -2
View File
@@ -235,9 +235,17 @@ pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
}
#[cfg(test)]
thread_local! {
/// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops.
pub(crate) static WRAP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
/// does).
fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
#[cfg(test)]
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
for para in t.text.split('\n') {
@@ -780,7 +788,8 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
for (i, line) in lines(t).iter().enumerate() {
let all = lines(t);
for (i, line) in all.iter().enumerate() {
let w = t.family.width(line, t.size, t.bold, t.italic);
let x = match t.align {
Align::Left | Align::Justify => r[0],
@@ -790,7 +799,6 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
// Baseline: 0.8 em below the line top.
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
// Justified: word spacing makes every line but the last fill the box.
let all = lines(t);
let spaces = line.matches(' ').count();
let tw =
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
@@ -1042,6 +1050,9 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R
}
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
if obj.is_none() {
crate::check_on_top(doc, p)?;
}
for (k, v) in res.iter() {
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
+62 -11
View File
@@ -9,7 +9,7 @@
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
//! as it is, so no comment or field value is lost with nothing in its place.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
@@ -129,7 +129,16 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
None
}
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
/// success, so a page refused part way leaves the pages before it as they were.
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
let mut work = doc.clone();
let drawn = flatten_in(&mut work, pages, which)?;
*doc = work;
Ok(drawn)
}
fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
// `?` in an appearance can still be fixed (a field's value, a comment's text are stored as
@@ -295,6 +304,12 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
Ok(drawn)
}
#[cfg(test)]
thread_local! {
/// How many field-tree nodes `prune_fields` visited on this thread.
pub(crate) static KEEP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With
/// `on_page`, also the widgets (fields without kids) that are on no page.
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>) -> Result<(), EditError> {
@@ -303,23 +318,59 @@ fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&
let af_ref = af.as_ref();
let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default();
let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
// Returns whether the node survives.
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>, depth: usize) -> bool {
if widgets.contains(&r) || depth > 64 {
return depth > 64;
// Returns whether the node survives, and whether that answer depends on the depth cutoff.
// `known` holds each node's answer once it is visited, so a node listed twice is pruned once,
// and one still being visited (a cycle) survives. An answer the cutoff decided records the depth
// it was reached at, and a shallower path visits the node again: at most once per depth.
fn keep(
doc: &mut Document,
r: ObjRef,
widgets: &HashSet<ObjRef>,
on_page: Option<&HashSet<ObjRef>>,
known: &mut HashMap<ObjRef, (bool, Option<usize>)>,
depth: usize,
) -> (bool, bool) {
#[cfg(test)]
KEEP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
if widgets.contains(&r) {
return (false, false);
}
let Some(d) = doc.get(r).as_dict().cloned() else { return true };
if depth > 64 {
return (true, true);
}
match known.get(&r) {
Some(&(survives, None)) => return (survives, false),
Some(&(survives, Some(at))) if depth >= at => return (survives, true),
_ => {}
}
known.insert(r, (true, None));
let Some(d) = doc.get(r).as_dict().cloned() else { return (true, false) };
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else {
return on_page.is_none_or(|p| p.contains(&r));
let survives = on_page.is_none_or(|p| p.contains(&r));
known.insert(r, (survives, None));
return (survives, false);
};
let survivors: Vec<Object> =
kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect();
let mut cut = false;
let survivors: Vec<Object> = kids
.iter()
.filter(|k| {
k.as_ref().is_none_or(|kr| {
let (survives, kid_cut) = keep(doc, kr, widgets, on_page, known, depth + 1);
cut |= kid_cut;
survives
})
})
.cloned()
.collect();
if survivors.len() != kids.len() {
let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone())));
}
!survivors.is_empty()
known.insert(r, (!survivors.is_empty(), cut.then_some(depth)));
(!survivors.is_empty(), cut)
}
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect();
let mut known = HashMap::new();
let survivors: Vec<Object> =
fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0).0)).cloned().collect();
if survivors.len() == fields.len() && widgets.is_empty() {
return Ok(()); // only looked for fields on no page, and there were none
}
+42 -5
View File
@@ -492,6 +492,9 @@ enum Isolation {
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
/// marked-content sequences independently of graphics malformation, or zero when unknown.
Unknown { unclosed: usize, marked: usize },
/// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would
/// have to close (some may be hidden optional content) is unknown.
TooDeep,
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
@@ -591,7 +594,10 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
_ => {}
}
}
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
if !marked_known {
return Isolation::TooDeep;
}
if malformed || text_open || path_open || !stack.is_empty() {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
@@ -608,8 +614,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// 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.
/// Returns the new content stream.
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when
/// their number is unknown, nothing is placed on top. 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.
@@ -624,6 +630,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
// Content left inside an optional-content sequence it cannot close could be hidden.
Isolation::TooDeep => return Err(too_deep_on_top()),
}
};
let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
@@ -651,6 +659,26 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Ok(mark_ref)
}
fn too_deep_on_top() -> EditError {
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
}
/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes
/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was.
fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
let obj = doc.get(page.obj);
let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) {
None => return Ok(()),
Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]),
Some(Object::Array(a)) => a.clone(),
Some(other) => vec![other.clone()],
};
match isolation(doc, &list) {
Isolation::TooDeep => Err(too_deep_on_top()),
_ => Ok(()),
}
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
/// on it and is left alone.
@@ -670,6 +698,7 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s
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())?;
check_on_top(doc, &all[page])?;
add_resources(doc, &all[page], None, Some(&content))?;
let p = page_list(doc).swap_remove(page);
place_tagged(doc, &p, tag, content, false)
@@ -749,6 +778,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
.iter()
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
.collect::<Result<Vec<_>, _>>()?;
for &i in pages {
check_on_top(doc, &all[i])?;
}
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
@@ -837,6 +869,11 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
{
return Err(EditError::Invalid("invalid watermark settings".into()));
}
if !wm.behind {
for &i in pages {
check_on_top(doc, &all[i])?;
}
}
if replace {
remove_marks(doc, pages, MarkKind::Watermark)?;
}
@@ -941,7 +978,7 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
if before != kept.len() {
let wrappers = match isolation(doc, &kept) {
Isolation::Clean { wrappers, .. } => wrappers,
Isolation::Unknown { .. } => {
Isolation::Unknown { .. } | Isolation::TooDeep => {
let mut opens = Vec::new();
let mut pairs = Vec::new();
for (i, o) in kept.iter().enumerate() {
@@ -997,7 +1034,7 @@ mod linebreak;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
+39 -2
View File
@@ -96,7 +96,7 @@ fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
}
#[test]
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
fn depth_budget_bounds_emitted_closures_and_overflow_is_refused_on_top() {
let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
expected.extend_from_slice(WRAP_CLOSE);
@@ -114,9 +114,46 @@ fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
// Past the budget the depth is unknown, so nothing can close the page's (possibly hidden)
// optional content before a mark on top: placing one there is refused, and the page is left
// as it was. Behind the content stays possible.
for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
let mut over = maximum.clone();
over.extend_from_slice(suffix);
assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
let mut doc = fixture(&[&over]);
let page = page_list(&doc).remove(0);
let original = contents(&mut doc, &page).unwrap();
assert!(
matches!(stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()), Err(EditError::Invalid(_))),
"overflow cannot resume speculative depth tracking after EMC"
);
assert_eq!(contents(&mut doc, &page).unwrap(), original);
place_tagged(&mut doc, &page, MarkKind::Background.tag(), b"q Q\n".to_vec(), true).unwrap();
}
}
/// Marks refused on top of content nested past the depth budget leave the page as it was: old
/// marks are not removed and no resources are added before the refusal.
#[test]
fn a_mark_refused_on_top_changes_nothing() {
let mut over = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
over.extend_from_slice(b"/Artifact BMC\n");
let mut doc = fixture(&[&over]);
let behind = Watermark { text: "OLD".into(), behind: true, ..Watermark::default() };
add_watermark(&mut doc, &[0], &behind, false).unwrap();
let page = page_list(&doc).remove(0).obj;
let before = (*doc.get(page)).clone();
let on_top = Watermark { text: "NEW".into(), ..Watermark::default() };
assert!(matches!(add_watermark(&mut doc, &[0], &on_top, true), Err(EditError::Invalid(_))));
assert_eq!((*doc.get(page)).clone(), before, "the old watermark stays");
let hf = HeaderFooter {
text: [String::new(), "Top".into(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Times,
..HeaderFooter::default()
};
assert!(matches!(add_header_footer(&mut doc, &[0], &hf, true, &Context::default()), Err(EditError::Invalid(_))));
assert_eq!((*doc.get(page)).clone(), before, "no fonts are added");
let mut stamped = doc.clone();
assert!(matches!(stamp(&mut stamped, 0, "Probe", b"/PCTimes 9 Tf\n".to_vec()), Err(EditError::Invalid(_))));
assert_eq!((*stamped.get(page)).clone(), before, "no fonts are added");
}
+123
View File
@@ -6356,6 +6356,107 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
);
}
#[test]
fn added_text_is_wrapped_once_per_write() {
let mut doc = fixture();
let lines: Vec<String> = (0..40).map(|i| format!("line {i}")).collect();
let text = AddedText { rect: [72.0, 100.0, 300.0, 700.0], text: lines.join("\n"), size: 10.0, align: Align::Justify, ..AddedText::default() };
added::WRAP_CALLS.with(|c| c.set(0));
add_content(&mut doc, 0, &Content::Text(text)).unwrap();
let calls = added::WRAP_CALLS.with(std::cell::Cell::get);
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
}
/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not
/// in its paths, and a node listing itself is visited once.
#[test]
fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() {
let levels = 16;
let page = stream("", " ");
// 5: the flattened widget; 6…: the chain; then the hidden widget at its end, on the page.
let leaf = 6 + levels;
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R {} 0 R] >> >>", leaf + 2);
let annots = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R {leaf} 0 R] >>");
let mut objs: Vec<String> = vec![
fields,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
annots,
page,
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N {} 0 R >> >>", leaf + 1),
];
for i in 0..levels {
let next = 7 + i;
objs.push(format!("<< /T (n{i}) /Kids [{next} 0 R {next} 0 R] >>"));
}
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 >>".into());
objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into());
objs.push(format!("<< /T (loop) /Kids [{} 0 R] >>", leaf + 2));
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let mut doc = build(&objs);
flatten::KEEP_CALLS.with(|c| c.set(0));
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
let calls = flatten::KEEP_CALLS.with(std::cell::Cell::get);
assert!(calls <= 2 * (levels + 4), "{calls} field-tree visits");
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len();
assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay");
}
/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so
/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget
/// under it is taken out when every field is flattened.
#[test]
fn a_field_reached_again_by_a_shorter_path_is_pruned_there() {
let chain = 64;
let page = stream("", " ");
// 7…: the chain; then the shared field, its off-page widget, and the short path to it.
let shared = 7 + chain;
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2);
let mut objs: Vec<String> = vec![
fields,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(),
];
for i in 0..chain {
objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i));
}
objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1));
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into());
objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>"));
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0);
let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len();
assert_eq!(kids, 0, "the off-page widget is pruned along the short path");
}
/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text
/// would run past the extraction budget is refused, not built in memory.
#[test]
fn page_text_past_the_budget_is_refused() {
let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256)));
let page = |shown: &str| {
let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET"));
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
&content,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>",
&cmap,
])
};
let doc = page(&"A".repeat(100_000));
assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page");
let doc = page("AA");
assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads");
}
/// A one-page document from the page's /Contents and /Resources, built in code.
fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document {
let mut doc = Document::new_empty();
@@ -6448,3 +6549,25 @@ fn reading_spends_one_decode_budget_on_content_and_forms() {
});
assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated);
}
/// A page that can't take flattened content on top (marked content nested past the depth budget)
/// refuses the whole flatten: the pages before it keep their comments and content.
#[test]
fn a_flatten_refused_on_a_later_page_changes_nothing() {
let deep = stream("", &"/Artifact BMC\n".repeat(10_001));
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&stream("", " "),
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 7 0 R /Annots [8 0 R] >>",
&deep,
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 0 >>\nstream\n\nendstream",
]);
let first = pdfcraft_cos::ObjRef::new(3, 0);
let before = (*doc.get(first)).clone();
assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_))));
assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content");
}
+40 -12
View File
@@ -530,12 +530,13 @@ impl TextLine {
self.origin.baseline
}
/// Paint position of the line's last text-showing operator: `(content-stream index,
/// operator index in the page's joined stream)`. Lines paint in order of this tuple.
/// Paint position of the line's first operator: `(content-stream index, operator index in
/// the page's joined stream)`. A line joins runs painted at different times, so anything
/// painted after this position may lie on top of part of it.
/// (Lines from `text_lines` never come from form XObjects, so `stream` is always a
/// page content-stream index and `ops` are indices into the joined operator list.)
pub fn paint_pos(&self) -> (usize, usize) {
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
pub fn first_paint_pos(&self) -> (usize, usize) {
(self.stream, self.ops.iter().min().copied().unwrap_or(0))
}
fn rotation(&self) -> f64 {
@@ -902,8 +903,15 @@ struct Carry {
marked: Vec<Option<usize>>,
/// For each open marked-content sequence, its optional content group when it is `/OC`.
layers: Vec<Option<Vec<u8>>>,
/// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]).
text_left: usize,
}
/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is
/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes;
/// real pages hold far less (a whole novel is about 1 MB).
const MAX_PAGE_TEXT: usize = 16 << 20;
impl Carry {
fn new() -> Self {
let ts = Ts {
@@ -922,7 +930,7 @@ impl Carry {
vertical: None,
unreadable: None,
};
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
}
}
@@ -1134,7 +1142,13 @@ fn interpret(
let blank =
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
match shown {
Some(t) => text.push_str(&t),
Some(t) => {
carry.text_left = carry
.text_left
.checked_sub(t.len())
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
text.push_str(&t);
}
None => decodable = false,
}
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
@@ -1292,7 +1306,19 @@ fn interpret(
/// The lines of text on a page (0-based), in content order.
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
let p = page_dict(doc, page)?;
text_lines_in(doc, &page_dict(doc, page)?, true)
}
/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the
/// page draws, not what its marked-content metadata says the glyphs mean. For checks that must
/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going
/// through every page doesn't walk the page tree again for each.
pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result<Vec<TextLine>, EditError> {
text_lines_in(doc, page, false)
}
/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans.
fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result<Vec<TextLine>, EditError> {
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut lines: Vec<TextLine> = Vec::new();
@@ -1302,7 +1328,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?;
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?;
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1604,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
let mut forms = invoked.into_iter().peekable();
for s in shown {
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
enter(w, resources, &name, ts)?;
enter(w, resources, &name, ts, &mut carry.text_left)?;
// Text after a form starts a new line.
last = None;
}
@@ -1616,21 +1642,23 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
add_shown(&mut w.lines, &mut last, s, si);
}
for (_, name, ts) in forms {
enter(w, resources, &name, ts)?;
enter(w, resources, &name, ts, &mut carry.text_left)?;
}
Ok(())
}
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> {
/// `text_left`: the page's text budget, shared with the forms the page draws.
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts, text_left: &mut usize) -> Result<(), EditError> {
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() };
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
let stream = w.next_stream;
w.next_stream = w.next_stream.saturating_add(1);
if let Some(r) = form.obj {
w.path.push(r);
}
walk(w, &ops, &form.resources, &mut carry, &|_| stream)?;
*text_left = carry.text_left;
if form.obj.is_some() {
w.path.pop();
}
+40 -14
View File
@@ -1,8 +1,11 @@
//! Compare files: the text differences between two open documents, a report, and the
//! differences marked as comments in the newer one.
use std::collections::HashMap;
use std::sync::Arc;
use pdfcraft_cos::ObjRef;
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
// that draw more.
@@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
/// Most bytes of encoded pictures one Office export prepares, counting each image object once
/// (the Word and PowerPoint writers keep at most 512 MiB of them as well).
pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20;
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
pub fn colour(kind: Kind) -> crate::Rgb {
match kind {
@@ -163,23 +170,32 @@ pub struct OfficeExport {
/// The exported file.
pub bytes: Vec<u8>,
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
/// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
/// the text and images of those forms are missing from `bytes`.
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
/// those pictures, are missing from `bytes`.
pub truncated_pages: Vec<usize>,
}
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
self.export_pages_checked().0
self.export_pages_checked(true, MAX_EXPORT_IMAGE_BYTES).0
}
/// [`Self::export_pages`], and the pages whose forms were left partly unread
/// ([`OfficeExport::truncated_pages`]).
fn export_pages_checked(&self) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
/// [`Self::export_pages`], and the pages whose forms were left partly unread or whose
/// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without
/// `images` no picture is prepared.
pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
let mut truncated = Vec::new();
// Each image object is encoded once and shared by every place that draws it.
let mut encoded: HashMap<ObjRef, PreparedImage> = HashMap::new();
let mut image_bytes = image_budget;
let pages = self
.info
.pages
@@ -197,9 +213,7 @@ impl crate::Document {
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
if reading.truncated {
truncated.push(i);
}
let forms_unread = reading.truncated;
let blocks = reading
.blocks
.into_iter()
@@ -217,13 +231,21 @@ impl crate::Document {
}
})
.collect();
let images = pdfcraft_edit::reading_images(cos, i)
.unwrap_or_default()
let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() };
let mut over_budget = false;
let images = placed
.iter()
.filter_map(|im| {
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
let object = im.object?;
let prepared = encoded.entry(object).or_insert_with(|| {
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
});
over_budget |= matches!(prepared, Err(true));
let (ext, bytes) = prepared.clone().ok()?;
Some(pdfcraft_export::Image {
ext: if ext == "jpg" { "jpg" } else { "png" },
ext,
bytes,
rect: visible_rect(im.rect),
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
@@ -231,6 +253,9 @@ impl crate::Document {
})
})
.collect();
if forms_unread || over_budget {
truncated.push(i);
}
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
@@ -247,7 +272,8 @@ impl crate::Document {
/// [`Self::export_office`], and the pages whose text it had to leave out
/// ([`OfficeExport::truncated_pages`]).
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
let (pages, truncated_pages) = self.export_pages_checked();
// RTF leaves pictures out.
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
+36 -5
View File
@@ -289,6 +289,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
.collect()
}
#[cfg(test)]
thread_local! {
/// Characters read to tell underscore runs from other words.
static UNDERSCORE_CHARS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
@@ -296,6 +302,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
let mut last_line = u32::MAX;
// A space glyph ends the word before it.
let mut gap = false;
// Whether the last word is an underscore run (a form's blank), which other glyphs don't join.
let mut last_under = false;
for (i, g) in text.glyphs.iter().enumerate() {
let line = text.line_of.get(i).copied().unwrap_or(0);
if g.text.trim().is_empty() {
@@ -303,10 +311,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
continue;
}
let under = g.text.chars().all(|c| c == '_');
let breaks = match words.last() {
None => true,
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under,
};
#[cfg(test)]
UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len()));
let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under;
gap = false;
last_line = line;
match words.last_mut() {
@@ -317,7 +324,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
r[2] = r[2].max(g.rect[2]);
r[3] = r[3].max(g.rect[3]);
}
_ => words.push((g.text.clone(), g.rect)),
_ => {
words.push((g.text.clone(), g.rect));
last_under = under;
}
}
}
words
@@ -409,6 +419,27 @@ impl Session {
mod review_tests {
use super::*;
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
/// the word so far without reading the word again.
#[test]
fn a_long_underscore_run_is_read_in_one_pass() {
let n = 10_000;
let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph {
text: t.into(),
rect: [i as f32, 0.0, i as f32 + 1.0, 10.0],
quad: [[0.0; 2]; 4],
direction: [1.0, 0.0],
};
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
glyphs.push(glyph(n, "x"));
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs };
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
let words = words_with(&text, |x, y| [x, y]);
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get);
assert!(read <= 4 * n, "{read} characters read for {n} glyphs");
}
#[test]
fn duplicate_field_names_diff_against_first_original() {
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
+47 -1
View File
@@ -6695,7 +6695,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() {
assert!(jpeg.starts_with(&[0xFF, 0xD8]));
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}");
assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})");
assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})");
}
}
@@ -7323,3 +7323,49 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() {
assert!(refused.is_err(), "the validate script refused it, once");
assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name"));
}
/// A page whose forms go past the reading limit and whose picture goes past the image budget is
/// listed once among the truncated pages.
#[test]
fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
let depth = compare::MAX_FORM_DEPTH + 1;
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
String::new(),
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\x20\x40\x7f\nendstream".into(),
"<< /Length 34 >>\nstream\nq 10 0 0 10 0 0 cm /Im Do Q /D1 Do\nendstream".into(),
];
// Forms D1..D{depth}, objects 6 onwards, each drawing the next.
let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", 5 + i)).collect();
let resources = format!("<< /XObject << /Im 4 0 R {xobjects}>> >>");
for i in 1..=depth {
let body = if i == depth { "q Q".to_string() } else { format!("/D{} Do", i + 1) };
objs.push(format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
body.len()
));
}
objs[2] = format!("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources {resources} >>");
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("both.pdf", None, Arc::new(bytes), None).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.export_pages_checked(false, 0).1, [0], "the forms alone truncate the page");
let (exported, _) = doc.export_pages_checked(true, 1 << 20);
assert_eq!(exported[0].images.len(), 1, "the picture exports within a sufficient budget");
let (pages, truncated) = doc.export_pages_checked(true, 0);
assert!(pages[0].images.is_empty(), "the picture is past the budget");
assert_eq!(truncated, [0]);
}
+1
View File
@@ -8,5 +8,6 @@ mod document_fonts;
mod form_rotation;
mod marked_content_isolation;
mod ocr_edit;
mod office_export_images;
mod private_image_resize;
mod private_text_glyphs;
@@ -0,0 +1,43 @@
//! Office export of pages that draw one image many times.
use std::sync::Arc;
use pdfcraft_engine::Session;
fn pdf(objects: &[String]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
/// One image drawn many times is prepared once: every placement shares its encoded bytes.
#[test]
fn an_image_drawn_many_times_is_encoded_once() {
let placements = 500;
let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements);
let bytes = pdf(&[
"<< /Type /Catalog /Pages 2 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 /Resources << /XObject << /Im 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(),
]);
let mut s = Session::new();
let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap();
let pages = s.get(id).unwrap().export_pages();
let images = &pages[0].images;
assert_eq!(images.len(), placements);
let first = images[0].bytes.as_ptr();
assert!(!images[0].bytes.is_empty());
assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image");
}
+5 -4
View File
@@ -37,7 +37,8 @@ pub struct Block {
pub struct Image {
/// "png" or "jpg".
pub ext: &'static str,
pub bytes: Vec<u8>,
/// Shared: a picture drawn in many places is held once.
pub bytes: std::sync::Arc<[u8]>,
pub rect: [f64; 4],
/// Width and height before rotation, in points.
pub size: [f64; 2],
@@ -997,7 +998,7 @@ mod tests {
],
images: vec![Image {
ext: "png",
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
rect: [72.0, 450.0, 272.0, 600.0],
size: [200.0, 150.0],
rotation: 0.0,
@@ -1439,7 +1440,7 @@ mod tests {
/// file in the package, with a content type; JPEG pictures too, inline or placed.
#[test]
fn word_pictures_resolve_to_media_in_the_package() {
let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() };
let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
for placed in [false, true] {
let mut p = page();
p.images = vec![jpeg.clone()];
@@ -1466,7 +1467,7 @@ mod tests {
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
let mut p = page();
let one = p.images[0].clone();
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
let zip = docx(&[p], "T");
let rels = part(&zip, "word/_rels/document.xml.rels");
+8 -2
View File
@@ -391,7 +391,7 @@ mod tests {
],
images: vec![Image {
ext: "png",
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
rect: [72.0, 150.0, 300.0, 300.0],
size: [228.0, 150.0],
rotation: 0.0,
@@ -619,7 +619,13 @@ mod tests {
blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64));
}
let images = (0..40)
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
.map(|k| Image {
ext: "png",
bytes: [0; 4].as_slice().into(),
rect: [0.0, -1e300, 1e-300, 1e300 - k as f64],
size: [1.0, 1.0],
rotation: 0.0,
})
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
let bytes = pptx(&[p], "Hostile");
+10 -1
View File
@@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>
(run, extra)
}
#[cfg(test)]
thread_local! {
/// Read names compared while merging a field's reads.
pub(crate) static NAMES_COMPARED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's
/// reads and issues, and Fix order's order.
pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
@@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
.map(|(f, _)| {
let mut names = f.actions.calculate.reads();
if let Some(js) = &f.actions.scripts.calculate {
let mut known: HashSet<String> = names.iter().cloned().collect();
for name in crate::af::calculation_reads(js) {
if !names.contains(&name) {
#[cfg(test)]
NAMES_COMPARED.with(|n| n.set(n.get() + 1));
if known.insert(name.clone()) {
names.push(name);
}
}
+16 -11
View File
@@ -594,7 +594,7 @@ fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() };
let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() };
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
@@ -627,7 +627,7 @@ fn adopt_page_fields(
let Some(annots) = annots.as_array() else { continue };
for e in annots {
let Some(r) = e.as_ref() else { continue };
if seen.fields.contains(&r) {
if seen.fields.contains(&r) || seen.widgets.contains(&r) {
continue;
}
let obj = doc.get(r);
@@ -768,15 +768,17 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
Ok(())
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
/// What one reading of the form's fields has used: the field dictionaries read, and the script
/// budget their actions share.
/// What one reading of the form's fields has used: the field and widget dictionaries read, and
/// the script budget their actions share.
struct Seen {
fields: std::collections::HashSet<ObjRef>,
/// A widget belongs to the first field that lists it, however often it is listed.
widgets: std::collections::HashSet<ObjRef>,
scripts: DecodeBudget,
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
fn walk(
doc: &Document,
r: ObjRef,
@@ -828,11 +830,8 @@ fn walk(
}
return;
}
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
.then(|| button_action(doc, d, &widget_refs, &mut seen.scripts))
.flatten();
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
// field listing them then reads.
let ff = inh.ff.unwrap_or(0);
let kind = match inh.ft.as_deref() {
Some(b"Tx") => FieldKind::Text,
@@ -844,6 +843,12 @@ fn walk(
Some(b"Sig") => FieldKind::Signature,
_ => return,
};
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
.then(|| button_action(doc, d, &widget_refs, &mut seen.scripts))
.flatten();
let widgets = widget_refs
.into_iter()
.filter_map(|w| {
+52
View File
@@ -2982,6 +2982,23 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
assert_eq!(page_refs(&doc), [page]);
}
/// A Calculate script naming thousands of fields is merged with the field's other reads in one
/// pass, not by searching the names so far for each one.
#[test]
fn a_script_reading_many_fields_merges_its_reads_in_one_pass() {
use crate::calc_order::NAMES_COMPARED;
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap();
let n = 2_000;
let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::<String>() + "0";
set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap();
NAMES_COMPARED.with(|c| c.set(0));
let order = calculation_order(&doc, &fields(&doc));
assert_eq!(order.fields[0].reads.len(), n);
let compared = NAMES_COMPARED.with(std::cell::Cell::get);
assert!(compared <= 4 * n, "{compared} names compared for {n} reads");
}
/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream.
fn repeated_script_references() -> Document {
use pdfcraft_cos::Stream;
@@ -3021,3 +3038,38 @@ fn repeated_script_references_share_one_decode_budget() {
let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum();
assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts");
}
/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share,
/// is read once: the first field to list it owns it.
#[test]
fn a_widget_listed_repeatedly_is_read_once() {
let kids = "7 0 R ".repeat(500);
let doc = document(&[
"<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(),
"<< /FT /Tx /T (a) /Kids 6 0 R >>".into(),
"<< /FT /Tx /T (b) /Kids 6 0 R >>".into(),
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [7 0 R] >>".into(),
format!("[{kids}]"),
"<< /Type /Annot /Subtype /Widget /Parent 2 0 R /Rect [0 0 10 10] >>".into(),
]);
let all = fields(&doc);
let widgets: Vec<usize> = all.iter().map(|f| f.widgets.len()).collect();
assert_eq!(widgets, [1, 0], "{:?}", all.iter().map(|f| &f.name).collect::<Vec<_>>());
}
/// A field of a kind that isn't read doesn't claim the widget it shares with a field that is.
#[test]
fn a_skipped_field_leaves_its_widget_to_the_next() {
let doc = document(&[
"<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(),
"<< /FT /Zz /T (odd) /Kids [6 0 R] >>".into(),
"<< /FT /Tx /T (b) /Kids [6 0 R] >>".into(),
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [6 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 3 0 R /Rect [0 0 10 10] >>".into(),
]);
let all = fields(&doc);
let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect();
assert_eq!(widgets, [("b", 1)]);
}
+17 -3
View File
@@ -522,6 +522,15 @@ pub fn list(doc: &Document) -> Listing {
}
out
}
/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads.
const MAX_VIEWPORTS: usize = 1024;
#[cfg(test)]
thread_local! {
/// Numbers read from viewport `/BBox` arrays.
pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9).
/// Outside every viewport the page measures true size in inches.
pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
@@ -535,12 +544,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64,
if let Some(o) = p.dict.get(b"VP") {
let o = doc.resolve(o);
let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?;
for viewport in arr.iter().rev() {
// The last viewports win; a page holds at most MAX_VIEWPORTS of its own.
for viewport in arr.iter().rev().take(MAX_VIEWPORTS) {
let o = doc.resolve(viewport);
let Some(d) = o.as_dict() else { continue };
if let Some(bbox) = d.get(b"BBox") {
let o = doc.resolve(bbox);
let b: Option<Vec<f64>> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect());
let b: Option<Vec<f64>> = o.as_array().filter(|a| a.len() == 4).and_then(|a| {
#[cfg(test)]
VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len()));
a.iter().map(|v| doc.resolve(v).as_f64()).collect()
});
if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite()))
&& at[0] >= b[0].min(b[2])
&& at[0] <= b[0].max(b[2])
@@ -566,7 +580,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s
Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?,
None => Vec::new(),
};
if list.len() >= 1024 {
if list.len() >= MAX_VIEWPORTS {
return Err(invalid("page has too many measurement viewports"));
}
let mut d = Dict::new();
+13 -1
View File
@@ -256,6 +256,8 @@ impl Walker<'_> {
let mut current = None;
let mut closures = Vec::new();
let mut path = Geometry::default();
// Read on the first `Do` and kept: resolving a direct dictionary copies it.
let mut xobjects = None;
let point = |m: Matrix, x: f64, y: f64| {
let (x, y) = m.apply(x, y);
[x, y]
@@ -390,7 +392,11 @@ impl Walker<'_> {
}
"Do" => {
if let Some(name) = op.name(0) {
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
let xobjects = xobjects.get_or_insert_with(|| {
#[cfg(test)]
XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1));
resources.get(b"XObject").map(|o| self.doc.resolve(o))
});
if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) {
let r = o.as_ref();
if r.is_some_and(|r| self.seen.contains(&r)) {
@@ -426,6 +432,12 @@ impl Walker<'_> {
Ok(())
}
}
#[cfg(test)]
thread_local! {
/// Times a content stream's `/XObject` resources were read.
pub(crate) static XOBJECT_DICT_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub fn geometry(doc: &Document, page_index: usize) -> Result<Geometry> {
let p = crate::page(doc, page_index)?;
let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o));
+25
View File
@@ -403,6 +403,31 @@ fn a_scale_ratio_converts_its_page_side_to_points() {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct
/// dictionary) for every `Do`.
#[test]
fn xobject_resources_are_read_once_per_stream() {
let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]);
snap::XOBJECT_DICT_READS.with(|c| c.set(0));
let paths = snap::geometry(&d, 0).unwrap();
assert!(paths.segments.is_empty());
let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get);
assert!(reads <= 1, "{reads} reads of the page's XObject resources");
}
/// Finding the scale under a point reads only well-formed viewport boxes, and only as many
/// viewports as a page can be given, however many a file lists or however long their boxes.
#[test]
fn scale_lookup_reads_viewports_within_bounds() {
let vp = vec!["5 0 R"; 5_000].join(" ");
let long_box = vec!["0"; 2_000].join(" ");
let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]);
VIEWPORT_NUMBERS_READ.with(|c| c.set(0));
let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap();
assert_eq!(scale.unit, "in");
let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get);
assert!(read <= 4 * 1024, "{read} viewport numbers read");
}
#[test]
fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
let mut d = fixture("", "", &[]);
+47 -23
View File
@@ -154,8 +154,8 @@ impl<'a> Copier<'a> {
}
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
for &a in &self.annots {
fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
for &a in &std::mem::take(&mut self.annots) {
let src_dict = dst.get(a).as_dict().cloned();
let Some(d) = src_dict else { continue };
let mut d = d;
@@ -188,10 +188,13 @@ impl<'a> Copier<'a> {
}
}
} else {
// Other actions (URI, Launch, JavaScript…) carry no page references.
let mut c = Copier::new(self.src, self.map.clone(), HashMap::new());
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?;
d.set(b"A".to_vec(), copied);
// Other actions (URI, Launch, JavaScript…) carry no page references. The
// copier lends its map out and takes it back with the new copies in it,
// so actions sharing an object share its one copy.
let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new());
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
self.map = c.map;
d.set(b"A".to_vec(), copied?);
}
}
}
@@ -541,13 +544,16 @@ fn named_dest(src: &Document, name: &[u8]) -> Option<Object> {
}
let names = src.resolve(catalog.get(b"Names")?);
let tree = names.as_dict()?.get(b"Dests")?.clone();
name_tree_lookup(src, &tree, name, 0)
name_tree_lookup(src, &tree, name, 0, &mut HashSet::new())
}
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option<Object> {
if depth > 32 {
/// Name trees are read once per node: a node listed twice (or a cycle) is skipped.
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8, seen: &mut HashSet<ObjRef>) -> Option<Object> {
if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) {
return None;
}
#[cfg(test)]
crate::work(1);
let node = src.resolve(node);
let d = node.as_dict()?;
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
@@ -560,14 +566,16 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt
}
}
let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1))
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1, seen))
}
/// All (key, value) pairs of a name tree, in order.
fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec<u8>, Object)>) {
if depth > 32 {
/// All (key, value) pairs of a name tree, in order, each node read once.
fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet<ObjRef>, out: &mut Vec<(Vec<u8>, Object)>) {
if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) {
return;
}
#[cfg(test)]
crate::work(1);
let node = src.resolve(node);
let Some(d) = node.as_dict() else { return };
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
@@ -580,7 +588,7 @@ fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Ve
}
}
for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() {
name_tree_entries(src, &k, depth + 1, out);
name_tree_entries(src, &k, depth + 1, seen, out);
}
}
@@ -680,24 +688,32 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec<Vec<(String, String
/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe.
fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet<String>) -> Vec<(ObjRef, String, String, String)> {
let mut planned = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut walk = RenameWalk::default();
for &root in roots {
plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned);
plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned);
}
planned
}
/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name
/// the suffix to try next.
#[derive(Default)]
struct RenameWalk {
seen: HashSet<ObjRef>,
next: HashMap<String, u32>,
}
/// One node of [`plan_renames`].
fn plan_renames_under(
doc: &Document,
node: ObjRef,
prefix: &str,
taken: &mut std::collections::HashSet<String>,
seen: &mut std::collections::HashSet<ObjRef>,
walk: &mut RenameWalk,
depth: u8,
planned: &mut Vec<(ObjRef, String, String, String)>,
) {
if depth > 32 || !seen.insert(node) {
if depth > 32 || !walk.seen.insert(node) {
return;
}
let Some(d) = doc.get(node).as_dict().cloned() else { return };
@@ -705,14 +721,19 @@ fn plan_renames_under(
if !name.is_empty() {
let fqn = qualified(prefix, &name);
if taken.contains(&fqn) {
let mut n = 1u32;
// `taken` only grows, so every suffix below the last one handed out for this name is
// still taken: resume there instead of at 1.
let mut n = walk.next.get(&fqn).copied().unwrap_or(1);
let mut variant;
while {
#[cfg(test)]
crate::work(1);
variant = format!("{name}-{n}");
taken.contains(&qualified(prefix, &variant))
} {
n += 1;
n = n.saturating_add(1);
}
walk.next.insert(fqn.clone(), n.saturating_add(1));
let new_fqn = qualified(prefix, &variant);
planned.push((node, variant.clone(), fqn, new_fqn.clone()));
taken.insert(new_fqn);
@@ -724,7 +745,7 @@ fn plan_renames_under(
let prefix = qualified_optional(prefix, &name);
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for kid in kids.iter().filter_map(|k| k.as_ref()) {
plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned);
plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned);
}
}
@@ -826,8 +847,11 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize
_ => (None, Dict::new()),
};
let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
let mut listed: HashSet<ObjRef> = list.iter().filter_map(Object::as_ref).collect();
for f in fields {
if !list.contains(&Object::Ref(*f)) {
#[cfg(test)]
crate::work(1);
if listed.insert(*f) {
list.push(Object::Ref(*f));
}
}
@@ -1144,7 +1168,7 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8
return Ok(());
};
let mut entries = Vec::new();
name_tree_entries(src, &tree, 0, &mut entries);
name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries);
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
for (k, v) in entries {
let v = copier.copy_value(dst, &v, None)?;
+19
View File
@@ -739,5 +739,24 @@ pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(),
if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) }
}
#[cfg(test)]
thread_local! {
/// Steps taken on this thread by loops whose cost follows the input's shape, so a test can
/// bound the work a hostile document causes without timing it.
static WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of input-shaped work (see [`WORK`]).
#[cfg(test)]
fn work(n: usize) {
WORK.with(|w| w.set(w.get().saturating_add(n)));
}
/// The steps counted on this thread since the last call.
#[cfg(test)]
fn take_work() -> usize {
WORK.with(|w| w.replace(0))
}
#[cfg(test)]
mod tests;
+10 -1
View File
@@ -29,7 +29,7 @@ enum XKey {
}
/// Who owns the resources currently being walked, for the key drawn names are recorded under.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Owner {
/// An indirect resources object (a page's, or a form XObject's own).
Resources(ObjRef),
@@ -50,6 +50,9 @@ struct Pruner<'a> {
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
glyph_fonts: HashSet<ObjRef>,
/// Forms already analysed, with the owner of the resources they drew against: drawing one
/// again records nothing new, and re-reading it on every `Do` is exponential in the nesting.
forms_seen: HashSet<(ObjRef, Owner)>,
}
impl Pruner<'_> {
@@ -211,6 +214,11 @@ impl Pruner<'_> {
// A form without its own resources draws against the ones it was invoked with.
None => (resources.clone(), owner),
};
if !self.forms_seen.insert((r, inner_owner)) {
continue;
}
#[cfg(test)]
crate::work(1);
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
@@ -307,6 +315,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
forms_seen: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
+114
View File
@@ -3133,6 +3133,120 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() {
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);
}
/// A one-page document whose page carries `annots` (object bodies 5…), with `extra` bodies after them.
fn page_with_annots(annots: &[String], extra: &[String], catalog_extra: &str) -> Document {
let n = annots.len();
let refs: String = (0..n).map(|i| format!("{} 0 R ", i + 5)).collect();
let mut b: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{refs}] >>"),
body("P1"),
];
b.extend(annots.iter().cloned());
b.extend(extra.iter().cloned());
open(build(&b, "/Root 1 0 R"))
}
#[test]
fn registering_many_imported_fields_is_linear() {
let n = 2_000;
let widgets: Vec<String> = (0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [0 0 10 10] >>")).collect();
let src = page_with_annots(&widgets, &[], "");
take_work();
let out = extract_pages(&src, &[0]).unwrap();
let work = take_work();
assert!(work <= 4 * n, "{work} steps registering {n} fields");
let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
assert_eq!(form.get(b"Fields").and_then(|f| f.as_array()).map(Vec::len), Some(n));
}
#[test]
fn renaming_many_same_named_fields_resumes_the_suffix_search() {
let n = 2_000;
let widgets: Vec<String> = (0..n).map(|_| "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [0 0 10 10] >>".to_string()).collect();
let src = page_with_annots(&widgets, &[], "");
take_work();
let renames = combine_field_conflicts(&[&src]).remove(0);
let work = take_work();
assert!(work <= 4 * n, "{work} suffix attempts for {n} fields");
// Still the first free variant, in order.
assert_eq!(renames.len(), n - 1);
assert_eq!(renames.first(), Some(&("x".to_string(), "x-1".to_string())));
assert_eq!(renames.last(), Some(&("x".to_string(), format!("x-{}", n - 1))));
}
#[test]
fn annotation_actions_share_the_objects_they_copy() {
// Fifty links whose URI actions all point at one string object.
let n = 50;
let links: Vec<String> =
(0..n).map(|_| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI {} 0 R >> >>", n + 5)).collect();
let src = page_with_annots(&links, &["(https://example.com/shared)".into()], "");
let out = extract_pages(&src, &[0]).unwrap();
let copies = out
.object_numbers()
.into_iter()
.filter(|&o| out.get(ObjRef::new(o, out.generation(o))).as_string().is_some_and(|s| s.bytes == b"https://example.com/shared"))
.count();
assert_eq!(copies, 1, "the shared URI is copied once, not once per action");
}
#[test]
fn name_trees_with_shared_kids_are_walked_once_per_node() {
// Each level lists the next one twice: 2^depth paths through `depth` nodes.
let depth = 10;
let first = 6; // after the catalog, pages, page, content and the link
let mut extra = Vec::new();
for tree in 0..2 {
let base = first + tree * (depth + 1);
for level in 0..depth {
let next = base + level + 1;
extra.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>"));
}
extra.push(if tree == 0 {
"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".into()
} else {
"<< /Names [(other) [3 0 R /Fit]] >>".into()
});
}
let link = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (missing) >>".to_string();
let catalog_extra = format!("/Names << /EmbeddedFiles {first} 0 R /Dests {} 0 R >>", first + depth + 1);
let src = page_with_annots(&[link], &extra, &catalog_extra);
take_work();
let out = combine(&[("a", &src)]).unwrap();
let work = take_work();
assert!(work <= 8 * (depth + 1), "{work} name-tree nodes visited");
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
assert_eq!(tree.get(b"Names").and_then(|n| n.as_array()).map(Vec::len), Some(2), "one attachment, listed once");
}
#[test]
fn a_form_drawing_itself_repeatedly_is_analysed_once() {
let form = "/F Do /F Do /F Do /F Do";
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /F 5 0 R /Unused 6 0 R >> >> >>".into(),
"<< /Length 5 >>\nstream\n/F Do\nendstream".into(),
format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream",
form.len()
),
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream".into(),
];
let src = open(build(&b, "/Root 1 0 R"));
take_work();
let out = extract_pages(&src, &[0]).unwrap();
let work = take_work();
assert!(work <= 4, "{work} form analyses");
let page = out.get(pages(&out).unwrap()[0].obj).as_dict().cloned().unwrap();
let res = out.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let x = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
assert!(x.contains(b"F") && !x.contains(b"Unused"), "pruning still keeps what is drawn and drops the rest");
}
/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk).
fn repeated_contents() -> Document {
use pdfcraft_cos::{Dict, Stream};
+28 -1
View File
@@ -172,11 +172,24 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> {
/// start and end of its value, and its quote character. Only matches inside a start tag.
fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> {
let mut from = 0;
// The last `<` and `>` before the match, found by one pass over the packet rather than by
// searching back from every match.
let (mut scanned, mut last_open, mut last_close) = (0, None, None);
while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) {
from = i + name.len();
let before = xmp.get(..i)?;
let gap = xmp.get(scanned..i)?;
#[cfg(test)]
crate::work(gap.len());
if let Some(at) = gap.rfind('<') {
last_open = Some(scanned + at);
}
if let Some(at) = gap.rfind('>') {
last_close = Some(scanned + at);
}
scanned = i;
// Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`).
if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() {
if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() {
continue;
}
let rest = xmp.get(from..)?;
@@ -269,4 +282,18 @@ mod tests {
}
}
}
#[test]
fn attribute_search_scans_the_packet_once() {
// Thousands of attribute-like words in one text node: none is inside a tag.
let packet = format!("<x:xmpmeta><rdf:RDF><rdf:Description>{}</rdf:Description></rdf:RDF></x:xmpmeta>", " rdf:about".repeat(5_000));
crate::take_work();
assert_eq!(attribute_span(&packet, "rdf:about"), None);
let work = crate::take_work();
assert!(work <= 2 * packet.len(), "{work} bytes scanned in a {}-byte packet", packet.len());
// A real attribute after them is still found.
let packet = packet.replace("</rdf:Description>", "</rdf:Description><rdf:Description rdf:about='u'/>");
let (_, s, e, q) = attribute_span(&packet, "rdf:about").unwrap();
assert_eq!((packet.get(s..e), q), (Some("u"), '\''));
}
}
+106 -27
View File
@@ -342,8 +342,17 @@ fn is_content(d: &Dict) -> bool {
/// The device colour a content stream sets. It is read operator by operator, so the bytes of a
/// string, a comment or an inline image are never taken for an operator, and an operator right
/// before a delimiter (`1 k/GS0 gs`) is still found.
fn content_colour(data: &[u8], used: &mut DeviceColour) {
for op in pdfcraft_content::parse(data).ops {
///
/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never
/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set.
/// Returns how many operators it read.
fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize {
let mut read = 0;
for op in pdfcraft_content::ops(data) {
if used.gray && used.rgb && used.cmyk {
break;
}
read += 1;
match op.op.as_slice() {
b"g" | b"G" => used.gray = true,
b"rg" | b"RG" => used.rgb = true,
@@ -369,6 +378,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) {
_ => {}
}
}
read
}
/// Whether any content or object uses a device colour space.
@@ -403,12 +413,18 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
&& (referenced.contains(r) || is_content(&s.dict))
&& let Ok(data) = s.decoded()
{
content_colour(&data, &mut used);
let _ = content_colour(&data, &mut used);
}
}
used
}
#[cfg(test)]
thread_local! {
/// Page `/Annots` entries read while matching annotations to their pages.
static ANNOT_ENTRIES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Check `doc` against `level`.
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
let mut out = Vec::new();
@@ -479,17 +495,20 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
// Pages: annotations and page actions.
let pages = pdfcraft_model::pages(doc);
let page_of = |r: ObjRef| {
pages.iter().position(|p| {
p.dict
.get(b"Annots")
.map(|a| doc.resolve(a))
.and_then(|a| a.as_array().cloned())
.unwrap_or_default()
.iter()
.any(|x| x.as_ref() == Some(r))
})
};
// Each annotation's page: the first page that lists it.
let mut annot_pages: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
for (i, p) in pages.iter().enumerate() {
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
&& let Some(a) = a.as_array()
{
#[cfg(test)]
ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len()));
for r in a.iter().filter_map(Object::as_ref) {
annot_pages.entry(r).or_insert(i);
}
}
}
let page_of = |r: ObjRef| annot_pages.get(&r).copied();
for (i, p) in pages.iter().enumerate() {
if p.dict.get(b"AA").is_some() {
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
@@ -499,12 +518,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
let Some(d) = dict_of(o) else { continue };
let ty = d.name(b"Type");
let sub = d.name(b"Subtype");
// Actions anywhere (structure elements also have /S: their role).
if let Some(s) = d.name(b"S")
&& FORBIDDEN_ACTIONS.contains(&s)
&& is_action(d)
{
issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true);
// Actions anywhere (structure elements also have /S: their role), with the actions they
// chain to and those written directly in an /A or /OpenAction.
let own = (d.get(b"S").is_some() && is_action(d)).then_some(d);
let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict()));
for a in [own].into_iter().chain(direct).flatten() {
if let Some(s) = chain_forbidden(doc, a) {
issue("6.5.1", "A {} action isn't allowed", Some(s), None, true);
}
}
// Annotations.
if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) {
@@ -636,10 +657,62 @@ fn is_action(d: &Dict) -> bool {
}
}
/// Whether `o` is (or refers to) a forbidden action, or to nothing.
/// How many actions one action and the `/Next` actions written inside it may hold before the
/// whole chain counts as forbidden, since it can't be checked.
const MAX_ACTION_CHAIN: usize = 1024;
/// The first forbidden action type in the action `a` or in the actions written directly in its
/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them, which may be an object of its
/// own). An action `/Next` refers to is an object of its own, checked and removed as one.
fn chain_forbidden(doc: &Document, a: &Dict) -> Option<String> {
let mut read = 0;
let mut seen = std::collections::HashSet::new();
// Actions written in an indirect `/Next` array, copied out of it.
let mut held: Vec<Dict> = Vec::new();
if let Some(s) = walk_chain(doc, a, &mut read, &mut seen, &mut held) {
return Some(s);
}
while let Some(d) = held.pop() {
if let Some(s) = walk_chain(doc, &d, &mut read, &mut seen, &mut held) {
return Some(s);
}
}
None
}
/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. The
/// actions of each indirect `/Next` array (each read once) go to `held`.
fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collections::HashSet<ObjRef>, held: &mut Vec<Dict>) -> Option<String> {
let mut stack = vec![a];
while let Some(d) = stack.pop() {
*read += 1;
if *read > MAX_ACTION_CHAIN {
return Some("too deeply chained".into());
}
if let Some(s) = d.name(b"S")
&& FORBIDDEN_ACTIONS.contains(&s)
{
return Some(String::from_utf8_lossy(s).into_owned());
}
match d.get(b"Next") {
Some(Object::Dict(next)) => stack.push(next),
Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)),
Some(Object::Ref(r)) if seen.insert(*r) => {
if let Object::Array(list) = &*doc.get(*r) {
// More than the chain limit can't be read anyway.
held.extend(list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).cloned());
}
}
_ => {}
}
}
None
}
/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing.
fn forbidden(doc: &Document, o: &Object) -> bool {
match &*doc.resolve(o) {
Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)),
Object::Dict(a) => chain_forbidden(doc, a).is_some(),
Object::Null => true,
_ => false,
}
@@ -785,14 +858,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
files += 1;
}
}
// Forbidden actions left as objects of their own (nothing refers to them any more).
// Forbidden actions left as objects of their own, or chained to by one, and those written
// directly in an /A elsewhere (an outline item's, say).
for (r, o) in objects(doc) {
if let Object::Dict(d) = &*o
&& d.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s))
&& is_action(d)
{
let Object::Dict(d) = &*o else { continue };
if d.get(b"S").is_some() && is_action(d) && chain_forbidden(doc, d).is_some() {
doc.set(r, Object::Null);
actions += 1;
} else if let Some(Object::Dict(a)) = d.get(b"A")
&& chain_forbidden(doc, a).is_some()
{
let mut nd = d.clone();
nd.remove(b"A");
doc.set(r, Object::Dict(nd));
actions += 1;
}
}
for (n, what) in [
+59
View File
@@ -418,6 +418,65 @@ fn oversized_xmp_is_refused_while_decoding() {
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
}
/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK
/// are all set, the rest of the stream is not read.
#[test]
fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() {
let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec();
data.extend(b"n\n".repeat(100_000));
let mut used = DeviceColour::default();
let read = content_colour(&data, &mut used);
assert!(used.gray && used.rgb && used.cmyk);
assert_eq!(read, 3);
}
/// A forbidden action chained by `/Next` behind an allowed one, written directly inside its
/// parent, is reported and removed like any other.
#[test]
fn forbidden_actions_chained_behind_allowed_ones_are_found_and_removed() {
let js = "<< /S /JavaScript /JS (app.alert(1)) >>";
let page = "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>";
let pages = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>";
let link = |a: &str| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 /A {a} >>");
let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>");
let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>");
let cases = [
build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""),
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""),
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""),
// `/Next` refers to an array that holds the action directly.
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""),
];
for (i, mut doc) in cases.into_iter().enumerate() {
let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed");
assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b));
let r = convert(&mut doc, Level::A2b).unwrap();
assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining);
let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap();
assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes));
}
}
/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching
/// every page's list again for each annotation.
#[test]
fn annotation_pages_are_found_in_one_pass() {
let n = 2_000;
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" "));
let mut objs =
vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page];
objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string()));
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let doc = build(&refs, "");
ANNOT_ENTRIES_READ.with(|c| c.set(0));
let issues = verify(&doc, Level::A2b);
assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}");
let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get);
assert!(read <= 2 * n, "{read} entries read for {n} annotations");
}
#[test]
fn an_output_intent_profile_header_is_read_within_a_size_cap() {
let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]";
+1 -1
View File
@@ -362,7 +362,7 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth:
let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default();
let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone());
if !budget.enter_form() {
return;
continue;
}
if let Ok(body) = budget.decode(form) {
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget);
+13
View File
@@ -927,3 +927,16 @@ fn text_direction_stops_at_the_form_budget() {
text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page());
assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}");
}
/// A form refused by the spent budget is left out; the text drawn after it still counts.
#[test]
fn text_direction_reads_on_past_a_refused_form() {
let mut doc = Document::new_empty();
let form = doc.add(Stream::from_raw([(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(), Vec::new()));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
let mut score = 0;
let content = b"/X Do BT 0 1 -1 0 0 0 Tm (a) Tj ET";
text_direction(&doc, content, &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::new(1 << 20, 0));
assert_eq!(score, 1);
}
+10 -3
View File
@@ -357,10 +357,17 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
///
/// # Errors
///
/// This operation is not transactional: an error may leave `doc` partially modified, with
/// sensitive content still present. Call this on a working clone and replace the original only
/// on success. On any error, discard the working clone; do not save or reuse it.
/// On any error `doc` is left as it was: the redaction runs on a copy (cheap: unchanged objects
/// are shared) that replaces `doc` only on success.
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
let mut work = doc.clone();
let report = apply_to(&mut work, pages)?;
*doc = work;
Ok(report)
}
/// [`apply`] without the copy: an error can leave `doc` part redacted.
fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
let (all_marks, unreadable) = marks_checked(doc);
// A mark with no readable area is not applied, and applying the others would look complete:
// refuse before anything changes. Only the pages being applied count.
+25 -8
View File
@@ -1690,8 +1690,8 @@ fn bluefin_survives(doc: &Document) -> bool {
/// #3285 review (the maintainer's probes): an element written in place in the parent tree that
/// owns the redacted text can't be changed, so applying is refused; the same element as an object
/// of its own is cleaned, and nothing of the word is left in a full rewrite.
/// Refusal does not promise rollback: direct callers discard the failed working copy.
/// of its own is cleaned, and nothing of the word is left in a full rewrite. A refusal leaves the
/// document as it was.
#[test]
fn an_element_written_in_place_owning_redacted_text_is_refused() {
let objs = |element: &str| -> Vec<Vec<u8>> {
@@ -1711,17 +1711,13 @@ fn an_element_written_in_place_owning_redacted_text_is_refused() {
let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>"));
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
let before = write_full(&doc, &SaveOptions::default()).unwrap();
let mut working = doc.clone();
let error = apply(&mut working, None).unwrap_err();
let error = apply(&mut doc, None).unwrap_err();
assert!(matches!(error, RedactError::Tags));
assert_eq!(
error.to_string(),
"the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
);
assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed");
assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags");
drop(working);
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "the refused document is unchanged");
let mut doc = pdf(objs("7 0 R"));
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
let r = apply(&mut doc, None).unwrap();
@@ -2745,3 +2741,24 @@ fn content_and_forms_share_one_budget_per_page() {
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read");
}
/// A page that fails to read after another page was already redacted leaves the whole document as
/// it was, the shared resources the first page's rewrite localised included.
#[test]
fn a_failure_on_a_later_page_leaves_the_document_unchanged() {
let mut doc = two_pages_one_resource_dictionary(true, false);
let all = pdfcraft_model::pages(&doc);
let (first, second, parent) = (all[0].obj, all[1].obj, doc.get(all[0].obj).as_dict().unwrap().reference(b"Parent").unwrap());
let mut dct = Dict::new();
dct.set(b"Filter".to_vec(), Object::name("DCTDecode"));
let bad = doc.add(Object::Stream(Stream::from_raw(dct, b"garbage".to_vec())));
doc.update_dict(second, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap();
mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], "");
mark(&mut doc, 1, &[[0.0, 0.0, 50.0, 50.0]], "");
let before: Vec<Object> = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect();
let text = content(&doc, 0);
assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(2)));
let after: Vec<Object> = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect();
assert_eq!(after, before);
assert_eq!(content(&doc, 0), text);
}
+12 -1
View File
@@ -62,6 +62,8 @@ pub struct TimestampToken {
pub policy: String,
pub serial: Vec<u8>,
pub gen_time: Time,
/// The TSTInfo nonce, when it has one that fits a `u64` (the size requests use).
pub nonce: Option<u64>,
}
impl TimestampToken {
@@ -140,6 +142,9 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
.to_vec();
let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec();
let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?;
// The nonce is the only INTEGER after genTime (RFC 3161 §2.4.2): accuracy is a SEQUENCE and
// ordering a BOOLEAN.
let nonce = fields.iter().skip(5).find(|f| f.tag == tag::INTEGER).and_then(|f| f.u64().ok());
// The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the
// imprint's (RFC 5652 §5.4).
let content_digest = token.signer.digest.digest(&[info]);
@@ -151,7 +156,7 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
if !token.verify_signature(cert, &content_digest)? {
return Err(bad("TSA signature is invalid"));
}
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time })
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time, nonce })
}
/// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint.
@@ -170,6 +175,12 @@ pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result<TimestampT
if token.digest != query.digest || token.imprint != query.imprint {
return Err(bad("message imprint does not match the requested digest"));
}
if query.nonce.is_some() && token.nonce != query.nonce {
return Err(bad("time-stamp token does not carry the requested nonce"));
}
if query.policy.as_ref().is_some_and(|p| *p != token.policy) {
return Err(bad("time-stamp token was issued under another policy than the one requested"));
}
Ok(token)
}
+22
View File
@@ -1611,6 +1611,28 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time));
}
/// A token answers the request that asked for it: a requested nonce or policy must come back.
#[test]
fn a_timestamp_response_must_carry_the_requested_nonce_and_policy() {
use pdfcraft_sign::timestamp::{TimestampQuery, parse_response, respond};
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let alg = pdfcraft_sign::DigestAlg::Sha256;
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
let query = |nonce: Option<u64>, policy: Option<&str>| {
let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap();
(q.nonce, q.policy) = (nonce, policy.map(str::to_string));
q
};
let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap();
let with_nonce = answer(&query(Some(42), None));
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok());
assert!(parse_response(&with_nonce, &query(None, None)).is_ok());
assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce");
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy");
let without_nonce = answer(&query(None, None));
assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token");
}
#[cfg(windows)]
#[test]
fn windows_store_enumeration_and_missing_identity() {
+105 -21
View File
@@ -354,7 +354,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
&& le.len() == 2
{
let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into());
let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", name(&le[0]), name(&le[1]));
let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1])));
}
// A callout: its leader path, text-box inset, intent and (single-name) line ending.
let cl = nums_of(doc, d.get(b"CL"));
@@ -1037,8 +1037,9 @@ const LOCKED: i64 = 128;
/// An existing annotation an import must never replace, move or unlock: one the user locked, a
/// redaction mark (replacing it would drop the region the user marked to remove), or a form
/// widget or field (replacing it would set the field's value and policy around the form setter).
fn import_protected(x: &Dict) -> bool {
x.int(b"F").unwrap_or(0) & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT")
fn import_protected(doc: &Document, x: &Dict) -> bool {
let flags = x.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
flags & LOCKED != 0 || matches!(x.name(b"Subtype"), Some(b"Redact" | b"Widget")) || x.contains(b"FT")
}
fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result<ObjRef, DataError> {
@@ -1089,7 +1090,7 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result<ObjRef, Da
for a in annots {
let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned());
let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm;
if same && existing.as_ref().is_some_and(import_protected) {
if same && existing.as_ref().is_some_and(|x| import_protected(doc, x)) {
clash = true;
kept.push(a);
} else if same {
@@ -1198,18 +1199,66 @@ fn find_by_name(doc: &Document, nm: &str) -> Option<ObjRef> {
None
}
/// Copy an object out of another document, resolving references (FDF objects are small).
fn deep(src: &Document, o: &Object, depth: usize) -> Object {
/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference
/// level after level would otherwise expand a small file into millions of values.
const MAX_FDF_IMPORT_VALUES: usize = 1_000_000;
struct ImportBudget {
values: usize,
bytes: usize,
}
impl ImportBudget {
fn new() -> Self {
Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES }
}
fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> {
match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) {
(Some(v), Some(b)) => {
(self.values, self.bytes) = (v, b);
Ok(())
}
_ => Err(DataError::Malformed(format!(
"the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text",
MAX_FDF_BYTES >> 20
))),
}
}
}
/// Copy an object out of another document, resolving references. A reference back to an object
/// being copied becomes null.
fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec<ObjRef>, budget: &mut ImportBudget) -> Result<Object, DataError> {
budget.spend(1, 0)?;
if depth > 16 {
return Object::Null;
return Ok(Object::Null);
}
match o {
Object::Ref(r) => deep(src, &src.get(*r), depth + 1),
Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1)).collect()),
Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), deep(src, v, depth + 1))).collect()),
Ok(match o {
Object::Ref(r) => {
if path.contains(r) {
return Ok(Object::Null);
}
path.push(*r);
let copy = deep(src, &src.get(*r), depth + 1, path, budget);
path.pop();
copy?
}
Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::<Result<_, _>>()?),
Object::Dict(d) => {
Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::<Result<_, DataError>>()?)
}
Object::Stream(_) => Object::Null,
Object::String(s) => {
budget.spend(0, s.bytes.len())?;
o.clone()
}
Object::Name(n) => {
budget.spend(0, n.len())?;
o.clone()
}
other => other.clone(),
}
})
}
fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec<String>)>, depth: usize) -> Result<(), DataError> {
@@ -1233,27 +1282,59 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec<Str
Ok(())
}
fn walk_fdf(fdf: &Document, list: &[Object], prefix: &str, out: &mut Vec<(String, Vec<String>)>, depth: usize) {
/// Read an FDF field tree. A field listed again beneath itself is skipped.
fn walk_fdf(
fdf: &Document,
list: &[Object],
prefix: &str,
out: &mut Vec<(String, Vec<String>)>,
depth: usize,
path: &mut Vec<ObjRef>,
budget: &mut ImportBudget,
) -> Result<(), DataError> {
if depth > 32 {
return;
return Ok(());
}
for f in list {
let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue };
budget.spend(1, 0)?;
let held;
let field = match f {
Object::Ref(r) if path.contains(r) => continue,
Object::Ref(r) => {
held = fdf.get(*r);
&*held
}
direct => direct,
};
let Some(d) = field.as_dict() else { continue };
let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue };
let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") };
budget.spend(0, full.len())?;
if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) {
let vals: Vec<String> = match &*v {
Object::String(s) => vec![s.to_text()],
Object::Name(n) => vec![pdfcraft_forms::name_text(n)],
Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(),
Object::Array(a) => {
budget.spend(a.len(), 0)?;
a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect()
}
_ => Vec::new(),
};
budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?;
out.push((full.clone(), vals));
}
if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) {
walk_fdf(fdf, &kids, &full, out, depth + 1);
if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k))
&& let Some(kids) = kids.as_array()
{
let entered = f.as_ref().map(|r| path.push(r)).is_some();
let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget);
if entered {
path.pop();
}
walked?;
}
}
Ok(())
}
fn fdf_body(fdf: &Document) -> Result<Dict, DataError> {
@@ -1272,8 +1353,9 @@ pub fn data_values(bytes: &[u8]) -> Result<Vec<(String, Vec<String>)>, DataError
let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?;
let body = fdf_body(&fdf)?;
let mut values = Vec::new();
let mut budget = ImportBudget::new();
if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) {
walk_fdf(&fdf, &list, "", &mut values, 0);
walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?;
}
return Ok(values);
}
@@ -1346,10 +1428,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?;
let body = fdf_body(&fdf)?;
let mut report = Report::default();
let mut budget = ImportBudget::new();
// Fields (with /Kids for hierarchical names).
let mut values = Vec::new();
if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) {
walk_fdf(&fdf, &list, "", &mut values, 0);
walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?;
}
// Comments: parents first, then replies (whose /IRT needs the parent's new reference).
let pages: Vec<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
@@ -1360,7 +1443,8 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
// The parent is another comment: link it below rather than copying it in.
let irt = src.get(b"IRT").cloned();
src.remove(b"IRT");
let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0) else { continue };
let mut path: Vec<ObjRef> = a.as_ref().into_iter().collect();
let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue };
let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else {
continue;
};
+107
View File
@@ -1758,3 +1758,110 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() {
let mark = imported_redact_mark(fdf);
assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}");
}
/// An FDF body with `body` as its `/FDF` dictionary contents and `objects` (numbered from 2).
fn fdf_with(body: &str, objects: &[String]) -> Vec<u8> {
let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n");
for (i, o) in objects.iter().enumerate() {
out.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 2));
}
out.push_str("trailer\n<< /Root 1 0 R >>\n%%EOF\n");
out.into_bytes()
}
/// A field that lists itself as a kid is read once.
#[test]
fn an_fdf_field_listing_itself_is_read_once() {
let cyclic = fdf_with("/Fields [2 0 R]", &["<< /T (a) /V (x) /Kids [2 0 R] >>".into()]);
assert_eq!(data_values(&cyclic).unwrap(), vec![("a".to_string(), vec!["x".to_string()])]);
}
/// A field tree whose `/Kids` repeat one subtree level after level is refused rather than
/// expanded into millions of rows.
#[test]
fn fdf_field_trees_that_repeat_references_are_bounded() {
let levels = 19;
let mut objects: Vec<String> = (0..levels).map(|i| format!("<< /T (f{i}) /V (x) /Kids [{n} 0 R {n} 0 R] >>", n = i + 3)).collect();
objects.push("<< /T (leaf) /V (x) >>".into());
let shared = fdf_with("/Fields [2 0 R]", &objects);
let read = data_values(&shared);
assert!(read.is_err(), "{} rows", read.map_or(0, |v| v.len()));
assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_))));
}
fn fdf_note(data: &str) -> String {
format!("<< /Subtype /Text /Rect [0 0 9 9] /NM (n) /Page 0 /PCData {data} >>")
}
/// A comment property that refers back to the comment is copied once, not nested level after
/// level.
#[test]
fn an_fdf_comment_referring_to_itself_is_copied_once() {
let cyclic = fdf_with("/Annots [2 0 R]", &[fdf_note("2 0 R")]);
let mut dst = blank();
assert_eq!(import(&mut dst, &cyclic).unwrap().comments, 1);
let placed = find_by_name(&dst, "n").unwrap();
let data = dst.get(placed).as_dict().unwrap().get(b"PCData").cloned();
assert!(!matches!(data, Some(Object::Dict(_))), "{data:?}");
}
/// A comment whose properties repeat one reference level after level is refused rather than
/// copied into millions of values.
#[test]
fn fdf_comment_graphs_that_repeat_references_are_bounded() {
let note = fdf_note;
let width = 6;
let mut objects = vec![note("3 0 R")];
for i in 0..8 {
objects.push(format!("[{}]", vec![format!("{} 0 R", i + 4); width].join(" ")));
}
objects.push("(leaf)".into());
let shared = fdf_with("/Annots [2 0 R]", &objects);
assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_))));
}
/// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add
/// attributes or elements to the exported XFDF.
#[test]
fn xfdf_line_ending_names_are_escaped() {
let mut doc = blank();
let page = pdfcraft_model::pages(&doc)[0].obj;
let hostile = b"a\" tail=\"x\"><field name=\"Name\"><value>pwned</value></field><b".to_vec();
let mut line = Dict::new();
line.set(b"Type".to_vec(), Object::name("Annot"));
line.set(b"Subtype".to_vec(), Object::name("Line"));
line.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
line.set(b"L".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
line.set(b"LE".to_vec(), Object::Array(vec![Object::Name(hostile.clone()), Object::Name(hostile.clone())]));
let line = doc.add(line);
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(line)]))).unwrap();
let xml = export_xfdf(&doc, true, false, "form.pdf");
let parsed = roxmltree::Document::parse(&xml).unwrap();
assert!(!parsed.descendants().any(|n| n.has_tag_name("field")), "{xml}");
let el = parsed.descendants().find(|n| n.has_tag_name("line")).unwrap();
let want = String::from_utf8(hostile).unwrap();
assert_eq!((el.attribute("head"), el.attribute("tail")), (Some(want.as_str()), Some(want.as_str())));
}
/// A comment locked through an indirect `/F` is as protected from a same-named import as one
/// with the flag written inline.
#[test]
fn a_comment_locked_by_an_indirect_flag_is_not_replaced_by_an_import() {
let mut doc = blank();
let page = pdfcraft_model::pages(&doc)[0].obj;
let flag = doc.add(Object::Int(128)); // Locked
let mut note = Dict::new();
note.set(b"Type".to_vec(), Object::name("Annot"));
note.set(b"Subtype".to_vec(), Object::name("Text"));
note.set(b"NM".to_vec(), PdfString::text("n1"));
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 10.into(), 30.into(), 30.into()]));
note.set(b"F".to_vec(), Object::Ref(flag));
let note = doc.add(Object::Dict(note));
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
let before = doc.get(note);
let xfdf = br#"<xfdf><annots><text page="0" name="n1" rect="0,0,1,1"/></annots></xfdf>"#;
assert_eq!(import(&mut doc, xfdf).unwrap().comments, 1);
let annots = doc.get(page).as_dict().and_then(|p| p.get(b"Annots").and_then(Object::as_array).cloned()).unwrap();
assert!(annots.contains(&Object::Ref(note)), "the locked comment was replaced");
assert_eq!(doc.get(note), before, "the locked comment was changed");
}
+15 -9
View File
@@ -21,10 +21,11 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
return
}
VERSION="${PDFCRAFT_VERSION:-$(workspace_version)}"
if [ -z "$VERSION" ]; then
if [[ -z "$VERSION" ]]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
@@ -32,12 +33,13 @@ export VERSION
# A labelled build says so in `--version` and About (pdfcraft_engine::build_info) and in its
# package release (packaging/linux/nfpm.yaml), so it can't be mistaken for the release.
if [ -n "${PDFCRAFT_BUILD_LABEL:-}" ]; then
if [[ -n "${PDFCRAFT_BUILD_LABEL:-}" ]]; then
case "$PDFCRAFT_BUILD_LABEL" in
*[!A-Za-z0-9.]* | .* | *.)
echo "error: PDFCRAFT_BUILD_LABEL may hold only letters, digits and inner dots: $PDFCRAFT_BUILD_LABEL" >&2
exit 1
;;
*) ;;
esac
export PDFCRAFT_BUILD_LABEL
fi
@@ -46,7 +48,7 @@ DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
if [ -z "${PDFCRAFT_BUILD_SHA:-}" ]; then
if [[ -z "${PDFCRAFT_BUILD_SHA:-}" ]]; then
PDFCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export PDFCRAFT_BUILD_SHA
@@ -55,25 +57,27 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
return
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
return
}
# Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR, set for every release) embed
# its fonts, so the package carries their licences: fonts/<family>/OFL.txt -> OFL-<family>.txt.
copy_font_licences() {
local dest="$1" f family
[ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
[[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0
for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
[ -f "$f" ] || continue
[[ -f "$f" ]] || continue
family="$(basename "$(dirname "$f")")"
cp "$f" "$dest/OFL-$family.txt"
done
@@ -90,7 +94,7 @@ stage_models() {
dest="$(cd "$dest" && pwd)"
(cd "$ROOT" && cargo xtask models "$dest")
rm -f "$dest"/*.part
if ! ls "$dest"/*.LICENCE.txt >/dev/null 2>&1 || [ ! -s "$dest/ATTRIBUTION.txt" ]; then
if ! ls "$dest"/*.LICENCE.txt >/dev/null 2>&1 || [[ ! -s "$dest/ATTRIBUTION.txt" ]]; then
echo "error: no OCR models in $dest after cargo xtask models" >&2
exit 1
fi
@@ -100,5 +104,7 @@ stage_models() {
# Portable SHA-256 of a file (prints just the hash).
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
local file="$1"
if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
return
}
+2 -2
View File
@@ -14,7 +14,7 @@ LINUX="$ROOT/packaging/linux"
APP_ID=ai.storyteller.pdfcraft
SKIP_BUILD=0
while [ $# -gt 0 ]; do
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,9p' "$0"; exit 0 ;;
@@ -32,7 +32,7 @@ BASENAME="pdfcraft-$VERSION-freebsd-$ARCH"
echo "==> PdfCraft $VERSION for FreeBSD $ARCH"
if [ "$SKIP_BUILD" = 0 ]; then
if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli)
fi
BIN="$CARGO_TARGET_DIR/release"
+4 -1
View File
@@ -27,7 +27,10 @@ MAC="$TMP/macos.svg"
echo '</svg>'
} >"$MAC"
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render() {
local src="$1" size="$2" out="$3"
resvg -w "$size" -h "$size" "$src" "$out" </dev/null
}
# 1024 px PNG on Apple's grid (also the runtime Dock icon on macOS, see apps/pdfcraft/src/main.rs).
render "$MAC" 1024 "$DIR/pdfcraft-1024.png"
+4 -4
View File
@@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
TARBALL=""
while [ $# -gt 0 ]; do
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
@@ -38,7 +38,7 @@ case "$ARCH" in
esac
BASENAME="pdfcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[ -x "$WORK/stage/bin/pdfcraft" ] || { echo "error: $TARBALL has no bin/pdfcraft" >&2; exit 1; }
[[ -x "$WORK/stage/bin/pdfcraft" ]] || { echo "error: $TARBALL has no bin/pdfcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak"
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [ "$TEST" = 1 ]; then
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=pdfcraft-cli "$APP_ID" --version
+12 -9
View File
@@ -30,7 +30,7 @@ APP_ID=ai.storyteller.pdfcraft
SKIP_BUILD=0
FORMATS="appimage deb rpm tar cli"
while [ $# -gt 0 ]; do
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
@@ -52,8 +52,8 @@ BASENAME="pdfcraft-$VERSION$LABEL_SUFFIX-linux-$ARCH"
echo "==> PdfCraft $VERSION$LABEL_SUFFIX for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
if [ -n "${CROSS_TARGET:-}" ]; then
if [[ "$SKIP_BUILD" = 0 ]]; then
if [[ -n "${CROSS_TARGET:-}" ]]; then
(cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli)
@@ -87,7 +87,10 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
has() {
local format="$1"
case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac
}
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -134,9 +137,9 @@ if has appimage; then
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [ -z "$TOOL" ]; then
if [[ -z "$TOOL" ]]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
if [[ ! -x "$TOOL" ]]; then
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
@@ -159,16 +162,16 @@ if has appimage; then
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
if [ -s "$OUT.zsync" ]; then
if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
else
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
fi
fi
if [ -z "${CROSS_TARGET:-}" ]; then
if [[ -z "${CROSS_TARGET:-}" ]]; then
"$STAGE/usr/bin/pdfcraft-cli" --version
elif [ -n "${EMULATOR:-}" ]; then
elif [[ -n "${EMULATOR:-}" ]]; then
"$EMULATOR" "$STAGE/usr/bin/pdfcraft-cli" --version
fi
echo "==> done"
+3 -3
View File
@@ -12,7 +12,7 @@ umask 077
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
if [ -z "${APPLE_CERTIFICATE:-}" ]; then
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
@@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
if [ -z "$IDENTITY" ]; then
if [[ -z "$IDENTITY" ]]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
if [ -n "${GITHUB_ENV:-}" ]; then
if [[ -n "${GITHUB_ENV:-}" ]]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
+12 -12
View File
@@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
while [ $# -gt 0 ]; do
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
@@ -44,9 +44,9 @@ DMG="$DIST/pdfcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/pdfcraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
if [ "$IDENTITY" = "-" ]; then
if [[ "$IDENTITY" = "-" ]]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
@@ -55,7 +55,7 @@ fi
echo "==> PdfCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
if [ "$SKIP_BUILD" = 0 ]; then
if [[ "$SKIP_BUILD" = 0 ]]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked -p pdfcraft -p pdfcraft-cli "${args[@]}")
@@ -74,9 +74,9 @@ done
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
[ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
[[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none)
local kc=()
[ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
[[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
@@ -88,8 +88,8 @@ notarize() {
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
if [ "$status" != "Accepted" ]; then
if [ -n "$id" ]; then
if [[ "$status" != "Accepted" ]]; then
if [[ -n "$id" ]]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
@@ -122,7 +122,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
if [ "$NOTARIZE" = 1 ]; then
if [[ "$NOTARIZE" = 1 ]]; then
ditto -c -k --keepParent "$APP" "$WORK/PdfCraft-notarize.zip"
notarize "$WORK/PdfCraft-notarize.zip"
xcrun stapler staple "$APP"
@@ -156,7 +156,7 @@ hdiutil_retry() {
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
[[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
@@ -174,7 +174,7 @@ if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/PdfCraft.app"; then
exit 1
fi
hdiutil_retry detach detach "$MOUNT" >/dev/null
if [ "$NOTARIZE" = 1 ]; then
if [[ "$NOTARIZE" = 1 ]]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
@@ -192,7 +192,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/pdfcraft-cli"
rm -f "$CLI_ZIP"
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/pdfcraft-cli" --version
echo "==> done"
+2 -2
View File
@@ -11,13 +11,13 @@ set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
if [ "${1:-}" != "--skip-build" ]; then
if [[ "${1:-}" != "--skip-build" ]]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/pdfcraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
+2 -2
View File
@@ -5841,8 +5841,8 @@ milestone = "M8"
status = "partial"
commands = []
tools = ["doc_audit_redactions"]
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
[[feature]]
id = "protect.remove-hidden-on-close"
+14
View File
@@ -1,2 +1,16 @@
sonar.projectKey=storytold_pdfcraft
sonar.organization=storytold
# vendor/ holds third-party crates carried with small, marked patches (vendor/README.md).
# They are kept close to upstream so each copy can be dropped once upstream releases the
# fix; restyling them would only make that harder, so they are not analysed here.
sonar.exclusions=vendor/**
# S4790 (weak hash): MD5 is the hash ISO 32000 mandates for the RC4/AES-128 standard security
# handler (Algorithms 1-5), and RIPEMD-160 is a digest existing signatures may name. Both are
# needed to open and verify existing files, not chosen for new security.
sonar.issue.ignore.multicriteria=crypt_md5,sign_ripemd
sonar.issue.ignore.multicriteria.crypt_md5.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.crypt_md5.resourceKey=crates/crypt/src/lib.rs
sonar.issue.ignore.multicriteria.sign_ripemd.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.sign_ripemd.resourceKey=crates/sign/src/keys.rs