Merge upstream/main into feat/comment-popups

Keeps both the comment pop-up test and main's ungroup tests in
automation.rs. Carries the shared fix for main's rules.rs build break
(content_streams now returns ContentStream; join via text::joined).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 22:46:45 +08:00
commit 3a733105f5
97 files changed
+6305 -456

No files matched your search

Generated
+24 -1
View File
@@ -4265,7 +4265,6 @@ version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
@@ -4642,6 +4641,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -5522,6 +5522,29 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
+8 -1
View File
@@ -70,7 +70,11 @@ pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
hayro = "0.7.1"
hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
# zlib-rs: miniz_oxide took about 1.7 µs for every fixed-Huffman block, empty or not
# (`pdfcraft_filters::FLATE_READ_WORK`). Other dependencies still build flate2's default
# miniz_oxide backend; flate2 prefers zlib-rs when both are built, as the Windows build already did
# through craft-update. `flate::tests::a_copy_from_before_the_start_is_damage` shows which runs.
flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] }
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
@@ -80,6 +84,9 @@ hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
+4
View File
@@ -3,6 +3,8 @@
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
@@ -229,6 +231,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
@@ -256,6 +259,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
+1 -1
View File
@@ -333,7 +333,7 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
.with_min_inner_size([820.0, 520.0])
.with_min_inner_size(pdfcraft_ui_egui::MIN_WINDOW_SIZE)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
+5
View File
@@ -186,3 +186,8 @@ sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
-1
View File
@@ -14,6 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
+74 -20
View File
@@ -25,6 +25,8 @@ pub enum AltError {
NotAFigure(u32),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the figure's content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -52,6 +54,8 @@ pub enum ArtifactError {
ParentTree(String),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the tag's marked content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -372,7 +376,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
}
}
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
/// How much of a content stream decoded.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded.
type DecodedStream = (Object, Option<(Stream, Vec<u8>, Read)>);
/// Decode the page's content streams within the page decode budget. `None` when a stream is
/// rejected because the budget is exhausted.
@@ -383,14 +399,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
for entry in entries {
let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
// Content past the budget may hold more of the tag: editing the rest
// would leave that part marked as a tag the tree no longer has.
Err(_) if budget.exhausted() => return None,
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
};
Some((s.clone(), data))
Some((s.clone(), data, read))
}
_ => None,
};
@@ -403,6 +419,18 @@ fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &Hash
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
}
/// What [`artifact_marked_content`] did.
#[derive(Debug, PartialEq)]
enum Marked {
/// The streams holding the content were rewritten.
Done,
/// None of the content is on the page, or with `require_all` not all of it: nothing changed.
NotFound,
/// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of
/// which can be rewritten without losing what wasn't read, or after the damage: nothing changed.
Damaged,
}
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
@@ -412,25 +440,45 @@ fn artifact_marked_content(
streams: Vec<DecodedStream>,
ids: &HashSet<i64>,
require_all: bool,
) -> Result<bool, pdfcraft_cos::CosError> {
) -> Result<Marked, pdfcraft_cos::CosError> {
let props = page_properties(doc, &page.dict);
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
// The pieces are the streams, in order.
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect();
let read: Vec<Read> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect();
let damage = read.iter().position(|r| *r == Read::Damaged);
let pieces = pdfcraft_content::Pieces::join(&decoded);
let mut found = HashSet::new();
let mut edits = Vec::new();
for op in pieces.parse().ops {
if let Some(id) = marked_id(doc, &props, &op, ids) {
// What follows the damage is unknown: a string or comment the damage left open runs
// on into the next stream, so what reads as an operator after it may not be one. An
// operator whose operands start before a stream not read whole and whose keyword comes
// after it takes them from around what wasn't read: that stream may end them itself.
let (first, last) = pieces.pieces_of(&op);
if damage.is_some_and(|d| last > d) || read.get(first..last).is_some_and(|r| r.iter().any(|r| *r != Read::Whole)) {
return Ok(Marked::Damaged);
}
found.insert(id);
edits.push((op.span, b"/Artifact BMC".to_vec()));
}
}
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(false);
// Content not found may be past the damage, or in a stream that doesn't decode: whether it
// is on the page isn't known.
if found != *ids && read.iter().any(|r| *r != Read::Whole) {
return Ok(Marked::Damaged);
}
let mut spliced = pieces.splice(edits).into_iter();
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(Marked::NotFound);
}
let spliced = pieces.splice(edits);
if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) {
return Ok(Marked::Damaged);
}
let mut spliced = spliced.into_iter();
let mut next = Vec::with_capacity(streams.len());
for (entry, stream) in streams {
let Some((stream, old)) = stream else {
let Some((stream, old, _)) = stream else {
next.push(entry);
continue;
};
@@ -456,7 +504,7 @@ fn artifact_marked_content(
_ => Object::Array(next),
};
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
Ok(true)
Ok(Marked::Done)
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
@@ -468,11 +516,15 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
// The parent tree no longer points those ids at the figure.
if artifact_marked_content(doc, page, streams, &ids, false)?
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
{
clear_parent_tree(doc, key, obj)?;
match artifact_marked_content(doc, page, streams, &ids, false)? {
// The parent tree no longer points those ids at the figure.
Marked::Done => {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj)?;
}
}
Marked::NotFound => {}
Marked::Damaged => return Err(AltError::Damaged),
}
}
// Out of its parent's kids.
@@ -513,10 +565,12 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
let replacement = replacement_kids(doc, parent, obj)?;
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
if !artifact_marked_content(doc, page, streams, &ids, true)? {
return Err(ArtifactError::ContentNotFound);
match artifact_marked_content(doc, page, streams, &ids, true)? {
Marked::Done => {}
Marked::NotFound => return Err(ArtifactError::ContentNotFound),
Marked::Damaged => return Err(ArtifactError::Damaged),
}
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
}
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
+17 -11
View File
@@ -4,7 +4,7 @@
//! which can then be retagged with the existing tag-property tool after human review.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
@@ -57,18 +57,24 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
// Strictly, and with `/DecodeParms` given by reference: the content is written back from it.
let mut budget = DecodeBudget::new(remaining, 0);
let decoded = budget.decode_strict(doc, stream).map_err(|e| {
// The allowance counts what every filter stage decodes, as a page's does.
if budget.remaining() == 0 {
unsupported(index, "decoding the content takes more than 16 MiB")
} else {
// The decoder's own reason: `CosError::Filter`'s text would say again that the data
// couldn't be decoded.
let why = match e {
pdfcraft_cos::CosError::Filter(why) => why,
other => other.to_string(),
};
unsupported(index, format!("content cannot be decoded strictly: {why}"))
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
})?;
all.extend_from_slice(&decoded);
all.push(b' ');
}
+241
View File
@@ -79,6 +79,68 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
/// Page content behind a predictor whose `/DecodeParms` are given by reference is read through
/// them: autotagging writes the text back as it was, marked, not with the predictor's tag bytes in
/// it (a `\0` every eight bytes, which split `24` into `2 4`).
#[test]
fn autotag_text_reads_content_with_parameters_given_by_reference() {
use pdfcraft_cos::{Dict, ObjRef};
let mut content = b"BT /F1 24 Tf 72 700 Td (Hello World) Tj ET".to_vec();
content.resize(content.len().div_ceil(8) * 8, b' ');
// PNG rows of eight bytes, each tagged 0 (no prediction).
let rows: Vec<u8> = content.chunks(8).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut doc = 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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
let parms: Dict = [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(8))].into_iter().collect();
let parms = doc.add(Object::Dict(parms));
let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
doc.set(ObjRef::new(4, 0), predicted);
assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 });
let page = &pdfcraft_model::pages(&doc)[0];
let written = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(written) = &*written else { panic!("page content") };
let text = String::from_utf8_lossy(&written.decoded_strict().unwrap()).into_owned();
assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}");
}
/// Autotagging refuses a page whose content it can't read whole, and says why: content past
/// 16 MiB, or content that doesn't decode strictly, with the decoder's reason right after.
#[test]
fn autotag_text_says_why_it_cannot_read_a_page() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let reason = |content: Stream| {
let mut doc = 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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
doc.set(ObjRef::new(4, 0), content);
match autotag_text(&mut doc) {
Err(AutoTagError::Unsupported { reason, .. }) => reason,
other => panic!("{other:?}"),
}
};
assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoding the content takes more than 16 MiB");
let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec());
corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let why = reason(corrupt);
assert!(why.starts_with("content cannot be decoded strictly: FlateDecode: "), "{why}");
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
let mut doc = pdf(
@@ -1087,6 +1149,185 @@ fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what
/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream
/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept
/// as stored.
#[test]
fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
use pdfcraft_cos::{ObjRef, Object};
let content = b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n";
let damaged = damaged_flate(content, b"0 0 1 rg 0 0 10 10 re f");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged.clone());
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0)));
let before = state(&doc);
assert_eq!(figures(&doc).len(), 2, "read up to the damage");
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(state(&doc), before);
let mut doc = figures_doc();
let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored");
}
/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's
/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open
/// runs on into the next stream, up to its first end of line, and a string starting there reads
/// as operators, among them what looks like the figure's content: rewritten, it would change the
/// string. Nothing changes, the structure tree included.
#[test]
fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC";
for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] {
let mut doc = figures_doc();
if let Some(after) = after {
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec()));
}
let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}");
assert_eq!(state(&doc), before, "{damaged:?}");
}
}
/// A page may split its content between an operator's operands and its keyword. When the operands
/// of the figure's `BDC` end the stream before one damaged from its start or one that doesn't
/// decode, the `BDC` the page reads is in what wasn't read: joined without it, those operands and
/// the next tag's read as one `BDC`, which reaches into the stream after it. Neither marking
/// changes anything, the structure tree included.
#[test]
fn a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let rest = b" BDC q 40 0 0 20 10 150 cm /Im Do Q EMC";
for damaged in [true, false] {
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >>".to_vec()));
let hidden = if damaged {
damaged_flate(b"", rest)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), rest);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = doc.add(hidden);
let after = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC".to_vec())));
let listed = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(after)]);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed)).unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "damaged: {damaged}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "damaged: {damaged}");
assert_eq!(state(&doc), before, "damaged: {damaged}");
}
}
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
/// to be off the page, so neither marking changes anything, the structure tree included.
#[test]
fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(state(&doc), before);
}
/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known
/// to be off the page, so neither marking changes anything.
#[test]
fn a_tags_content_past_the_damage_is_not_taken_for_absent() {
use pdfcraft_cos::ObjRef;
let damaged =
damaged_flate(b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n", b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged);
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let before = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), before);
}
/// A page's `/StructParents` given by reference is read through it: the parent tree no longer
/// points the marked content at the tag.
#[test]
fn struct_parents_given_by_reference_are_read() {
use pdfcraft_cos::{ObjRef, Object};
let by_reference = || {
let mut doc = figures_doc();
let key = doc.add(Object::Int(0));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"StructParents".to_vec(), Object::Ref(key))).unwrap();
doc
};
let nums = |doc: &Document| doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
let mut doc = by_reference();
mark_decorative(&mut doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Ref(ObjRef::new(8, 0))])])));
let mut doc = by_reference();
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Null])])));
}
/// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other
/// streams is still marked: only running past the decode budget refuses.
#[test]
fn a_tag_beside_a_stream_that_does_not_decode_is_marked() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"0 g".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let contents = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let Some(Object::Array(list)) = contents else { panic!("contents {contents:?}") };
assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
+2 -2
View File
@@ -283,7 +283,7 @@ impl<'a> Walker<'a> {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = self.budget.decode_strict(stream)?;
let data = self.budget.decode_strict(self.doc, stream)?;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
@@ -326,7 +326,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(budget.decode_strict(s)?);
streams.push(budget.decode_strict(doc, s)?);
}
}
let pieces = Pieces::join(&streams);
+1
View File
@@ -467,6 +467,7 @@ impl Automation {
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"ungroup" => ImageEdit::Ungroup,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
+61 -3
View File
@@ -6,6 +6,10 @@ use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, Tex
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
/// `doc_optimize` with `target_bytes`: (max target ppi, max JPEG quality) per extra attempt, mildest first.
/// The quality applies only to images the caller left on JPEG; zip and retain keep their encoding.
const TARGET_LADDER: [(f64, u8); 5] = [(110.0, 45), (96.0, 35), (72.0, 28), (54.0, 20), (36.0, 12)];
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
@@ -380,7 +384,45 @@ impl Automation {
})
.collect::<Result<_>>()?,
};
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let target = match a.opt_int("target_bytes")? {
None => None,
Some(t) if t >= 1 => Some(usize::try_from(t).map_err(|_| ToolError::InvalidArgs(format!("target_bytes {t} is too large")))?),
Some(t) => return Err(ToolError::InvalidArgs(format!("target_bytes must be at least 1, not {t}"))),
};
let (mut bytes, mut r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let mut attempts = 1;
let mut step_used = 0;
// Settings already optimized with; a rung that changes nothing is not run again.
let mut tried = vec![settings.clone()];
if let Some(t) = target {
// Stronger image settings, one rung per attempt, until the copy fits. Every attempt
// starts from the open document, so a failed one changes nothing; the number of
// attempts is fixed. The smallest copy is kept if none fits.
for (k, &(ppi, quality)) in TARGET_LADDER.iter().enumerate() {
if bytes.len() <= t {
break;
}
let mut s = settings.clone();
for img in [&mut s.color, &mut s.gray] {
img.downsample = true;
img.target_ppi = img.target_ppi.min(ppi);
img.above_ppi = img.above_ppi.min(ppi * 1.5).max(img.target_ppi);
// Only a caller who chose JPEG gets a lower quality; zip and retain stay as asked.
if let Compression::Jpeg(q) = img.compression {
img.compression = Compression::Jpeg(q.min(quality));
}
}
if tried.contains(&s) {
continue;
}
let (b2, r2) = self.session.optimized_bytes(id, &s, &discard).map_err(failed)?;
tried.push(s);
attempts += 1;
if b2.len() < bytes.len() {
(bytes, r, step_used) = (b2, r2, k + 1);
}
}
}
write_atomic(&path, &bytes)?;
fn valid_version(v: &[u8]) -> Option<&str> {
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
@@ -400,7 +442,19 @@ impl Automation {
// entry). Both are "d.d", so text order is version order.
let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max();
let o = &r.optimize;
Ok(json!({
let mut extra = json!({});
if let Some(t) = target {
let (ppi, quality) = step_used.checked_sub(1).and_then(|k| TARGET_LADDER.get(k)).copied().unzip();
let lossy = matches!(settings.color.compression, Compression::Jpeg(_)) || matches!(settings.gray.compression, Compression::Jpeg(_));
let quality = quality.filter(|_| lossy);
extra = json!({
"target_bytes": t,
"target_reached": bytes.len() <= t,
"attempts": attempts,
"target_settings": { "step": step_used, "max_ppi": ppi, "max_jpeg_quality": quality },
});
}
let mut out = json!({
"path": path.to_string_lossy(),
"pdf_version": pdf_version,
"object_streams": settings.effective_object_streams(),
@@ -425,7 +479,11 @@ impl Automation {
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
}))
});
if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
o.extend(e.iter().map(|(k, v)| (k.clone(), v.clone())));
}
Ok(out)
}
/// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based).
+41 -6
View File
@@ -2,8 +2,8 @@
//! (removing what they cover for good) or clear them.
use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad,
redact_word_list,
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
};
use serde_json::{Value, json};
@@ -59,16 +59,20 @@ impl Automation {
a.get("find").is_some(),
a.get("words").is_some(),
a.get("pattern").is_some(),
a.get("regex").is_some(),
a.opt_bool("whole_pages")?.unwrap_or(false),
];
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern, regex or whole_pages: true".into()));
}
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
}
if a.get("validate").is_some() && a.get("regex").is_none() {
return Err(ToolError::InvalidArgs("validate applies only to regex".into()));
}
// Per searched word or phrase: how many marks it made.
let mut matched_words: Vec<(String, usize)> = Vec::new();
if a.get("rect").is_some() {
@@ -89,6 +93,19 @@ impl Automation {
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
None => None,
};
let user_re = match a.opt_str("regex")? {
Some(src) => {
let validator = match a.opt_str("validate")? {
Some(v) => Some(RedactValidator::from_id(v).ok_or_else(|| {
let ids: Vec<_> = REDACT_VALIDATORS.iter().map(|v| v.id()).collect();
ToolError::InvalidArgs(format!("unknown validate {v:?}; allowed: {}", ids.join(", ")))
})?),
None => None,
};
Some(RedactRegex::new(src, validator).map_err(|e| ToolError::InvalidArgs(e.to_string()))?)
}
None => None,
};
let needles: Vec<String> = match (a.opt_str("find")?, a.get("words")) {
(Some(f), _) => vec![f.to_owned()],
(None, Some(_)) => {
@@ -108,9 +125,27 @@ impl Automation {
let texts = self.page_texts(id, &pages)?;
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match pattern {
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
None => needles
let hits: Vec<(Option<usize>, _)> = match (&user_re, pattern) {
(Some(re), _) => {
// A page with too many matches fails the call: marking only the first ones
// would leave the rest readable after redact_apply.
let too_many = std::cell::Cell::new(false);
let found = text.find_with(|chars| {
re.find(chars).unwrap_or_else(|_| {
too_many.set(true);
Vec::new()
})
});
if too_many.get() {
return Err(ToolError::InvalidArgs(format!(
"regex has more than 10000 matches on page {}; nothing was marked. Narrow the expression so that every match can be marked",
p + 1
)));
}
found.into_iter().map(|h| (None, h)).collect()
}
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
(None, None) => needles
.iter()
.enumerate()
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
+8 -5
View File
@@ -620,7 +620,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date, iban: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, more than 10000 matches on one page fail the call, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@@ -632,6 +632,8 @@ pub fn tools() -> Vec<ToolDef> {
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": pattern_enum(),
"regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax), at most 1024 bytes (maxLength counts characters, the limit is bytes); every match is marked." },
"validate": { "type": "string", "enum": ["luhn", "mod97", "iban"], "description": "With regex: keep a match only if its checksum is valid (spaces and hyphens ignored)." },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
"overlay": { "type": "string" },
@@ -1295,7 +1297,7 @@ pub fn tools() -> Vec<ToolDef> {
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] },
"value": { "type": "string" },
}), &["doc", "object", "generation", "property"])),
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, content not found on its page, or content that a damaged or undecodable content stream may hide or change are rejected. Undoable.")
.with(schema(json!({
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
@@ -1565,14 +1567,14 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, ungroup (Form artwork only: its content is drawn in place, so its text becomes editable with text_paragraphs/text_edit and its pictures separate; refused for knockout groups and for transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's, which would composite differently, and for forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share, which would stop remapping their colours), or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "ungroup"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
@@ -1638,7 +1640,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1650,6 +1652,7 @@ pub fn tools() -> Vec<ToolDef> {
"color": { "type": "object" },
"gray": { "type": "object" },
"mono": { "type": "object" },
"target_bytes": { "type": "integer", "minimum": 1 },
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
+279
View File
@@ -4818,6 +4818,34 @@ fn redacting_through_tools() {
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn redacting_with_a_caller_regex_through_tools() {
let dir = workdir("redact-regex");
std::fs::write(
dir.join("ids.txt"),
"Ref 1 85 05 78 006 084 36 end\nRef 2 91 12 34 567 890 12 end\nCard 4539 1488 0343 6467 and 4539 1488 0343 6468",
)
.unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "ids.txt" }))["doc"].as_u64().unwrap();
let bad = |a: &mut Automation, args: Value| matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_)));
assert!(bad(&mut a, json!({ "doc": doc, "regex": "(unclosed" })));
assert!(bad(&mut a, json!({ "doc": doc, "regex": "a*" })));
assert!(bad(&mut a, json!({ "doc": doc, "regex": "x".repeat(2000) })));
assert!(bad(&mut a, json!({ "doc": doc, "regex": "x", "pattern": "ssn" })));
assert!(bad(&mut a, json!({ "doc": doc, "regex": "x", "validate": "crc" })));
assert!(bad(&mut a, json!({ "doc": doc, "find": "x", "validate": "luhn" })));
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "regex": "Z{3}" })), Err(ToolError::Failed(_))));
let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "regex": r"\d \d\d \d\d \d\d \d{3} \d{3} \d\d" }));
assert_eq!(r["marked"].as_u64(), Some(2));
let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "regex": r"\d{4}( \d{4}){3}", "validate": "luhn" }));
assert_eq!(r["marked"].as_u64(), Some(1), "only the number with a valid checksum");
ok(&mut a, "redact_apply", json!({ "doc": doc }));
let text = page_text(&mut a, doc)[0].clone();
assert!(!text.contains("85 05") && !text.contains("91 12") && !text.contains("6467"), "{text}");
assert!(text.contains("6468"), "{text}");
}
#[test]
/// Mark, apply and save remove the complete international number while retaining labels and controls.
fn redacting_italian_international_slash_numbers_through_tools() {
@@ -7604,6 +7632,63 @@ fn a_page_printed_in_strips_gets_small_through_tools() {
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "preset": "tiny" })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn optimizing_to_a_target_size() {
// Dropped at the end of the test, also when an assertion fails.
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let (w, h) = (2400u32, 1600u32);
let px: Vec<u8> = (0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
[(x * 255 / w) as u8 ^ ((x * y) & 15) as u8, (y * 255 / h) as u8, ((x + y) & 255) as u8]
})
.collect();
let mut jpeg = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95);
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(600));
enc.encode(&px, w, h, image::ExtendedColorType::Rgb8).unwrap();
std::fs::write(dir.join("photo.jpg"), &jpeg).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.jpg"] }))["doc"].as_u64().unwrap();
// Without a target nothing changes: no target fields.
let base = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "base.pdf" }));
assert!(base.get("target_reached").is_none(), "{base}");
let base_len = base["bytes_after"].as_u64().unwrap();
// A target below the default result is met by a stronger step.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "fit.pdf", "target_bytes": base_len - 1 }));
assert_eq!(r["target_reached"], true, "{r}");
assert!(r["bytes_after"].as_u64().unwrap() < base_len, "{r}");
assert!(r["target_settings"]["step"].as_u64().unwrap() >= 1, "{r}");
assert_eq!(std::fs::metadata(dir.join("fit.pdf")).unwrap().len(), r["bytes_after"].as_u64().unwrap());
// A target already met by the first attempt tries nothing more.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "easy.pdf", "target_bytes": base_len }));
assert_eq!((r["target_reached"].as_bool(), r["attempts"].as_u64()), (Some(true), Some(1)), "{r}");
// An impossible target: bounded attempts, smallest copy written, said plainly.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "tiny.pdf", "target_bytes": 1 }));
assert_eq!((r["target_reached"].as_bool(), r["attempts"].as_u64()), (Some(false), Some(6)), "{r}");
assert!(r["bytes_after"].as_u64().unwrap() < base_len, "{r}");
assert!(std::fs::metadata(dir.join("tiny.pdf")).is_ok());
// The open document is untouched by the failed attempts, and bad targets are refused.
let again = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "again.pdf" }));
assert_eq!(again["bytes_after"].as_u64().unwrap(), base_len);
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "target_bytes": 0 })), Err(ToolError::InvalidArgs(_))));
// A caller who asked for lossless images keeps them: retries only tighten the ppi, no JPEG quality.
let lossless = json!({ "compression": "zip" });
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "zip.pdf", "color": lossless, "gray": lossless, "target_bytes": 1 }));
assert_eq!(r["attempts"].as_u64(), Some(6), "{r}");
assert!(r["target_settings"]["max_ppi"].is_number() && r["target_settings"]["max_jpeg_quality"].is_null(), "{r}");
// A JPEG caller gets the quality step.
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jpg.pdf", "target_bytes": 1 }));
assert!(r["target_settings"]["max_jpeg_quality"].is_number(), "{r}");
// Settings already at the ladder's floor leave every rung equal to the first run: no retries.
let floor = json!({ "ppi": 9, "above_ppi": 9, "compression": "jpeg", "quality": 1 });
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "floor.pdf", "color": floor, "gray": floor, "target_bytes": 1 }));
assert_eq!((r["target_reached"].as_bool(), r["attempts"].as_u64()), (Some(false), Some(1)), "{r}");
// Confinement still applies.
assert!(a.call("doc_optimize", &json!({ "doc": doc, "path": "../out.pdf", "target_bytes": 1000 })).is_err());
}
#[test]
fn initial_view_through_tools() {
let dir = workdir("initial-view");
@@ -8326,6 +8411,52 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
}
/// #5149: a two-column, two-row table drawn with ruled lines reaches Word as a table with its
/// rules, not as four paragraphs one under another.
#[test]
fn exporting_a_ruled_two_by_two_table_to_word_keeps_the_table() {
let dir = workdir("ruled-table-word");
let content = "0.5 w 20 270 m 300 270 l S 20 245 m 300 245 l S 20 220 m 300 220 l S \
20 220 m 20 270 l S 160 220 m 160 270 l S 300 220 m 300 270 l S \
BT /F1 11 Tf 25 253 Td (Name) Tj 140 0 Td (Value) Tj -140 -25 Td (Alpha) Tj 140 0 Td (One) Tj ET \
BT /F1 11 Tf 20 150 Td (A paragraph after the table.) Tj ET";
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
stream.as_str(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
std::fs::write(dir.join("table.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "table.docx" }));
let zip = std::fs::read(dir.join("table.docx")).unwrap();
let mut at = 0;
let mut xml = String::new();
while zip[at..].starts_with(b"PK\x03\x04") {
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
let data = at + 30 + len;
if &zip[at + 30..data] == b"word/document.xml" {
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
break;
}
at = data + size;
}
assert_eq!(xml.matches("<w:tbl>").count(), 1, "the ruled grid is a table: {xml}");
let table = xml.split_once("<w:tbl>").and_then(|(_, t)| t.split_once("</w:tbl>")).map(|(t, _)| t).unwrap();
assert_eq!(table.matches("<w:tr>").count(), 2, "{table}");
for word in ["Name", "Value", "Alpha", "One"] {
assert_eq!(table.matches(&format!(">{word}</w:t>")).count(), 1, "{word} is a cell: {xml}");
assert_eq!(xml.matches(&format!(">{word}</w:t>")).count(), 1, "{word} is written once");
}
assert!(table.contains("w:val=\"single\""), "the drawn rules become borders: {table}");
assert!(!table.contains("A paragraph after the table."));
assert_eq!(xml.matches(">A paragraph after the table.</w:t>").count(), 1);
}
/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an
/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves.
#[test]
@@ -11063,6 +11194,154 @@ fn comment_popups_are_opened_moved_and_listed() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
/// text becomes paragraphs that `text_edit` changes, and undo brings the form back.
fn table_in_a_form_pdf() -> Vec<u8> {
let table = b"BT /F1 12 Tf 1 0 0 1 20 120 Tm (Revenue) Tj 1 0 0 1 120 120 Tm (153,300.00) Tj 1 0 0 1 20 100 Tm (Costs) Tj 1 0 0 1 120 100 Tm (382,110.00) Tj ET 0.6 G 0.5 w 15 115 m 220 115 l S";
// Two pictures with soft masks overlapping each other, the second also over the table's
// last cell: a translucent wash, and a picture whose corner pixels are see-through.
let inner = [&table[..], b" q 40 0 0 20 30 40 cm /Sig Do Q q 60 0 0 40 50 30 cm /Wash Do Q q 100 0 0 30 110 90 cm /Sig Do Q"].concat();
let rgb: Vec<u8> = std::iter::repeat_n([20u8, 60, 220], 16).flatten().collect();
let alpha: Vec<u8> = (0..16).map(|i| if i % 3 == 0 { 255 } else { 0 }).collect();
let page = b"q /Fm1 Do Q";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 160] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", page),
stream(
"<< /Type /XObject /Subtype /Form /BBox [0 0 240 160] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /Font << /F1 6 0 R >> /XObject << /Sig 7 0 R /Wash 9 0 R >> >>",
&inner,
),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 8 0 R", &rgb),
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 8", &alpha),
stream(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 10 0 R",
&[230, 40, 40].repeat(4),
),
stream("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[128, 96, 160, 64]),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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", objs.len() + 1).as_bytes());
out
}
#[test]
fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() {
let dir = workdir("ungroup");
std::fs::write(dir.join("table.pdf"), table_in_a_form_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
let paragraphs = |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"].as_array().unwrap().len();
let objects = |a: &mut Automation| ok(a, "object_list", json!({ "doc": doc, "page": 1 }))["objects"].as_array().unwrap().len();
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "one piece of artwork, no text");
let (w, h, before) = a.render_pixels(doc, 1, 2.0).unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
let (w2, h2, after) = a.render_pixels(doc, 1, 2.0).unwrap();
assert_eq!((w, h), (w2, h2));
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
assert_eq!(differing, 0, "ungrouping draws the same pixels");
assert!(paragraphs(&mut a) >= 4, "the table's text is page text now");
let images = ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }));
let kinds: Vec<_> = images["images"].as_array().unwrap().iter().map(|i| i["kind"].clone()).collect();
assert_eq!(kinds, vec![json!("image"); 3], "the pictures are separate pictures now: {images}");
let list = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
let target =
list["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().is_some_and(|t| t.contains("382,110.00"))).unwrap()["paragraph"]
.clone();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": target, "text": "400,000.00" }));
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
let s = text.to_string();
assert!(s.contains("400,000.00") && !s.contains("382,110.00"), "{s}");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back");
}
/// One 300 × 300 page that draws form `Fm1` with `page_do`; the form (with `matrix`) fills with
/// pattern `/P1` (`pattern`) in `content`, and a red outline on the page marks where it lands.
fn pattern_in_a_form_pdf(page_do: &str, matrix: &str, content: &str, pattern: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_string(),
stream_object("<<", &format!("{page_do} 1 0 0 RG 1 w 60 60 180 180 re S")),
stream_object(&format!("<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] {matrix} /Resources << /Pattern << /P1 6 0 R >> >>"), content),
pattern.to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.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", objs.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", objs.len() + 1).as_bytes());
out
}
/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that
/// uses it (ISO 32000-2 §8.7.2), so a pattern filled inside a form moved by its `/Matrix` or by a
/// `cm` before the `Do` must stay where it was when the form is drawn inline. Each case draws
/// differently in Acrobat when the placement is lost (tiles out of phase or half size, the
/// shading outside its box); ungrouped, every one paints the same pixels.
#[test]
fn ungrouping_keeps_patterns_where_the_forms_placement_put_them() {
let tiling = stream_object(
"<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >>",
"0 0 0 rg 0 0 10 10 re f 10 10 10 10 re f",
);
let shading = "<< /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [false false] >> >>";
let cases = [
("no placement", "q /Fm1 Do Q", "", "/Pattern cs /P1 scn 60 60 180 180 re f", tiling.as_str()),
("form Matrix, half a tile", "q /Fm1 Do Q", "/Matrix [1 0 0 1 10 0]", "/Pattern cs /P1 scn 50 60 180 180 re f", tiling.as_str()),
("cm doubling the form", "q 2 0 0 2 0 0 cm /Fm1 Do Q", "", "/Pattern cs /P1 scn 30 30 90 90 re f", tiling.as_str()),
("shading moved by the form Matrix", "q /Fm1 Do Q", "/Matrix [1 0 0 1 100 100]", "/Pattern cs /P1 scn 0 0 100 100 re f", shading),
];
for (what, page_do, matrix, content, pattern) in cases {
let dir = workdir("ungroup_patterns");
std::fs::write(dir.join("p.pdf"), pattern_in_a_form_pdf(page_do, matrix, content, pattern)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "p.pdf" }))["doc"].as_u64().unwrap();
let (w, h, before) = a.render_pixels(doc, 1, 1.0).unwrap();
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
let (w2, h2, after) = a.render_pixels(saved, 1, 1.0).unwrap();
assert_eq!((w, h), (w2, h2), "{what}");
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
assert_eq!(differing, 0, "{what}: ungrouping draws the same pixels, saved and reopened");
}
}
/// An indirect stream object: `dict` (without its closing `>>`) and `data`.
fn stream_object(dict: &str, data: &str) -> String {
format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// Reviewing headlessly: corrections keep the boxes, a stale or foreign review is refused, and
/// applying consumes the handle (the pages already accepted were applied once).
#[test]
+189 -36
View File
@@ -11,7 +11,9 @@ use crate::{CosError, Document, ObjRef, Object, Stream};
/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the
/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to
/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit.
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit. A
/// byte Flate reads is [`pdfcraft_filters::FLATE_READ_WORK`] steps besides, since inflating blocks
/// that make nothing takes up to 21 ns a byte: about 7 ns a step.
const IMAGE_WORK: u64 = 1 << 34;
/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a
/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored
@@ -38,9 +40,9 @@ const JPEG_WORK: u64 = 2;
/// and images can share one, so reading it again for each has to count.
const JPEG_READ_WORK: u64 = 4;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data and
/// whether it stopped at damage, or the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction.
@@ -263,45 +265,57 @@ impl DecodeBudget {
}
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
/// decoding, once the allowance is spent.
/// decoding, once the allowance is spent. A stream damaged partway gives what decoded before
/// the damage, so code that writes the stream back reads it with
/// [`DecodeBudget::decode_with_damage`] instead.
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_within_counted)
self.charge(stream, usize::MAX, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
/// past `max` bytes is an error that charges `max`, the work it took to find out.
pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
self.charge(stream, max, Stream::decoded_within_counted)
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
/// A stream written back from them loses everything after the damage, which other readers may
/// still read.
pub fn decode_with_damage(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<(Vec<u8>, bool), CosError> {
self.charge(&doc.decoding_view(stream), max, Stream::decoded_within_counted)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
/// the allowance.
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted)
/// the allowance, with its `/DecodeParms` given by reference read in `doc`.
pub fn decode_strict(&mut self, doc: &Document, stream: &Stream) -> Result<Vec<u8>, CosError> {
let strict: Decoder = |s, max| s.decoded_strict_within_counted(max).map(|(data, spent)| (data, false, spent));
self.charge(&doc.decoding_view(stream), usize::MAX, strict).map(|(data, _)| data)
}
/// Decodes `stream` within `max` and the allowance, charging what every filter stage
/// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream
/// pays for the bytes decoded before the error, so referencing a stream again and again
/// spends the allowance by the work each decode does.
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<Vec<u8>, CosError> {
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
if max < self.bytes {
let result = decode(stream, max);
let spent = match &result {
Ok((_, spent)) => *spent,
Ok((_, _, spent)) => *spent,
Err((FilterError::LimitExceeded(_), _)) => max,
Err((_, spent)) => *spent,
};
self.bytes = self.bytes.saturating_sub(spent);
return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string()));
return result.map(|(data, damaged, _)| (data, damaged)).map_err(|(e, _)| CosError::Filter(e.to_string()));
}
match decode(stream, self.bytes) {
Ok((data, spent)) => {
Ok((data, damaged, spent)) => {
self.bytes = self.bytes.saturating_sub(spent);
Ok(data)
Ok((data, damaged))
}
Err((FilterError::LimitExceeded(_), _)) => {
self.bytes = 0;
@@ -332,17 +346,24 @@ impl DecodeBudget {
self.seen.insert(r)
}
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
/// place, so the caller chooses between skipping it and giving up.
/// Each stream `contents` names, decoded against the allowance, in order, its `/DecodeParms`
/// given by reference read in `doc`. `contents` is a page's `/Contents`: one stream, or an
/// array whose non-stream entries are left out. A stream that fails to decode, or arrives
/// after the allowance is spent, is an `Err` in its place, so the caller chooses between
/// skipping it and giving up.
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect()
}
/// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway
/// ([`DecodeBudget::decode_with_damage`], which reads `/DecodeParms` given by reference).
pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec<Result<(Vec<u8>, bool), CosError>> {
match &*doc.resolve(contents) {
Object::Stream(s) => vec![self.decode(s)],
Object::Stream(s) => vec![self.decode_with_damage(doc, s, usize::MAX)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode(s)),
Object::Stream(s) => Some(self.decode_with_damage(doc, s, usize::MAX)),
_ => None,
})
.collect(),
@@ -367,6 +388,11 @@ mod image_tests {
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())
}
/// What a Flate stage pays to read `n` bytes, besides what it makes.
fn read(n: usize) -> u64 {
pdfcraft_filters::FLATE_READ_WORK * n as u64
}
/// Each image spends what it took from one allowance, and those past it are refused and
/// counted, whatever their filters. What can't be decoded here costs nothing, and fails
/// without being one of these.
@@ -413,7 +439,7 @@ mod image_tests {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")]));
let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner));
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64;
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64 + read(bomb.raw.len()) + read(inner.len());
let mut budget = ImageBudget::with_work(2 * cost + 1);
for left in [cost + 1, 1] {
assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples);
@@ -442,17 +468,17 @@ mod image_tests {
let image = behind(pdfcraft_filters::encode_flate(&alone.raw));
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64 + read(image.raw.len()));
// Past this image's limit: an error, with what finding out took paid and the rest left.
let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &bomb, 1000).is_err());
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000);
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + read(bomb.raw.len()) + 1000);
assert_eq!(budget.refused(), 0, "that image's limit isn't the run's");
// A small allowance is a limit too: it stops there, and the image is refused.
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10);
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + read(bomb.raw.len()) + 10);
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
@@ -492,7 +518,7 @@ mod image_tests {
let doc = Document::open(std::sync::Arc::new(file)).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let s = stream(&doc, image);
let filters = (raw.len() + skipped.len()) as u64;
let filters = (raw.len() + skipped.len()) as u64 + read(raw.len());
assert_eq!(spent(&doc, &s), first, "the first read undoes them");
assert_eq!(spent(&doc, &s), first - filters, "kept");
@@ -507,7 +533,7 @@ mod image_tests {
let image = jbig2(Some(Object::Dict(parms)));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals"));
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64);
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64 + read(over.len()));
assert_eq!(budget.refused(), 0);
}
@@ -528,7 +554,7 @@ mod image_tests {
dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded));
let pixels = u64::from(w) * u64::from(h);
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels;
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + read(image.raw.len()) + pixels;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize);
assert_eq!(budget.work_left(), 0, "exactly that");
@@ -553,7 +579,7 @@ mod image_tests {
};
let rows = vec![0xFFu8; 512 * 1024];
let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows));
let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024;
let cost = (flate.raw.len() + rows.len()) as u64 + read(flate.raw.len()) + 4096 * 1024;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that");
@@ -590,6 +616,36 @@ mod image_tests {
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// What Flate reads is priced for blocks that make nothing, and an image an encoder made pays
/// little for it: a scan-like picture stored in under half its size costs no more than half
/// again its bytes, samples and pixels. Redaction decodes a picture once for each page that draws it, so
/// a letterhead on hundreds of pages has to fit about as often as it did unpriced (here, at
/// most half again the cost).
#[test]
fn an_image_an_encoder_made_pays_little_for_its_reading() {
let doc = Document::new_empty();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let samples: Vec<u8> = (0..256 * 256usize)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
((i % 256) as u8 / 16) ^ (seed as u8 & 3)
})
.collect();
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(256));
dict.set(b"Height".to_vec(), Object::Int(256));
let image = Stream::flate(dict, &samples);
assert!(image.raw.len() * 2 < samples.len(), "stored smaller: {}", image.raw.len());
let unpriced = (image.raw.len() + samples.len()) as u64 + 256 * 256;
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &image, 1 << 20).unwrap();
let paid = u64::MAX - budget.work_left();
assert_eq!(paid, unpriced + read(image.raw.len()));
assert!(2 * paid <= 3 * unpriced, "{paid} steps against {unpriced}");
}
/// An image pays for the pixels its data holds, not those its size declares: one claiming
/// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the
/// images after it are decoded as they would be without it.
@@ -609,8 +665,8 @@ mod image_tests {
budget.decode(&doc, s, 1 << 24).unwrap();
u64::MAX - budget.work_left()
};
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels");
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64 + read(claims.raw.len()), "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + read(image.raw.len()) + 64 * 64, "its bytes, and its pixels");
for decode in [ImageBudget::decode, ImageBudget::decode_filters] {
let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image));
assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]);
@@ -627,7 +683,7 @@ mod image_tests {
let samples = vec![7u8; 1000];
let raw = pdfcraft_filters::encode_flate(&samples);
let chain = [(Filter::Flate, Params::default())];
let cost = (raw.len() + samples.len()) as u64;
let cost = (raw.len() + samples.len()) as u64 + read(raw.len());
let mut budget = ImageBudget::with_work(cost + 10);
assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), 10);
@@ -664,7 +720,7 @@ mod image_tests {
assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows);
let cost = u64::MAX - budget.work_left();
// The tag byte of each of its 100 rows counts, which the predictor takes off.
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64);
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64 + read(image.raw.len()));
let mut budget = ImageBudget::with_work(cost - 1);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
@@ -769,7 +825,7 @@ mod tests {
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
assert!(budget.decode_strict(stream).is_err());
assert!(budget.decode_strict(&doc, stream).is_err());
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new(10 << 20, 1);
@@ -793,14 +849,111 @@ mod tests {
let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let doc = Document::new_empty();
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_strict(&corrupt).is_err());
assert!(budget.decode_strict(&doc, &corrupt).is_err());
assert_eq!(budget.remaining(), (10 << 20) - partial);
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - partial);
}
/// A stream damaged partway decodes to what came before the damage, and says so, so that code
/// writing it back can leave it as it is. What every reader takes is no damage: a missing
/// checksum, no filter at all.
#[test]
fn a_stream_damaged_partway_says_so() {
let doc = Document::new_empty();
let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(&doc, s, max).map(|(d, damaged)| (d.len(), damaged)).ok();
let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec());
let partial = cut.decoded().map(|d| d.len()).unwrap_or(0);
assert!(partial > 0, "a cut flate stream decodes in part");
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &cut, usize::MAX), Some((partial, true)));
assert_eq!(budget.remaining(), (10 << 20) - partial);
// Within a bound of its own, under what is left.
assert_eq!(read(&mut budget, &cut, 4 << 20), Some((partial, true)));
assert_eq!(budget.decode(&cut).map(|d| d.len()).ok(), Some(partial));
let mut budget = DecodeBudget::new(10 << 20, 1);
assert_eq!(read(&mut budget, &whole, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &whole, 4 << 20), Some((1 << 20, false)));
let unchecked = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() - 4).unwrap_or_default().to_vec());
assert_eq!(read(&mut budget, &unchecked, usize::MAX), Some((1 << 20, false)));
assert_eq!(read(&mut budget, &Stream::from_raw(Dict::new(), b"0 0 m".to_vec()), usize::MAX), Some((5, false)));
// A later stage's damage is the stream's.
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec());
assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
// So is an earlier stage's, though the stage after it reads all it was given: a stored
// block of whole hex, then a block of the reserved type (RFC 1951 §3.2.3).
let readable = b"4142>";
let len = u16::try_from(readable.len()).unwrap_or_default();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
let flate_damaged = Stream::from_raw(hex.dict.clone(), raw);
assert_eq!(budget.decode_with_damage(&doc, &flate_damaged, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
}
/// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a
/// predicted stream decodes as with them direct. Of the stream, decoding copies no more than
/// its filters and their parameters.
#[test]
fn parameters_given_by_reference_are_read() {
let content = b"q 10 0 0 10 0 0 cm /Im Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let raw = Stream::flate(Dict::new(), &rows).raw;
let mut doc = Document::new_empty();
let direct: Dict = [(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect();
let by_reference = Object::Ref(doc.add(Object::Dict(direct.clone())));
let predictor = doc.add(Object::Int(12));
let inner = Object::Dict([(b"Predictor".to_vec(), Object::Ref(predictor))].into_iter().collect());
for parms in [Object::Dict(direct), by_reference, inner] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), parms.clone());
dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000]));
let s = Stream::from_raw(dict, raw.to_vec());
assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}");
assert_eq!(DecodeBudget::page().decode_strict(&doc, &s).ok(), Some(content.to_vec()), "{parms:?}");
let r = doc.add(Object::Stream(s.clone()));
let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)]));
assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}");
assert_eq!(DecodeBudget::page().decode_contents(&doc, &Object::Ref(r)), [Ok(content.to_vec())], "{parms:?}");
let view = doc.decoding_view(&s);
assert!(matches!(view, std::borrow::Cow::Borrowed(_)) || view.dict.len() == 2, "{:?}", view.dict);
}
}
/// A page's content streams each say whether they are damaged, in their places.
#[test]
fn a_pages_streams_each_say_whether_they_are_damaged() {
let mut doc = Document::new_empty();
let whole = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l S")));
let Object::Stream(s) = &*doc.get(whole) else { panic!("not a stream") };
let cut = Stream::from_raw(s.dict.clone(), s.raw.get(..s.raw.len() - 6).unwrap_or_default().to_vec());
let cut = doc.add(Object::Stream(cut));
let contents = Object::Array(vec![Object::Ref(whole), Object::Null, Object::Ref(cut)]);
let mut budget = DecodeBudget::page();
let read: Vec<_> = budget.decode_contents_with_damage(&doc, &contents).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(read, [Some(false), Some(true)]);
let one: Vec<_> =
budget.decode_contents_with_damage(&doc, &Object::Ref(cut)).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(one, [Some(true)]);
assert_eq!(budget.decode_contents(&doc, &contents).iter().filter(|r| r.is_ok()).count(), 2, "read up to the damage");
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
@@ -832,7 +985,7 @@ mod tests {
assert_eq!(budget.remaining(), (10 << 20) - charged);
assert!(budget.decode_within(&chained, 2 << 20).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
assert!(budget.decode_strict(&chained).is_ok());
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
}
}
+2 -2
View File
@@ -24,11 +24,11 @@ pub(crate) use objects::Held;
pub(crate) use reader::find;
#[cfg(test)]
use reader::rfind;
pub(crate) use state::ObjectReader;
use state::{ObjStm, Slot, Unreadable};
pub(crate) use state::{ObjectReader, held_bytes};
pub use state::{ReadState, SourceIdentity};
#[cfg(test)]
use state::{SECTION_READS, held_bytes, objstm_member};
use state::{SECTION_READS, objstm_member};
use xref::XrefIndex;
pub use xref::{Revision, XrefEntry};
+25 -8
View File
@@ -323,7 +323,29 @@ impl Document {
/// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
/// large object copied once for each key and filter.
pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
}
/// [`Document::with_direct_parms`] for decoding alone: where that copies `s`, this makes a
/// stream of `s`'s data, shared, and a dictionary of its `/Filter` and the parameters made
/// direct, all its filters read. A stream named again and again, as a page's `/Contents` may
/// list one, isn't copied whole each time.
pub(crate) fn decoding_view<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut dict = Dict::new();
if let Some(f) = s.dict.get(b"Filter") {
dict.set(b"Filter".to_vec(), f.clone());
}
dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(Stream { dict, raw: s.raw.clone() })
}
/// The `/DecodeParms` [`Document::with_direct_parms`] gives `s`, or `None` when it keeps `s`.
fn direct_parms(&self, s: &Stream) -> Option<Object> {
let p = s.dict.get(b"DecodeParms")?;
let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
let filters: Vec<bool> = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().map(crypt).collect(),
@@ -331,7 +353,7 @@ impl Document {
None => Vec::new(),
};
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
return None;
}
let mut changed = matches!(p, Object::Ref(_));
// Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
@@ -358,12 +380,7 @@ impl Document {
}
_ => one(p, &mut changed),
};
if !changed {
return Cow::Borrowed(s);
}
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
changed.then_some(made)
}
/// `o`, or the object it refers to, without copying either.
+39 -3
View File
@@ -635,10 +635,20 @@ impl Document {
// Offsets of objects that may be cross-reference streams: their dictionaries are the
// trailers of files that have no `trailer` keyword (§7.5.8).
let mut xref_streams = Vec::new();
for (num, offset, generation, body) in object_headers(&data) {
let headers = object_headers(&data);
// Where `/XRef` occurs in the file, found in one pass.
let mut xref_marks = Vec::new();
let mut mark_from = 0;
while let Some(p) = find(&data, b"/XRef", mark_from, data.len()) {
xref_marks.push(p);
mark_from = p + 1;
}
for &(num, offset, generation, body) in &headers {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
// `/XRef` within the 1024 bytes after the header (the first one that is there).
let first = xref_marks.partition_point(|&p| p < body);
if xref_marks.get(first).is_some_and(|&p| p + 5 <= body.saturating_add(1024).min(data.len()))
&& let Ok(at) = usize::try_from(offset)
{
xref_streams.push(at);
@@ -827,7 +837,33 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<
if from >= to || needle.is_empty() || to - from < needle.len() {
return None;
}
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
let hay = &hay[from..to];
// Look for the needle's rarest byte first, a plain byte scan, and compare the rest only there.
const COMMON: &[u8] = b" etaoinsrhldcumfpgwybvkxjqz";
let (k, rare) =
needle.iter().copied().enumerate().max_by_key(|&(_, b)| COMMON.iter().position(|&c| c == b).unwrap_or(COMMON.len())).unwrap_or((0, 0));
let mut i = k;
while let Some(p) = hay.get(i..).and_then(|rest| position_of(rest, rare)) {
let start = i + p - k;
if hay.get(start..start + needle.len()) == Some(needle) {
return Some(start + from);
}
i += p + 1;
}
None
}
/// The first position of `byte` in `hay`. Whole 32-byte blocks are tested without branching, which
/// the compiler turns into vector compares; only a block that holds the byte is searched.
fn position_of(hay: &[u8], byte: u8) -> Option<usize> {
const BLOCK: usize = 32;
let (blocks, rest) = hay.as_chunks::<BLOCK>();
for (i, block) in blocks.iter().enumerate() {
if block.iter().fold(false, |any, &b| any | (b == byte)) {
return block.iter().position(|&b| b == byte).map(|p| i * BLOCK + p);
}
}
rest.iter().position(|&b| b == byte).map(|p| blocks.len() * BLOCK + p)
}
pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
+1 -1
View File
@@ -335,7 +335,7 @@ const OWNED_BYTES_DEPTH: usize = 256;
/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of
/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB
/// (#5873).
pub(super) fn held_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
pub(crate) fn held_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
if depth > OWNED_BYTES_DEPTH {
return usize::MAX;
}
+26
View File
@@ -2189,3 +2189,29 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
if from >= to || needle.is_empty() || to - from < needle.len() {
return None;
}
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
};
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let alphabet = b"/XRef t\n<>0";
for _ in 0..20_000 {
let hay: Vec<u8> = (0..next() % 200).map(|_| alphabet[(next() % alphabet.len() as u64) as usize]).collect();
let needles: [&[u8]; 5] = [b"/XRef", b"trailer", b"R", b"ff", b"<<"];
let needle = needles[(next() % 5) as usize];
let from = (next() % (hay.len() as u64 + 1)) as usize;
let to = from + (next() % (hay.len() as u64 + 1 - from as u64)) as usize;
assert_eq!(find(&hay, needle, from, to), naive(&hay, needle, from, to), "{hay:?} {needle:?} {from} {to}");
}
}
+17 -6
View File
@@ -297,13 +297,18 @@ impl Stream {
/// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept
/// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter
/// stages together, and the result gives the bytes they decoded (the work done, on success
/// and on failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
/// stages together, and the result gives whether the data stopped at damage (it is then what
/// decoded before it) and the bytes the stages decoded (the work done, on success and on
/// failure).
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
let mut damaged = false;
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent))
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, partial, spent)| {
damaged = partial;
(v, spent)
})
};
self.decode_chain(max, decode)
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
@@ -337,8 +342,14 @@ impl Stream {
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within(MAX_DECODED)
}
/// Like [`Stream::decoded_strict`], bounded by `max` (and [`MAX_DECODED`]): for data that
/// will be copied elsewhere, where a damaged stream must not pass for a shorter one.
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max` and counted like
+129 -21
View File
@@ -15,7 +15,7 @@ use std::io::Write;
use std::sync::Arc;
use crate::CosError;
use crate::document::{Document, ObjectReader, XrefEntry};
use crate::document::{Document, ObjectReader, XrefEntry, held_bytes};
use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
/// Serialize one object. Deterministic: same object → same bytes.
@@ -23,9 +23,7 @@ pub fn serialize(o: &Object, out: &mut Vec<u8>) {
match o {
Object::Null => out.extend_from_slice(b"null"),
Object::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
Object::Int(i) => {
let _ = write!(out, "{i}");
}
Object::Int(i) => write_int(*i, out),
Object::Real(r) => write_real(*r, out),
Object::String(s) => write_string(s, out),
Object::Name(n) => write_name(n, out),
@@ -42,11 +40,46 @@ pub fn serialize(o: &Object, out: &mut Vec<u8>) {
Object::Dict(d) => write_dict(d, out),
Object::Stream(s) => write_stream(s, out),
Object::Ref(r) => {
let _ = write!(out, "{} {} R", r.num, r.generation);
write_digits(u64::from(r.num), out);
out.push(b' ');
write_digits(u64::from(r.generation), out);
out.extend_from_slice(b" R");
}
}
}
/// Upper-case hexadecimal digits.
const HEX: &[u8; 16] = b"0123456789ABCDEF";
/// The decimal digits of `v`, the same bytes `write!(out, "{v}")` gives, without the formatter.
fn write_digits(mut v: u64, out: &mut Vec<u8>) {
// `u64::MAX` has 20 digits, so `at` never goes below 0.
let mut buf = [0u8; 20];
let mut at = buf.len();
loop {
at -= 1;
buf[at] = b'0' + (v % 10) as u8;
v /= 10;
if v == 0 {
break;
}
}
out.extend_from_slice(&buf[at..]);
}
/// A signed integer in decimal, as `write!(out, "{i}")` writes it.
fn write_int(i: i64, out: &mut Vec<u8>) {
if i < 0 {
out.push(b'-');
}
write_digits(i.unsigned_abs(), out);
}
/// `b` as two upper-case hexadecimal digits.
fn hex_pair(b: u8) -> [u8; 2] {
[HEX[usize::from(b >> 4)], HEX[usize::from(b & 15)]]
}
fn write_real(r: f64, out: &mut Vec<u8>) {
if !r.is_finite() {
out.push(b'0');
@@ -55,7 +88,7 @@ fn write_real(r: f64, out: &mut Vec<u8>) {
// A whole number is written as an integer only within the integer range Annex C gives
// (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers.
if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) {
let _ = write!(out, "{}", r as i32);
write_int(i64::from(r as i32), out);
return;
}
// Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the
@@ -76,7 +109,8 @@ fn write_name(n: &[u8], out: &mut Vec<u8>) {
if regular {
out.push(b);
} else {
let _ = write!(out, "#{b:02X}");
out.push(b'#');
out.extend_from_slice(&hex_pair(b));
}
}
}
@@ -84,9 +118,10 @@ fn write_name(n: &[u8], out: &mut Vec<u8>) {
fn write_string(s: &PdfString, out: &mut Vec<u8>) {
let binary = s.bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
if s.hex || binary * 4 > s.bytes.len().max(1) {
out.reserve(s.bytes.len().saturating_mul(2).saturating_add(2));
out.push(b'<');
for b in &s.bytes {
let _ = write!(out, "{b:02X}");
for &b in &s.bytes {
out.extend_from_slice(&hex_pair(b));
}
out.push(b'>');
return;
@@ -105,7 +140,7 @@ fn write_string(s: &PdfString, out: &mut Vec<u8>) {
0x0C => out.extend_from_slice(b"\\f"),
b' '..=b'~' => out.push(b),
_ => {
let _ = write!(out, "\\{b:03o}");
out.extend_from_slice(&[b'\\', b'0' + (b >> 6), b'0' + ((b >> 3) & 7), b'0' + (b & 7)]);
}
}
}
@@ -275,14 +310,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
/// The objects [`write_full`] writes: everything reachable from the trailer's /Root, /Info and
/// /Encrypt and from its unknown entries, breadth-first, with the new number each gets
/// (aliases of one slot included) and, in visit order, whether it is a stream.
type SaveOrder = (HashMap<ObjRef, u32>, Vec<(ObjRef, bool)>);
/// (aliases of one slot included) and, in visit order, whether it is a stream and the parsed
/// object where the budget allowed keeping it.
type SaveOrder = (HashMap<ObjRef, u32>, Vec<(ObjRef, bool)>, Vec<Option<Arc<Object>>>);
fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveOrder, CosError> {
/// Number the objects reachable from the trailer. `retain` keeps parsed objects for the write
/// pass, up to the budget. A caller that needs only the numbers passes `false`, so the traversal
/// holds no parsed objects beyond the current one.
fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict, retain: bool) -> Result<SaveOrder, CosError> {
// Breadth-first walk from the trailer, assigning new numbers in visit order.
let mut map: HashMap<ObjRef, u32> = HashMap::new();
// Record the type while traversing so classifying packed objects needs no second parse.
let mut order: Vec<(ObjRef, bool)> = Vec::new();
// The parsed object of each entry in `order`, kept for the write pass while the budget
// allows it. Past the budget the write pass reads the object again.
let mut kept: Vec<Option<Arc<Object>>> = Vec::new();
let mut kept_budget = KeptObjects::default();
let mut queue: VecDeque<ObjRef> = VecDeque::new();
let visit_refs = |o: &Object, queue: &mut VecDeque<ObjRef>| collect_refs(o, &mut |r| queue.push_back(r));
for key in [&b"Root"[..], b"Info", b"Encrypt"] {
@@ -321,8 +364,9 @@ fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveO
map.insert(canonical, num);
order.push((canonical, matches!(*o, Object::Stream(_))));
visit_refs(&o, &mut queue);
kept.push((retain && kept_budget.admit(&o, reader.document.bytes())).then_some(o));
}
Ok((map, order))
Ok((map, order, kept))
}
/// The object numbers a full save of `doc` would write: exactly the objects [`write_full`]
@@ -331,7 +375,7 @@ fn full_save_order(reader: &mut ObjectReader, trailer_in: &Dict) -> Result<SaveO
pub fn reachable_objects(doc: &Document) -> Result<HashSet<u32>, CosError> {
let mut reader = doc.object_reader();
let trailer_in = reader.document.trailer().clone();
let (_, order) = full_save_order(&mut reader, &trailer_in)?;
let (_, order, _) = full_save_order(&mut reader, &trailer_in, false)?;
Ok(order.into_iter().map(|(r, _)| r.num).collect())
}
@@ -345,7 +389,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
let mut reader = doc.object_reader();
stamp_mod_date(&mut reader.document, opts);
let trailer_in = reader.document.trailer().clone();
let (map, order) = full_save_order(&mut reader, &trailer_in)?;
let (map, order, mut kept) = full_save_order(&mut reader, &trailer_in, true)?;
let version = reader.document.version().chars().take(3).collect::<String>();
let version = if opts.object_streams && version.as_str() < "1.5" { "1.5".to_string() } else { version };
let mut out = Vec::new();
@@ -375,14 +419,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
let mut rows: BTreeMap<u32, Row> = BTreeMap::new();
// Keep only references, not renamed copies of the entire object graph. Standalone
// objects still precede object streams, preserving the established output order.
let mut packed: Vec<(u32, ObjRef)> = Vec::new();
let mut packed: Vec<(u32, ObjRef, Option<Arc<Object>>)> = Vec::new();
for (i, (r, is_stream)) in order.iter().enumerate() {
let num = output_number(i)?;
let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(&num) && root_outside != Some(num);
if in_stream {
packed.push((num, *r));
packed.push((num, *r, kept[i].take()));
} else {
let o = reread(&mut reader, *r, *is_stream)?;
let o = written_object(&mut reader, *r, *is_stream, kept[i].take())?;
let o = renumber(&prepared(&reader.document, r.num, num, 0, &o), &map);
rows.insert(num, Row::InFile(out.len() as u64, 0));
write_indirect(num, 0, &o, &mut out);
@@ -392,9 +436,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
// individually: the whole stream is (§7.5.7, §7.6.2).
let mut next = output_number(order.len())?;
let mut batch = ObjectStreamBatch::default();
for (num, reference) in packed {
for (num, reference, parsed) in packed {
let mut encoded = Vec::new();
serialize(&renumber(&*reread(&mut reader, reference, false)?, &map), &mut encoded);
serialize(&renumber(&*written_object(&mut reader, reference, false, parsed)?, &map), &mut encoded);
if batch.count > 0 && batch.body.len().saturating_add(encoded.len()).saturating_add(1) > OBJSTM_BYTES {
batch.flush(&reader.document, &mut next, &mut rows, &mut out)?;
}
@@ -428,6 +472,38 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
Ok(out)
}
/// What `write_full` keeps of the objects it parsed while numbering them, measured with
/// [`held_bytes`] (the parsed tree and any data that is not a view into the file). Beyond the
/// budget objects are read again when written, which bounds the memory of a save of a huge
/// document.
#[derive(Default)]
struct KeptObjects {
bytes: usize,
}
impl KeptObjects {
const BYTES: usize = 64 * 1024 * 1024;
/// Whether `object` still fits the budget; if so it is counted against it.
fn admit(&mut self, object: &Object, data: &Arc<Vec<u8>>) -> bool {
let bytes = self.bytes.saturating_add(held_bytes(object, data, 0));
if bytes > Self::BYTES {
return false;
}
self.bytes = bytes;
true
}
}
/// The object to write for `r`: the one parsed while numbering when it was kept, else `r` read
/// again.
fn written_object(reader: &mut ObjectReader, r: ObjRef, is_stream: bool, kept: Option<Arc<Object>>) -> Result<Arc<Object>, CosError> {
match kept {
Some(o) => Ok(o),
None => reread(reader, r, is_stream),
}
}
/// Assemble the full rewrite's trailer and apply its identifier policy.
fn full_save_trailer(doc: &Document, trailer_in: &Dict, map: &HashMap<ObjRef, u32>, opts: &SaveOptions, out: &[u8]) -> Dict {
let mut trailer = Dict::new();
@@ -935,6 +1011,38 @@ mod tests {
Lexer::new(&out, 0).object().expect("parses")
}
#[test]
fn kept_objects_stop_at_their_byte_budget() {
let data = Arc::new(Vec::new());
let string = Object::String(PdfString::literal(vec![b'x'; 1000]));
let array = Object::Array(vec![Object::Int(1), Object::Null]);
let mut kept = KeptObjects::default();
assert!(kept.admit(&string, &data) && kept.admit(&array, &data));
assert_eq!(kept.bytes, held_bytes(&string, &data, 0) + held_bytes(&array, &data, 0));
let mut full = KeptObjects { bytes: KeptObjects::BYTES - held_bytes(&string, &data, 0) + 1 };
let before = full.bytes;
assert!(!full.admit(&string, &data), "one byte over the budget");
assert_eq!(full.bytes, before, "a refused object is not counted");
let mut deep = Object::Null;
for _ in 0..300 {
deep = Object::Array(vec![deep]);
}
assert!(!KeptObjects::default().admit(&deep, &data), "nesting past the limit is never kept");
}
#[test]
fn kept_objects_count_names_and_dictionary_keys() {
let data = Arc::new(Vec::new());
let name = Object::Name(vec![b'n'; KeptObjects::BYTES + 1]);
let mut kept = KeptObjects::default();
assert!(!kept.admit(&name, &data), "a name over the byte budget is not kept");
assert_eq!(kept.bytes, 0);
let mut dict = Dict::new();
dict.set(vec![b'k'; KeptObjects::BYTES + 1], Object::Null);
assert!(!kept.admit(&Object::Dict(dict), &data), "nor a dictionary key");
assert_eq!(kept.bytes, 0);
}
#[test]
fn a_second_read_that_differs_fails_the_save_instead_of_writing_null() {
let mut doc = Document::new_empty();
+1
View File
@@ -5,5 +5,6 @@ mod corpus;
mod encryption;
mod generations;
mod proptests;
mod serialize_format;
mod shared_streams;
mod writer;
+116
View File
@@ -0,0 +1,116 @@
//! `serialize` writes integers, references, hexadecimal strings, octal escapes and `#xx` name
//! escapes with its own digit loops. They must give the bytes the standard formatter gives.
use pdfcraft_cos::{ObjRef, Object, PdfString, serialize};
use proptest::prelude::*;
fn bytes_of(o: &Object) -> Vec<u8> {
let mut v = Vec::new();
serialize(o, &mut v);
v
}
#[test]
fn integer_extremes_and_digit_boundaries() {
let mut cases = vec![0i64, 1, -1, 9, 10, 99, 100, i64::MAX, i64::MIN, i64::MIN + 1, i64::from(i32::MAX), i64::from(i32::MIN)];
let mut p = 1i64;
while let Some(next) = p.checked_mul(10) {
cases.extend([next - 1, next, next + 1, -next]);
p = next;
}
for i in cases {
assert_eq!(bytes_of(&Object::Int(i)), i.to_string().into_bytes(), "{i}");
}
}
#[test]
fn reference_extremes() {
for (n, g) in [(0, 0), (1, 0), (u32::MAX, u16::MAX), (10, 65535), (4_000_000_000, 7)] {
assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes());
}
}
#[test]
fn every_byte_value_in_hex_strings_octal_escapes_and_names() {
let all: Vec<u8> = (0..=255).collect();
let hex: String = all.iter().map(|b| format!("{b:02X}")).collect();
assert_eq!(bytes_of(&Object::String(PdfString { bytes: all.clone(), hex: true })), format!("<{hex}>").into_bytes());
// A literal string with a few control bytes escapes them in octal.
let lit = bytes_of(&Object::String(PdfString { bytes: b"abcdefghijkl\x01\x7f\xffz".to_vec(), hex: false }));
assert_eq!(lit, b"(abcdefghijkl\\001\\177\\377z)");
let name = bytes_of(&Object::Name(all.clone()));
let mut want = b"/".to_vec();
for &b in &all {
let regular = (b'!'..=b'~').contains(&b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'#');
if regular {
want.push(b);
} else {
want.extend_from_slice(format!("#{b:02X}").as_bytes());
}
}
assert_eq!(name, want);
}
#[test]
fn whole_real_at_i32_min_stays_a_real_token() {
// -2^31 is outside the Annex C integer range (±2^31-1), so it keeps its trailing point.
assert_eq!(bytes_of(&Object::Real(f64::from(i32::MIN))), b"-2147483648.");
}
proptest! {
#[test]
fn integers_match_the_formatter(i in any::<i64>()) {
prop_assert_eq!(bytes_of(&Object::Int(i)), format!("{i}").into_bytes());
}
#[test]
fn whole_reals_match_the_formatter(i in -i32::MAX..=i32::MAX) {
prop_assert_eq!(bytes_of(&Object::Real(f64::from(i))), format!("{i}").into_bytes());
}
#[test]
fn references_match_the_formatter(n in any::<u32>(), g in any::<u16>()) {
prop_assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes());
}
#[test]
fn hex_strings_match_the_formatter(bytes in proptest::collection::vec(any::<u8>(), 0..300)) {
let mut want = String::from("<");
for b in &bytes {
want.push_str(&format!("{b:02X}"));
}
want.push('>');
prop_assert_eq!(bytes_of(&Object::String(PdfString { bytes, hex: true })), want.into_bytes());
}
#[test]
fn binary_literal_strings_match_the_formatter(bytes in proptest::collection::vec(any::<u8>(), 0..300)) {
// Mostly-binary input is written as hex; mostly-text input with escapes is a literal.
let got = bytes_of(&Object::String(PdfString { bytes: bytes.clone(), hex: false }));
let binary = bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
let mut want = Vec::new();
if binary * 4 > bytes.len().max(1) {
want.push(b'<');
for b in &bytes {
want.extend_from_slice(format!("{b:02X}").as_bytes());
}
want.push(b'>');
} else {
want.push(b'(');
for &b in &bytes {
match b {
b'(' | b')' | b'\\' => want.extend_from_slice(&[b'\\', b]),
b'\n' => want.extend_from_slice(b"\\n"),
b'\r' => want.extend_from_slice(b"\\r"),
b'\t' => want.extend_from_slice(b"\\t"),
0x08 => want.extend_from_slice(b"\\b"),
0x0C => want.extend_from_slice(b"\\f"),
b' '..=b'~' => want.push(b),
_ => want.extend_from_slice(format!("\\{b:03o}").as_bytes()),
}
}
want.push(b')');
}
prop_assert_eq!(got, want);
}
}
+21 -9
View File
@@ -183,12 +183,17 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
// a negative.
let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk));
// a negative. The space is read once: an indexed one's lookup table is paid for each time.
let read = (jpeg && !inverted).then(|| space(doc, s.dict.get(b"ColorSpace"), budget));
let as_is_negative = matches!(read, Some(Ok(Space::Cmyk)));
if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {
return as_is();
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let mut space = match read {
_ if mask => Space::Gray,
Some(read) => read?,
None => space(doc, s.dict.get(b"ColorSpace"), budget)?,
};
let (samples, bpc) = if jpeg {
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) {
return Err(FilterError::Spent.to_string());
@@ -436,7 +441,10 @@ impl Space {
/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that.
const MAX_LOOKUP: usize = 64 << 10;
fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
/// The colour space `cs` names; an indexed one's lookup table is read within `budget`, as the
/// image's samples are, since each image reads it again and its filters can do work that makes
/// nothing.
fn space(doc: &Document, cs: Option<&Object>, budget: &mut ImageBudget) -> Result<Space, String> {
let Some(cs) = cs else { return Err("the image has no colour space".into()) };
let cs = doc.resolve(cs);
let unsupported = |n: &[u8]| Err(format!("{} images can't be exported yet", String::from_utf8_lossy(n)));
@@ -470,7 +478,7 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
if nested {
return Err("nested indexed colour spaces aren't valid".into());
}
let base = space(doc, a.get(1))?;
let base = space(doc, a.get(1), budget)?;
let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() {
Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8,
_ => return Err("an indexed colour space with an invalid hival".into()),
@@ -478,10 +486,14 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
// Through its parameters given by reference too, as the image's samples.
Some(Object::Stream(t)) => doc
.with_direct_parms(t)
.decoded_within(MAX_LOOKUP)
.map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?,
Some(Object::Stream(t)) => {
let refused = budget.refused();
match budget.decode_filters(doc, t, MAX_LOOKUP) {
Ok(table) => table,
Err(_) if budget.refused() > refused => return Err(FilterError::Spent.to_string()),
Err(_) => return Err("an indexed colour space with an oversized or damaged lookup table".into()),
}
}
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
+92
View File
@@ -2409,6 +2409,98 @@ fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() {
assert_eq!(budget.refused(), 1);
}
/// An indexed image's lookup table, given as a stream, is read within the export's allowance as
/// the image's samples are: each image reads it again, and its filters can do work that makes
/// nothing (Flate over empty blocks). Past the allowance the image is left out with the reason.
#[test]
fn a_lookup_table_is_read_within_the_exports_allowance() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
let table = [255, 0, 0, 0, 0, 255];
let lookup = Stream::flate(Dict::new(), &table);
let exported = |as_stream: bool, work: u64| {
let mut doc = Document::new_empty();
let table = if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) };
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(2));
dict.set(b"Height".to_vec(), Object::Int(1));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
let image = doc.add(Object::Stream(Stream::flate(dict, &[0, 1])));
let mut budget = ImageBudget::with_work(work);
let file = crate::extract::image_file_within(&doc, image, &mut budget);
(file, work - budget.work_left(), budget.refused())
};
let (direct, image_alone, _) = exported(false, u64::MAX);
let (streamed, both, _) = exported(true, u64::MAX);
assert_eq!(streamed.unwrap(), direct.unwrap(), "the same picture");
let read = (lookup.raw.len() + table.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * lookup.raw.len() as u64;
assert_eq!(both, image_alone + read, "the table is paid for as it is read");
let (refused, _, count) = exported(true, image_alone);
assert!(refused.unwrap_err().contains("more image decoding than"));
assert_eq!(count, 1);
}
/// A JPEG's colour space is read once for an export, its lookup table paid for once: whether it is
/// CMYK (a negative as is) and what its samples are both come from that reading. An indexed JPEG
/// isn't valid, and is written as it is, but its soft mask makes it go on past the first. With the
/// mask, a table the allowance can't pay for leaves the image out, with the reason.
#[test]
fn a_jpegs_lookup_table_is_paid_for_once() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
let table = [255, 0, 0, 0, 0, 255];
let lookup = Stream::flate(Dict::new(), &table);
let mut jpeg = Vec::new();
image::GrayImage::from_pixel(2, 1, image::Luma([1])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
// `given`: None leaves the colour space out, Some(true) gives the table as a stream.
let exported = |given: Option<bool>, work: u64| {
let mut doc = Document::new_empty();
let table = given.map(|as_stream| {
if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) }
});
let mut mask = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
mask.set(k.as_bytes().to_vec(), v);
}
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let mask = doc.add(Object::Stream(Stream::flate(mask, &[255, 128])));
let mut dict = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
dict.set(k.as_bytes().to_vec(), v);
}
if let Some(table) = table {
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
}
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
dict.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(dict, jpeg.clone())));
let mut budget = ImageBudget::with_work(work);
let file = crate::extract::image_file_within(&doc, image, &mut budget);
(file, work - budget.work_left(), budget.refused())
};
let (direct, direct_cost, _) = exported(Some(false), u64::MAX);
let (streamed, streamed_cost, _) = exported(Some(true), u64::MAX);
assert_eq!(direct, Ok(("jpg", jpeg.clone())), "written as it is");
assert_eq!(streamed, direct);
let read = (lookup.raw.len() + table.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * lookup.raw.len() as u64;
assert_eq!(streamed_cost, direct_cost + read, "one reading of the table");
// Without a colour space nothing is read after the soft mask, so this is what the mask costs.
let (none, mask, _) = exported(None, u64::MAX);
assert_eq!(none.unwrap_err(), "the image has no colour space");
// Read first, the table would be refused a step short of the mask too, and the mask after it.
assert!(read >= mask, "the table costs at least what the mask does");
assert_eq!(exported(Some(true), mask - 1).2, 1, "the mask is read first, and refused alone");
let (refused, _, count) = exported(Some(true), mask + read - 1);
let refused = refused.unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(count, 1, "refused at the table, and nothing after it asks to be paid for");
}
/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that
/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without
/// its transparency otherwise.
+4 -4
View File
@@ -208,7 +208,7 @@ pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result
let same = |w: &ObjRef| match &*doc.get(*w) {
Object::Stream(s) => {
[TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok().as_deref() == Some(bytes.as_slice())
}
_ => false,
};
@@ -248,7 +248,7 @@ fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> O
return None;
}
let hash = note.get(b"Before")?.as_string()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(doc, s).ok()?;
let parsed = pdfcraft_content::parse(head(&bytes));
// The `n` standing in for the fill, after exactly the operators that came before it.
let fill = parsed.ops.get(index)?;
@@ -384,11 +384,11 @@ fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>)
if b.len() > SCAN_BYTES.saturating_sub(total) {
return None;
}
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
budget.decode_strict(doc, &Stream::from_raw(Dict::new(), b.to_vec())).ok()?
}
// Tolerant decoding can return an empty or partial stream after corruption.
// That cannot prove which paint preceded the candidate backdrop.
_ => budget.decode_strict(s).ok()?,
_ => budget.decode_strict(doc, s).ok()?,
};
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
return None;
+2 -2
View File
@@ -1,7 +1,7 @@
//! A page's background colour: the colour the page is painted with before anything else, as
//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992).
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_content::{Matrix, Op};
use pdfcraft_cos::{Dict, Document};
use crate::{EditError, Rgb};
@@ -51,7 +51,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) };
+82 -47
View File
@@ -1,15 +1,15 @@
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
//! delete, or ungroup grouped artwork (its content drawn in place, see `ungroup`). Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
use crate::text::content_streams;
use crate::text::{ContentStream, content_streams, joined};
/// The identity of a soft mask (`/SMask`) in the PDF graphics state.
#[derive(Clone, Debug)]
@@ -47,7 +47,7 @@ pub struct PageImage {
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
/// operators of the page's streams joined ([`pdfcraft_content::Pieces`]).
pub stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -103,11 +103,6 @@ fn too_many_images() -> EditError {
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -470,6 +465,9 @@ pub enum ImageChange {
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
/// Grouped Form artwork only: draw the form's content in place of its `Do`, so its text and
/// pictures become the page's own and can be edited one by one (#2964).
Ungroup,
}
/// The user-space transform that maps box `from` onto box `to` (move and resize).
@@ -532,12 +530,17 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
/// None when its `Q` is at or past `hidden` ([`crate::text::first_op_after_damage`]): a stream
/// between that wasn't read may draw in that `cm` too.
fn enclosing_transform(ops: &[Op], start: usize, end: usize, hidden: usize) -> Option<(usize, Matrix)> {
let at = start.checked_sub(1)?;
let cm = ops.get(at)?;
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
return None;
}
if end.checked_add(1)? >= hidden {
return None;
}
let m = Matrix(cm.nums::<6>()?);
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
}
@@ -1057,7 +1060,9 @@ fn determine_arrange_spans(
has_other_paint: bool,
marked_unit: bool,
) -> Result<(std::ops::Range<usize>, usize, usize, usize), EditError> {
let scope = image_scopes(ops, target, "arranged")
// Nothing is hidden for Arrange: it takes out the scope it finds, so a scope that closes past a
// stream not read whole changes a stream past it, which `rewritten_contents` refuses.
let scope = image_scopes(ops, target, "arranged", ops.len())
.ok()
.and_then(|s| s.isolated)
.filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end);
@@ -1191,13 +1196,7 @@ fn build_relocated_unit_ops(
}
/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`.
fn arrange_image(
doc: &mut Document,
p: &pdfcraft_model::Page,
all: &[(Object, Vec<u8>)],
img: &PageImage,
order: ArrangeOrder,
) -> Result<(), EditError> {
fn arrange_image(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage, order: ArrangeOrder) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
@@ -1264,7 +1263,7 @@ fn arrange_image(
ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start))
};
let owned_destination = destination_piece
.filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
.filter(|piece| all.get(*piece).is_some_and(|s| doc.resolve(&s.entry).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some())));
if let Some(piece) = owned_destination {
let whole_unit = ops.iter().enumerate().all(|(index, op)| {
let (first, last) = pieces.pieces_of(op);
@@ -1439,13 +1438,14 @@ fn arrange_image(
};
if let Some(piece) = owned_destination {
let insertion = piece.saturating_add(usize::from(insert_after));
crate::text::drawn_before_damage(all, insertion)?;
let changed = pieces.splice([(target_span, removal_replacement)]);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
let mut list = match contents {
Object::Array(list) => list,
one => vec![one],
};
let insertion = piece.saturating_add(usize::from(insert_after));
if insertion > list.len() {
return Err(EditError::Invalid("the destination content stream is missing".into()));
}
@@ -1467,7 +1467,7 @@ fn arrange_image(
}
let changed = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1501,8 +1501,10 @@ impl ImageScopes {
}
}
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`.
fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, EditError> {
/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. A scope
/// still open at op `hidden` ([`crate::text::first_op_after_damage`]) holds a stream that wasn't
/// read, which may paint there: it counts as painting something else.
fn image_scopes(ops: &[Op], target: usize, done: &str, hidden: usize) -> Result<ImageScopes, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
@@ -1521,6 +1523,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, Ed
let mut isolated = None;
let mut outermost = None;
for (index, op) in ops.iter().enumerate() {
if index == hidden {
for scope in &mut stack {
scope.other_paint = true;
}
}
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
@@ -1606,20 +1613,26 @@ fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec<std::ops::Ran
}
/// Remove only crops written by PdfCraft, preserving the document author's clipping paths.
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec<u8>)], img: &PageImage) -> Result<(), EditError> {
fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[ContentStream], img: &PageImage) -> Result<(), EditError> {
let pieces = joined(all);
let ops = pieces.parse().ops;
let target = img.op;
let scope = image_scopes(&ops, target, "cropped")?;
let Some((start, _, _)) = scope.transform_scope(target) else {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops = |hidden| -> Result<Vec<std::ops::Range<usize>>, EditError> {
let scope = image_scopes(&ops, target, "cropped", hidden)?;
Ok(scope.transform_scope(target).map(|(start, _, _)| app_crop_ranges(&ops, start, target)).unwrap_or_default())
};
let crops = app_crop_ranges(&ops, start, target);
if crops.is_empty() {
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
let crops_there = crops(crate::text::first_op_after_damage(all, &pieces, &ops))?;
if crops_there.is_empty() {
// A crop in a scope that closes past a stream not read whole may clip what that stream draws.
return Err(EditError::Invalid(if crops(ops.len())?.is_empty() {
"the image has no PdfCraft crop to remove".into()
} else {
"the image's crop may also clip what a content stream that is damaged or can't be decoded draws, so it isn't removed".into()
}));
}
let crops = crops_there;
let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index))));
let contents = crate::text::rewritten_contents(doc, all, changed);
let contents = crate::text::rewritten_contents(doc, all, changed)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1650,6 +1663,25 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
ImageChange::Crop(keep) => {
return crop_image_impl(doc, &p, &all, &img, *keep);
}
ImageChange::Ungroup => {
if !img.is_form {
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
}
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
let space = crate::ungroup::page_blend_space(doc, &p.dict);
let (inline, mut merged, patterns) =
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?;
if !patterns.is_empty() {
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (name, copy) in patterns {
table.set(name, Object::Ref(doc.add(copy)));
}
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
resources = Some(merged);
inline
}
ImageChange::Replace(r) => {
if img.is_form {
return Err(EditError::Invalid(
@@ -1666,7 +1698,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
}
};
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
let contents = crate::text::rewritten_contents(doc, &all, changed)?;
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
@@ -1687,7 +1719,7 @@ pub(crate) fn selectable_images(
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
.filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.filter(|(_, image)| !all.get(image.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r)))
.collect())
}
@@ -1700,7 +1732,7 @@ pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: us
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
transform: [f64; 6],
) -> Result<(), EditError> {
@@ -1715,8 +1747,9 @@ fn transform_image(
}
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let hidden = crate::text::first_op_after_damage(streams, &pieces, &ops);
let (start, end, placement) =
image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
image_scopes(&ops, target, "transformed", hidden)?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let local = placement.then(&Matrix(transform)).then(&inverse);
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
@@ -1725,7 +1758,7 @@ fn transform_image(
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
if let Some((at, cm)) = enclosing_transform(&ops, start, end, hidden) {
let folded = local.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
@@ -1733,7 +1766,7 @@ fn transform_image(
let changed = crate::text::splice(&pieces, &ops, |index| {
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], false) } else { (Vec::new(), true) }
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
return Ok(());
}
@@ -1754,7 +1787,7 @@ fn transform_image(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1766,7 +1799,7 @@ fn transform_image(
fn crop_image_impl(
doc: &mut Document,
page: &pdfcraft_model::Page,
streams: &[(Object, Vec<u8>)],
streams: &[ContentStream],
image: &PageImage,
keep: [f64; 4],
) -> Result<(), EditError> {
@@ -1790,8 +1823,9 @@ fn crop_image_impl(
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
let (start, end, placement) =
image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
let (start, end, placement) = image_scopes(&ops, target, "cropped", crate::text::first_op_after_damage(streams, &pieces, &ops))?
.transform_scope(target)
.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
@@ -1844,7 +1878,7 @@ fn crop_image_impl(
(Vec::new(), true)
}
});
let contents = crate::text::rewritten_contents(doc, streams, changed);
let contents = crate::text::rewritten_contents(doc, streams, changed)?;
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
@@ -1872,6 +1906,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
}
let pieces = joined(&all);
let ops = pieces.parse().ops;
let hidden = crate::text::first_op_after_damage(&all, &pieces, &ops);
let mut inside_text = false;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
@@ -1889,7 +1924,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
let mut folds = std::collections::BTreeMap::new();
for image in images.iter().filter(|image| image.has_editor_crop()) {
let Some(&(_, _, offset)) = plans.get(&image.op) else { continue };
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else {
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved", hidden)?.transform_scope(image.op) else {
return Err(EditError::Invalid("the image crop cannot be moved independently".into()));
};
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
@@ -1906,7 +1941,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
};
// An enclosing cm is before the captured placement; an inside-scope cm is after it.
// Their multiplication orders differ when that cm scales or shears a translation.
let folded = enclosing_transform(&ops, start, end)
let folded = enclosing_transform(&ops, start, end, hidden)
.map(|(at, cm)| (at, local.then(&cm)))
.or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local))));
if let Some((at, folded)) = folded {
@@ -1942,7 +1977,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index, hidden) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
@@ -1977,7 +2012,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
let contents = crate::text::rewritten_contents(doc, &all, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+3
View File
@@ -43,12 +43,15 @@ pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
pub use text::strip_subset_tag;
mod background;
pub use background::page_background;
mod rules;
pub use rules::page_rules;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
mod ungroup;
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
+14 -9
View File
@@ -4,8 +4,9 @@
use std::collections::{BTreeMap, BTreeSet};
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::text::ContentStream;
use crate::{Content, EditError};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
@@ -152,7 +153,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeome
return Ok((Pieces::default(), Vec::new()));
}
let streams = checked_streams(doc, page)?;
let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect();
let mut geometry = BTreeMap::new();
@@ -161,7 +162,7 @@ fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec<AddedGeome
let mut stack = Vec::new();
for (index, op) in ops.iter().enumerate() {
let (stream, end) = pieces.pieces_of(op);
let object = streams.get(stream).and_then(|(r, _)| r.as_ref());
let object = streams.get(stream).and_then(|s| s.entry.as_ref());
if object.is_some_and(|r| owned.contains(&r)) {
if let std::collections::btree_map::Entry::Vacant(entry) = first.entry(stream) {
if stream != end || !op.is("q") {
@@ -275,7 +276,7 @@ fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [
})
.collect::<Result<Vec<_>, EditError>>()?;
let data = pieces.splice(edits);
let contents = crate::text::rewritten_contents(doc, &streams, data);
let contents = crate::text::rewritten_contents(doc, &streams, data)?;
let refs = match &contents {
Object::Array(a) => a.clone(),
one => vec![one.clone()],
@@ -288,9 +289,9 @@ fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [
Ok(())
}
/// A missing or undecodable preceding stream can hide graphics state that affects every
/// A missing, undecodable or damaged preceding stream can hide graphics state that affects every
/// later item. Refuse group editing rather than inventing an inventory from partial data.
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
fn checked_streams(doc: &Document, page: usize) -> Result<Vec<ContentStream>, EditError> {
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let contents = match p.dict.get(b"Contents") {
None => Vec::new(),
@@ -305,6 +306,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
},
};
let mut remaining = MAX_CONTENT_BYTES;
let mut decode = DecodeBudget::new(usize::MAX, 0);
let mut depth = 0usize;
let mut operations = 0usize;
let mut streams = Vec::with_capacity(contents.len());
@@ -313,9 +315,12 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
let Object::Stream(stream) = &*resolved else {
return Err(EditError::Invalid("a page content stream is missing".into()));
};
let bytes = stream
.decoded_within(remaining.min(64 * 1024 * 1024))
let (bytes, damaged) = decode
.decode_with_damage(doc, stream, remaining.min(64 * 1024 * 1024))
.map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?;
if damaged {
return Err(EditError::Invalid("a page content stream is damaged partway".into()));
}
remaining =
remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?;
let parsed = pdfcraft_content::parse(&bytes);
@@ -333,7 +338,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<(Object, Vec<u8>)>
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
}
streams.push((object, bytes));
streams.push(ContentStream { entry: object, data: bytes, read: crate::text::Read::Whole });
}
Ok(streams)
}
+142
View File
@@ -0,0 +1,142 @@
//! A page's ruled lines: the horizontal and vertical strokes (and thin filled bars) its content
//! draws, so exports can tell a ruled table from text that merely lines up (#5149).
use pdfcraft_content::Matrix;
use pdfcraft_cos::Document;
use crate::EditError;
/// How deep `q` may nest (deeper saves are not tracked).
const MAX_SAVES: usize = 64;
/// At most this many rules are returned, and a path keeps at most this many segments, so a
/// hostile page stays bounded.
pub const MAX_RULES: usize = 4096;
/// A segment whose ends differ by at most this much across its run (points) is horizontal or
/// vertical; a filled rectangle at most this thick is a bar drawn as a rule.
const THIN: f64 = 2.0;
/// The page's ruled lines, each as its box `[x0, y0, x1, y1]` in default user space (`x0 <= x1`,
/// `y0 <= y1`; a stroke has zero thickness): horizontal and vertical straight segments of stroked
/// paths (rectangle edges included) and filled rectangles at most two points thick. Curves,
/// slanted lines and paths that are only clipped are left out, and so are the contents of forms.
/// At most [`MAX_RULES`] rules, in content order.
pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditError> {
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut out: Vec<[f64; 4]> = Vec::new();
let mut ctm = Matrix::IDENTITY;
let mut saved: Vec<Matrix> = Vec::new();
let mut lost_saves = 0usize;
// The current path in page space: its straight segments, its rectangles, the current point
// and where the current subpath started.
let mut segments: Vec<[(f64, f64); 2]> = Vec::new();
let mut rects: Vec<[(f64, f64); 4]> = Vec::new();
let mut at: Option<(f64, f64)> = None;
let mut start: Option<(f64, f64)> = None;
for op in &ops {
if out.len() >= MAX_RULES {
break;
}
match op.op.as_slice() {
b"q" => {
if saved.len() < MAX_SAVES {
saved.push(ctm);
} else {
lost_saves = lost_saves.saturating_add(1);
}
}
b"Q" => {
if lost_saves > 0 {
lost_saves -= 1;
} else if let Some(m) = saved.pop() {
ctm = m;
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
ctm = m.then(&ctm);
}
}
b"m" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
(at, start) = (p, p);
}
b"l" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
if let (Some(a), Some(b)) = (at, p)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = p;
}
b"c" => at = op.nums::<6>().map(|[.., x, y]| ctm.apply(x, y)),
b"v" | b"y" => at = op.nums::<4>().map(|[.., x, y]| ctm.apply(x, y)),
b"h" => {
if let (Some(a), Some(b)) = (at, start)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = start;
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
let q = [ctm.apply(x, y), ctm.apply(x + w, y), ctm.apply(x + w, y + h), ctm.apply(x, y + h)];
if rects.len() < MAX_RULES {
rects.push(q);
}
(at, start) = (Some(q[0]), Some(q[0]));
}
}
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => {
let closes = matches!(op.op.as_slice(), b"s" | b"b" | b"b*");
if closes && let (Some(a), Some(b)) = (at, start) {
segments.push([a, b]);
}
let edges = rects.iter().flat_map(|q| [[q[0], q[1]], [q[1], q[2]], [q[2], q[3]], [q[3], q[0]]]);
out.extend(segments.iter().copied().chain(edges).filter_map(axis_aligned));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"f" | b"F" | b"f*" => {
out.extend(rects.iter().filter_map(thin_bar));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"n" => (segments, rects, at, start) = (Vec::new(), Vec::new(), None, None),
_ => {}
}
}
out.truncate(MAX_RULES);
Ok(out)
}
/// The box of a horizontal or vertical segment, or `None` for a slanted, tiny or non-finite one.
fn axis_aligned([a, b]: [(f64, f64); 2]) -> Option<[f64; 4]> {
let r = [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}
/// The box of a filled rectangle drawn as a rule: upright, at most [`THIN`] thick and longer than that.
fn thin_bar(q: &[(f64, f64); 4]) -> Option<[f64; 4]> {
let upright = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() <= 0.01 || (a.1 - b.1).abs() <= 0.01;
if !(upright(q[0], q[1]) && upright(q[1], q[2])) {
return None;
}
let xs = q.map(|p| p.0);
let ys = q.map(|p| p.1);
let r = [
xs.iter().copied().fold(f64::INFINITY, f64::min),
ys.iter().copied().fold(f64::INFINITY, f64::min),
xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -55,7 +55,10 @@ use fallback::{add_type3_font, is_type3_fallback};
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
use interp::{Origin, Paint, union};
use lines::page_dict;
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice};
pub(crate) use lines::{
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
joined, resource_dict, rewritten_contents, splice,
};
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
+4 -4
View File
@@ -1,7 +1,7 @@
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::lines::{content_streams, joined, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
use super::{Document, EditError, HashMap, Object, Op, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
@@ -270,7 +270,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
@@ -297,7 +297,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
patches.insert(s.op, replacement);
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+132 -23
View File
@@ -61,16 +61,37 @@ impl TextLine {
}
}
/// The page's content streams as listed, each with its decoded data (empty when it doesn't
/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading
/// or rewriting a page from part of its content would lose the rest.
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
/// One entry of a page's `/Contents`, and what it decoded to.
pub(crate) struct ContentStream {
/// The entry as listed.
pub(crate) entry: Object,
/// Its decoded data, empty when it doesn't decode.
pub(crate) data: Vec<u8>,
/// How much of it decoded.
pub(crate) read: Read,
}
/// How much of a content stream decoded ([`rewritten_contents`] says what each allows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the graphics state and syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// The page's content streams as listed, each with its decoded data, within one page's
/// [`DecodeBudget`]. An error when the content runs past it: reading or rewriting a page from
/// part of its content would lose the rest.
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<ContentStream>, EditError> {
content_streams_within(doc, page, &mut DecodeBudget::page())
}
/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on
/// spending.
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<ContentStream>, EditError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -79,20 +100,26 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
},
};
let mut out = Vec::with_capacity(list.len());
for o in list {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => match budget.decode(s) {
Ok(data) => data,
for entry in list {
let (data, read) = match &*doc.resolve(&entry) {
Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
},
_ => Vec::new(),
// Nothing to lose: a null or missing entry draws nothing.
_ => (Vec::new(), Read::Whole),
};
out.push((o, data));
out.push(ContentStream { entry, data, read });
}
Ok(out)
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
pub(crate) fn joined(streams: &[ContentStream]) -> Pieces {
Pieces::join(&streams.iter().map(|s| s.data.as_slice()).collect::<Vec<_>>())
}
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it.
/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split
@@ -120,26 +147,108 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) ->
pieces.splice(edits)
}
/// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn
/// after a stream not read whole, damaged partway or not decoding at all: what wasn't read can
/// change how it reads, the graphics state it draws in among it.
pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> {
if at > first_damaged(streams) {
return Err(EditError::Invalid(
"this change comes after a content stream that is damaged or can't be decoded, and what wasn't read can change how the rest of the page reads"
.into(),
));
}
Ok(())
}
/// The index of the first stream not read whole, or the number of streams.
fn first_damaged(streams: &[ContentStream]) -> usize {
streams.iter().position(|s| s.read != Read::Whole).unwrap_or(streams.len())
}
/// The index of the first of `ops`, parsed from `pieces` (the [`joined`] `streams`), drawn after a
/// stream not read whole, or `ops.len()`. A `q … Q` still open there may hold what wasn't read,
/// painting in the state it sets. An operator counts by its keyword: one whose operands start
/// before such a stream takes them from around what wasn't read.
pub(crate) fn first_op_after_damage(streams: &[ContentStream], pieces: &Pieces, ops: &[Op]) -> usize {
let damaged = first_damaged(streams);
ops.iter().position(|op| pieces.pieces_of(op).1 > damaged).unwrap_or(ops.len())
}
/// Whether `new` changes a text object still open at the first stream not read whole. The text
/// object goes on in it, and the text it shows there starts where this text ends: a change of this
/// text moves it. A change before the text object doesn't.
fn changes_text_open_into_damage(streams: &[ContentStream], new: &[Vec<u8>]) -> bool {
let upto = first_damaged(streams).saturating_add(1);
if upto > streams.len() {
return false;
}
let before = Pieces::join(&streams.iter().take(upto).map(|s| s.data.as_slice()).collect::<Vec<_>>());
let mut open = None;
for op in before.parse().ops {
match op.op.as_slice() {
b"BT" => open = Some(op.span.start),
b"ET" => open = None,
_ => {}
}
}
let Some(at) = open else { return false };
let after = Pieces::join(&new.iter().take(upto).map(Vec::as_slice).collect::<Vec<_>>());
// Unchanged from the text object's start to the stream not read whole: it ends the same.
!before.data().get(at..).is_some_and(|text| after.data().ends_with(text))
}
/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams
/// (keeping their dictionaries), the others stay as they were.
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec<u8>)], new: Vec<Vec<u8>>) -> Object {
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for ((obj, old), data) in streams.iter().zip(new) {
if *old == data {
contents.push(obj.clone());
/// (keeping their dictionaries), the others stay as they were. An error, before anything is
/// added, when a piece that changed is a stream that didn't decode whole, or comes after one.
/// Written from what decoded, a damaged stream would lose everything after the damage, and one
/// that doesn't decode all of it. What follows the damage is unknown, so a later stream may not
/// read as it seems: a string or comment the damage left open runs on into it, and operators the
/// damage hides can change the graphics state it draws in. A stream that doesn't decode hides its
/// operators all the same.
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec<Vec<u8>>) -> Result<Object, EditError> {
// The last stream that changed: when it is drawn before the damage, so are the others.
if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) {
drawn_before_damage(streams, i)?;
}
if changes_text_open_into_damage(streams, &new) {
return Err(EditError::Invalid(
"this change is in a text object that goes on in a content stream that is damaged or can't be decoded, whose text starts where this text ends"
.into(),
));
}
for (s, data) in streams.iter().zip(&new) {
if s.data == *data {
continue;
}
let mut dict = match &*doc.resolve(obj) {
match s.read {
Read::Whole => {}
Read::Damaged => {
return Err(EditError::Invalid(
"this change is in a content stream that is damaged partway, and rewriting it would lose what follows the damage".into(),
));
}
Read::Unread => {
return Err(EditError::Invalid("this change is in a content stream that can't be decoded, and rewriting it would lose it".into()));
}
}
}
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for (s, data) in streams.iter().zip(new) {
if s.data == data {
contents.push(s.entry.clone());
continue;
}
let mut dict = match &*doc.resolve(&s.entry) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data)))));
}
match <[Object; 1]>::try_from(contents) {
Ok(match <[Object; 1]>::try_from(contents) {
Ok([one]) => one,
Err(many) => Object::Array(many),
}
})
}
pub(crate) fn page_dict(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
@@ -201,7 +310,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
let mut carry = Carry::new();
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let pieces = joined(&content_streams(doc, &p.dict)?);
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, actual_text.then_some(&properties))?;
@@ -608,7 +717,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
// can run on from one stream into the next. Text still counts as the stream its operator
// starts in, which keeps lines from different streams apart.
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
walk(&mut w, &ops, &res, &mut carry, &stream_of)?;
+10 -10
View File
@@ -4,10 +4,10 @@ use super::fallback::{
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
};
use super::interp::{Carry, Origin, TextState, interpret};
use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines};
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
use super::{
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks,
is_centred, parse, rewrite_styled_block,
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
parse, rewrite_styled_block,
};
/// What replacing a line did.
@@ -56,7 +56,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
// The line's own font, when it can show every character.
@@ -127,7 +127,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
// Rebuild: the line's first operator becomes the replacement, its others go.
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let contents = rewritten_contents(doc, &streams, new_data)?;
let page_ref = p.obj;
if substituted.is_some() {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
@@ -834,7 +834,7 @@ pub(crate) fn rewrite_block_from(
}
let upright = dispatch.upright;
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let text = paragraph_text(text.unwrap_or(&b.text));
// The characters to show, for choosing fonts: the breaks are moves, not glyphs.
@@ -970,7 +970,7 @@ pub(crate) fn rewrite_block_from(
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, !dropped.contains(&i))
});
let contents = rewritten_contents(doc, &streams, new_data);
let contents = rewritten_contents(doc, &streams, new_data)?;
let new_font = !reuse;
if new_font {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
@@ -1040,7 +1040,7 @@ pub(crate) fn selectable_blocks(
!b.lines
.iter()
.filter_map(|i| lines.get(*i))
.any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r)))
.any(|l| streams.get(l.stream).and_then(|s| s.entry.as_ref()).is_some_and(|r| excluded.contains(&r)))
})
.collect())
}
@@ -1134,7 +1134,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
}
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = joined(&streams);
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
@@ -1205,7 +1205,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
return Err(EditError::Invalid("a selected text operation cannot be moved".into()));
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
+2 -2
View File
@@ -962,7 +962,7 @@ pub(crate) fn rewrite_styled_block(
};
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
@@ -1206,7 +1206,7 @@ pub(crate) fn rewrite_styled_block(
// when the rewrite splits one, and a kept row's operators come later in the stream.
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
let contents = rewritten_contents(doc, &streams, data);
let contents = rewritten_contents(doc, &streams, data)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), contents);
+590
View File
@@ -0,0 +1,590 @@
//! Edit a PDF ▸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005).
//!
//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own
//! content streams. Ungrouping copies the form's content into the page at the same place,
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
//! the page's under names that don't clash. Its text then edits like any other. The form object
//! itself is left alone, so another page or placement that draws it is unchanged. Each resource
//! name is read from the operand the renderer uses ([`resource_operand`]), so a surplus operand
//! before it changes neither the renaming nor the checks below.
//!
//! Drawing a form inline paints the same pixels unless the form is a transparency group whose
//! compositing differs from painting its parts one by one: a knockout group, a group that uses
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents
//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would
//! composite in the page's. Those are refused rather than changed, as is a form whose
//! `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` the page doesn't share (it remaps the form's
//! device colours, §8.6.5.6, and inline the page's resources apply), tagged form content (its
//! structure link would be lost) and content whose marked content or text objects don't balance
//! (inlined, they would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
//!
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
use std::collections::HashMap;
use pdfcraft_content::{Matrix, Op, parse};
use pdfcraft_cos::{Dict, Document, Object};
use crate::EditError;
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
/// any real form, and a bomb must not be copied into the page.
const MAX_FORM_BYTES: usize = 64 << 20;
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
/// The `/ColorSpace` entries that remap device colours painted with the resources they are in
/// (ISO 32000-2 §8.6.5.6).
const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
const MAX_MOVED_PATTERNS: usize = 256;
/// A name map for one resource category: form name → name in the page's resources.
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
/// the form's merged in, and the pattern copies to add to the document, each under the name in
/// the merged `/Pattern` dictionary that the ops already use.
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
/// then the CTM there).
pub(crate) fn ungroup_ops(
doc: &Document,
resources: &Dict,
name: &[u8],
translucent: bool,
page_space: Option<&Object>,
placement: Matrix,
) -> Result<Ungrouped, EditError> {
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
if !is_name(doc, &s.dict, b"Subtype", b"Form") {
return Err(refuse("it is not a Form XObject"));
}
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
};
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
let matrix = match s.dict.get(b"Matrix") {
None => None,
Some(_) => Some(Matrix(
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
.map_err(|_| refuse("its matrix is unreadable"))?,
)),
};
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
let form_res = own.clone().unwrap_or_else(|| resources.clone());
// A tagged form's content is a structure element's: drawn inline it would lose that link.
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
}
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
if let Some(group) = &group {
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
return Err(refuse("it is a knockout transparency group"));
}
if translucent {
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
}
// Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS);
// inline they would composite in the page's.
if let Some(cs) = group.get(b"CS")
&& !same_blend_space(doc, cs, page_space)
{
return Err(refuse("it is a transparency group that blends in a colour space of its own"));
}
}
// Strictly: damaged data must not be copied in as a shorter form.
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
let parsed = parse(&data);
if parsed.skipped > 0 {
return Err(refuse("its content has parts that can't be read"));
}
let mut ops = parsed.ops;
if group.is_some() {
// The form itself is being read: drawing itself again counts as blending.
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0 };
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
return Err(refuse("it is a transparency group with blend modes inside"));
}
}
// A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline,
// the page's resources are the current ones (§7.8.3).
if own.is_some()
&& let Some(key) = unshared_default_space(doc, &form_res, resources)
{
let key = String::from_utf8_lossy(key);
return Err(refuse(&format!("it remaps its device colours with a /{key} the page doesn't share")));
}
// Merge the form's resources into a copy of the page's, renaming what clashes.
let mut merged = resources.clone();
let mut renames: Vec<(&[u8], Renames)> = Vec::new();
if own.is_some() {
for cat in CATEGORIES {
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut map = Renames::new();
let mut n = 0u32;
for (key, value) in theirs.iter() {
// A default colour space checked above is the page's, which applies inline;
// renamed, it would be an entry nothing uses.
if mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..])) {
continue;
}
let fresh = loop {
n = n.saturating_add(1);
let candidate = format!("PCUg{n}").into_bytes();
if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() {
break candidate;
}
if n == u32::MAX {
return Err(refuse("too many resources"));
}
};
mine.set(fresh.clone(), value.clone());
map.push((key.to_vec(), fresh));
}
merged.set(cat.to_vec(), Object::Dict(mine));
if !map.is_empty() {
renames.push((cat, map));
}
}
}
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone())
};
// A nested form, Type 3 font or tiling pattern without resources of its own looks its names
// up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it
// would find the page's, which differ wherever a name was renamed (every form resource that
// isn't already the page's, the nested item's own entry included).
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources"));
}
let mut depth = 0usize;
// Marked content and text objects must balance within the form's own stream: an extra EMC
// would close one the page opened, and one left open would take in the page's later content.
let mut marked = 0usize;
let mut in_text = false;
for op in &mut ops {
match op.op.as_slice() {
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?,
b"BT" if in_text => return Err(refuse("its content begins a text object inside another")),
b"BT" => in_text = true,
b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")),
b"ET" => in_text = false,
_ => {}
}
match op.op.as_slice() {
b"q" => depth = depth.saturating_add(1),
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?,
_ => {}
}
let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?;
if let Some((cat, i)) = slot
&& let Some(operand) = op.operands.get_mut(i)
&& let Object::Name(n) = operand
&& let Some(to) = renamed(cat, n)
{
*operand = Object::Name(to);
}
if let Some((dict, _)) = &mut op.inline {
for key in [&b"CS"[..], b"ColorSpace"] {
if let Some(Object::Name(n)) = dict.get(key)
&& let Some(to) = renamed(b"ColorSpace", n)
{
dict.set(key.to_vec(), Object::Name(to));
}
}
}
}
if marked > 0 || in_text {
return Err(refuse("its content leaves marked content or a text object open"));
}
// Patterns keep their place relative to the shapes they fill: each one the form selects is
// drawn from a copy that also carries the placement. Nested forms and patterns inside
// patterns keep their own parents, so only this stream's selections change.
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
if placement != Matrix::IDENTITY {
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut moved: Renames = Vec::new();
let mut n = 0u32;
for op in &mut ops {
let Ok(Some((b"Pattern", i))) = resource_operand(op) else { continue };
let Some(Object::Name(from)) = op.operands.get(i) else { continue };
// A name the resources don't have selects nothing, inline or not.
let Some(entry) = table.get(from).cloned() else { continue };
let to = match moved.iter().find(|(f, _)| f == from) {
Some((_, to)) => to.clone(),
None => {
if moved.len() >= MAX_MOVED_PATTERNS {
return Err(refuse("it paints with too many patterns"));
}
let copy =
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
let fresh = loop {
n = n.saturating_add(1);
let candidate = format!("PCUgPat{n}").into_bytes();
if table.get(&candidate).is_none() {
break candidate;
}
if n == u32::MAX {
return Err(refuse("too many resources"));
}
};
// Reserved here; the caller replaces it with the copy's reference.
table.set(fresh.clone(), Object::Null);
patterns.push((fresh.clone(), copy));
moved.push((from.clone(), fresh.clone()));
fresh
}
};
if let Some(operand) = op.operands.get_mut(i) {
*operand = Object::Name(to);
}
}
if !patterns.is_empty() {
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
}
// A form in a layer (/OC) stays in it: its content is marked with the same membership.
let layer = match s.dict.get(b"OC") {
None => None,
Some(oc) => {
let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone());
let name = match existing {
Some(k) => k,
None => {
let fresh = (1..=u32::MAX)
.map(|n| format!("PCUgOC{n}").into_bytes())
.find(|c| props.get(c).is_none())
.ok_or_else(|| refuse("too many resources"))?;
props.set(fresh.clone(), oc.clone());
merged.set(b"Properties".to_vec(), Object::Dict(props));
fresh
}
};
Some(name)
}
};
let num = |v: f64| Object::Real(v);
let mut out = Vec::new();
if let Some(name) = &layer {
out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())]));
}
out.push(Op::new("q", Vec::new()));
if let Some(m) = matrix {
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
}
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
out.push(Op::new("W", Vec::new()));
out.push(Op::new("n", Vec::new()));
out.extend(ops);
// A form ends with the state it was drawn with (the Do saves and restores it).
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
out.push(Op::new("Q", Vec::new()));
if layer.is_some() {
out.push(Op::new("EMC", Vec::new()));
}
Ok((out, merged, patterns))
}
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace`
/// resources that the page's don't have with an equal value (the same object, or the same value
/// resolved). A default colour space remaps the device colours painted with the resource
/// dictionary it is in (ISO 32000-2 §8.6.5.6, §7.8.3), so inline only the page's would apply.
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<&'static [u8]> {
let spaces = |res: &Dict| res.get(b"ColorSpace").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (theirs, mine) = (spaces(form), spaces(page));
DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some_and(|v| !mine.get(key).is_some_and(|p| *doc.resolve(p) == *doc.resolve(v))))
}
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its
/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy.
fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Object> {
let resolved = doc.resolve(entry);
let (mut dict, stream) = match &*resolved {
Object::Stream(s) => (s.dict.clone(), Some(s)),
Object::Dict(d) => (d.clone(), None),
_ => return None,
};
let own = match dict.get(b"Matrix") {
None => Matrix::IDENTITY,
Some(m) => {
let m = doc.resolve(m);
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
Matrix::from_operands(&nums)?
}
};
let moved = own.then(&placement);
if !moved.0.iter().all(|v| v.is_finite()) {
return None;
}
dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect()));
match stream {
Some(s) if s.raw.len() > MAX_FORM_BYTES => None,
Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })),
None => Some(Object::Dict(dict)),
}
}
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
const MAX_NESTING: usize = 8;
/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest
/// count as blending, so a file can't make the check run for ever.
const MAX_NESTED_READS: usize = 1024;
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
/// form drawing itself), the answer for each form with resources of its own (its answer doesn't
/// depend on who draws it), and how many reads it has made.
struct BlendWalk {
path: Vec<pdfcraft_cos::ObjRef>,
known: HashMap<pdfcraft_cos::ObjRef, bool>,
reads: usize,
}
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
struct Unreadable;
/// Which operand of `op` names a resource, and from which category, read as the renderer reads
/// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`,
/// `sh`, `cs` and `CS` take the last operand; `Tf` a name then a size; `BDC` and `DP` a tag then
/// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them.
/// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which
/// [`ungroup_ops`] refuses rather than guess at.
fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
let operands = &op.operands;
let name_at = |i: Option<usize>| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_))));
let last = operands.len().checked_sub(1);
let (cat, at): (&'static [u8], Option<usize>) = match op.op.as_slice() {
b"Do" => (b"XObject", last),
b"gs" => (b"ExtGState", last),
b"sh" => (b"Shading", last),
b"cs" | b"CS" => (b"ColorSpace", last),
b"Tf" => {
if !last.is_some_and(|i| matches!(operands.get(i), Some(Object::Int(_) | Object::Real(_)))) {
return Err(Unreadable);
}
(b"Font", operands.len().checked_sub(2))
}
b"BDC" | b"DP" => {
if !name_at(operands.len().checked_sub(2)) {
return Err(Unreadable);
}
match last.and_then(|i| operands.get(i)) {
Some(Object::Name(_)) => (b"Properties", last),
Some(Object::Dict(_)) => return Ok(None),
_ => return Err(Unreadable),
}
}
b"scn" | b"SCN" => {
if !operands.iter().all(|o| matches!(o, Object::Int(_) | Object::Real(_) | Object::Name(_))) {
return Err(Unreadable);
}
return Ok(operands.iter().rposition(|o| matches!(o, Object::Name(_))).map(|i| (&b"Pattern"[..], i)));
}
_ => return Ok(None),
};
match at {
Some(i) if name_at(Some(i)) => Ok(Some((cat, i))),
_ => Err(Unreadable),
}
}
/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with.
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false };
let Some(Object::Name(name)) = op.operands.get(i) else { return false };
let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else {
return false;
};
let dict = match &*doc.resolve(&entry) {
Object::Stream(s) => s.dict.clone(),
Object::Dict(d) => d.clone(),
_ => return false,
};
let drawn = match cat {
b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"),
b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"),
// A shading pattern has no content of its own.
_ => int_of(doc, &dict, b"PatternType") == Some(1),
};
drawn && dict.get(b"Resources").is_none()
}
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through
/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't
/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather
/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped.
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool {
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
for op in ops {
let Ok(Some((cat, i))) = resource_operand(op) else { continue };
let Some(name) = op.name(i) else { continue };
let o = match cat {
b"ExtGState" if state_blends(states.get(name)) => return true,
b"XObject" => xobjects.get(name),
b"Pattern" => patterns.get(name),
_ => continue,
};
let Some(o) = o else { continue };
let resolved = doc.resolve(o);
let nested = match &*resolved {
Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st,
Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st,
// A shading pattern.
Object::Stream(st) if !op.is("Do") => {
if state_blends(st.dict.get(b"ExtGState")) {
return true;
}
continue;
}
Object::Dict(d) if !op.is("Do") => {
if state_blends(d.get(b"ExtGState")) {
return true;
}
continue;
}
_ => continue,
};
let r = o.as_ref();
if let Some(known) = r.and_then(|r| walk.known.get(&r)) {
if *known {
return true;
}
continue;
}
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
return true;
}
walk.reads += 1;
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let has_own = own_resources.is_some();
let own = own_resources.unwrap_or_else(|| resources.clone());
let inner = parse(&data);
if inner.skipped > 0 {
return true;
}
if let Some(r) = r {
walk.path.push(r);
}
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
if let Some(r) = r {
walk.path.retain(|x| *x != r);
if has_own {
walk.known.insert(r, blends);
}
}
if blends {
return true;
}
}
false
}
/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency
/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses.
pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option<Object> {
let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?;
group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone())
}
/// Whether a group blending in `cs` composites as the page does: in the page group's own space
/// when it names one, else in DeviceRGB, the space viewers composite a page in on screen.
fn same_blend_space(doc: &Document, cs: &Object, page_space: Option<&Object>) -> bool {
let cs = doc.resolve(cs);
match page_space {
Some(page) => *cs == *page,
None => cs.as_name() == Some(b"DeviceRGB"),
}
}
/// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may
/// be one, ISO 32000-2 §7.3.10).
fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool {
d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want))
}
/// `d`'s integer `key`, directly or through an indirect object.
fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<i64> {
d.get(key).and_then(|v| doc.resolve(v).as_int())
}
/// Whether an ExtGState blends with something other than Normal.
fn blends(doc: &Document, gs: &Dict) -> bool {
let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false };
let first = match &*bm {
Object::Array(a) => a.first().map(|x| doc.resolve(x)),
_ => Some(bm.clone()),
};
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
}
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's
/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs`
/// changes only the parameters it sets.
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool {
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
let mut stack: Vec<[bool; 4]> = Vec::new();
let mut now = [false; 4];
for op in ops.iter().take(at) {
match op.op.as_slice() {
b"q" => stack.push(now),
b"Q" => now = stack.pop().unwrap_or([false; 4]),
b"gs" => {
let Ok(Some((_, i))) = resource_operand(op) else { continue };
let Some(gs) = op.name(i).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
continue;
};
let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0);
if let Some(v) = alpha(b"ca") {
now[0] = v;
}
if let Some(v) = alpha(b"CA") {
now[1] = v;
}
if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) {
now[2] = m.as_name() != Some(b"None");
}
if gs.get(b"BM").is_some() {
now[3] = blends(doc, &gs);
}
}
_ => {}
}
}
now.iter().any(|v| *v)
}
+3 -1
View File
@@ -313,7 +313,9 @@ impl crate::Document {
}
// 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 }
// The ruled lines tell a drawn table from text that merely lines up (#5149).
let rules = pdfcraft_edit::page_rules(cos, i).unwrap_or_default().into_iter().map(visible_rect).collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background, rules }
})
.collect();
(pages, truncated)
+3
View File
@@ -26,6 +26,9 @@ pub enum ImageEdit {
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
/// Grouped Form artwork: draw its content in place, so its text and pictures can be edited
/// one by one (#2964).
Ungroup,
}
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
+1
View File
@@ -292,6 +292,7 @@ impl Edit {
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
},
ImageEdit::RemoveCrop => "Remove crop",
ImageEdit::Ungroup => "Ungroup artwork",
}),
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
+2 -1
View File
@@ -307,6 +307,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
ImageEdit::Ungroup => pdfcraft_edit::ImageChange::Ungroup,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
@@ -412,7 +413,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete | ImageEdit::Ungroup => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
+1
View File
@@ -129,6 +129,7 @@ pub use pdfcraft_redact::patterns::{
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
};
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator};
pub use pdfcraft_sign as sign;
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore};
pub use pdfcraft_xfdf::Format as DataFormat;
+77 -2
View File
@@ -314,7 +314,7 @@ impl Session {
opts.date = self.signing_date();
}
// A typed Password-field value is not signed into the file (#3926), as it isn't saved.
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?))
}
@@ -332,7 +332,7 @@ impl Session {
if opts.date.is_empty() {
opts.date = self.signing_date();
}
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?);
// Checked like a save before anyone writes it (#2166).
self.check_save(doc, &signed)?;
@@ -374,3 +374,78 @@ impl Session {
Ok(())
}
}
/// `cos` with its form fields drawn, when the signature about to be made is the document's first
/// and the form sets `/NeedAppearances` (#5939). That signature removes the flag, after which
/// viewers show each field's stored appearance; a filled field whose stored appearance draws no
/// text (an empty placeholder such as `/Tx BMC EMC`, or a stale look) would go blank. So every
/// text and choice field is drawn from its value first, as the document is displayed
/// ([`pdfcraft_forms::with_field_appearances`]), and the signature covers that look.
///
/// One exception: where PdfCraft's drawing would show a character as `?` (the field's font can't
/// hold it) and the field's own appearance draws text, the field keeps its own appearance, which
/// the program that filled it drew with a font that has the character. A field whose own
/// appearance draws no text gets PdfCraft's drawing, `?` and all. Later signatures change
/// nothing here: redrawing would count as a change to the earlier signatures.
fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) -> std::borrow::Cow<'_, pdfcraft_cos::Document> {
use pdfcraft_forms::FieldKind;
if !pdfcraft_forms::need_appearances(&cos) || pdfcraft_sign::has_signatures(&cos) {
return cos;
}
let Some(mut drawn) = pdfcraft_forms::with_field_appearances(&cos) else { return cos };
for f in pdfcraft_forms::fields(&cos) {
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
continue;
}
for w in &f.widgets {
let own = cos.get(w.obj).as_dict().filter(|d| draws_text(&cos, d)).and_then(|d| d.get(b"AP").cloned());
// `.1`: the first character the drawing showed as `?`.
if let Some(ap) = own
&& pdfcraft_forms::appearance::field_appearance_drawn(&cos, &f, w, &f.value, true).1.is_some()
{
drawn.update_dict(w.obj, |d| d.set(b"AP".to_vec(), ap)).ok();
}
}
}
std::borrow::Cow::Owned(drawn)
}
/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through the
/// form XObjects it paints (a background-only one doesn't count).
fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool {
let ap = widget.get(b"AP").map(|ap| cos.resolve(ap));
let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n));
let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false };
stream_draws_text(cos, s, &mut 16)
}
/// Whether the form stream `s` shows text, following `Do` into its form XObjects. `budget` caps
/// the streams opened along the way; running out (deep nesting, a cycle, many XObjects) counts
/// as text, so the field keeps its own appearance as it would without the look inside.
fn stream_draws_text(cos: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, budget: &mut u8) -> bool {
let Ok(data) = s.decoded() else { return false };
let resources = s.dict.get(b"Resources").map(|r| cos.resolve(r));
let xobjects = resources.as_deref().and_then(|r| r.as_dict()).and_then(|r| r.get(b"XObject")).map(|x| cos.resolve(x));
for op in pdfcraft_content::ops(&data) {
match op.op.as_slice() {
b"Tj" | b"TJ" | b"'" | b"\"" => return true,
b"Do" => {
let name = op.operands.first().and_then(|n| n.as_name());
let x = name.and_then(|n| xobjects.as_deref().and_then(|x| x.as_dict()).and_then(|x| x.get(n))).map(|x| cos.resolve(x));
let Some(pdfcraft_cos::Object::Stream(f)) = x.as_deref() else { continue };
if f.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
if *budget == 0 {
return true;
}
*budget -= 1;
if stream_draws_text(cos, f, budget) {
return true;
}
}
_ => {}
}
}
false
}
+120
View File
@@ -2699,6 +2699,126 @@ fn a_signed_file_carries_no_password_field_value() {
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
}
/// A form with `/NeedAppearances true` whose filled fields have stored appearances that draw no
/// text: `name` an empty placeholder, `town` one too with a value Helvetica can't hold (`ň`),
/// `city` such a value with its own appearance that draws it (#5939), `street` no appearance at
/// all, `river` a value Helvetica can't hold (`ř`) with an appearance that paints only a
/// background form XObject, and `lake` such a value drawn by a form XObject its appearance
/// paints.
fn need_appearances_form() -> Vec<u8> {
let stream =
|content: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada Lovelace) /Rect [20 250 180 270] /P 3 0 R /AP << /N 8 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (town) /V <FEFF0050006C007A00650148> /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V <FEFF00420072006E006F00200159> /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
stream("/Tx BMC EMC"),
stream("/Tx BMC EMC"),
stream("/Tx BMC BT /Helv 10 Tf 2 5 Td (Brno r) Tj ET EMC"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (street) /V (Main Street) /Rect [20 100 180 120] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (river) /V <FEFF004F0159006500630068> /Rect [20 50 180 70] /P 3 0 R /AP << /N 13 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Bg 14 0 R >> >> /Length 10 >>\nstream\nq /Bg Do Q\nendstream".into(),
stream("0.9 g 0 0 160 20 re f"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (lake) /V <FEFF004F0159006500630068> /Rect [20 20 180 40] /P 3 0 R /AP << /N 16 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Fm 17 0 R >> >> /Length 10 >>\nstream\nq /Fm Do Q\nendstream".into(),
stream("BT /Helv 10 Tf 2 5 Td (Orech) Tj ET"),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.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", objs.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", objs.len() + 1).as_bytes());
out
}
/// The decoded normal appearance of the widget `num` in `doc`.
fn normal_appearance(doc: &pdfcraft_cos::Document, num: u32) -> String {
let widget = doc.get(pdfcraft_cos::ObjRef::new(num, 0));
let ap = doc.resolve(widget.as_dict().unwrap().get(b"AP").unwrap());
let n = doc.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let pdfcraft_cos::Object::Stream(s) = &*n else { panic!("{n:?}") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
/// #5939 review: the first signature removes `/NeedAppearances`, so the fields are drawn from
/// their values first; without that, `name` and `town` would show their empty placeholders. A
/// field whose own appearance draws its value keeps it rather than get a `?` for `ř`.
#[test]
fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap();
let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
let doc = pdfcraft_cos::Document::open(signed.clone()).unwrap();
assert!(!pdfcraft_forms::need_appearances(&doc));
assert!(normal_appearance(&doc, 4).contains("(Ada Lovelace) Tj"), "{}", normal_appearance(&doc, 4));
assert!(normal_appearance(&doc, 5).contains("(Plze?) Tj"), "{}", normal_appearance(&doc, 5));
assert!(normal_appearance(&doc, 6).contains("(Brno r) Tj"), "{}", normal_appearance(&doc, 6));
assert!(normal_appearance(&doc, 11).contains("(Main Street) Tj"), "{}", normal_appearance(&doc, 11));
// Its own appearance only paints a background, so `river` gets the drawing, `?` and all.
assert!(normal_appearance(&doc, 12).contains("(O?ech) Tj"), "{}", normal_appearance(&doc, 12));
let city = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(6, 0)).as_dict().unwrap().get(b"AP").cloned();
let original = pdfcraft_cos::Document::open(Arc::new(need_appearances_form())).unwrap();
assert_eq!(city(&doc), city(&original), "city keeps its own appearance");
let lake = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(15, 0)).as_dict().unwrap().get(b"AP").cloned();
assert_eq!(lake(&doc), lake(&original), "lake keeps the appearance its form XObject draws");
s.mark_signed(id, signed, None).unwrap();
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
let sig = &s.get(id).unwrap().signatures[0];
assert_eq!((sig.signed, sig.status, sig.modification.clone()), (true, SignatureStatus::Valid, sign::Modification::None));
}
/// A later signature draws nothing and leaves the flag: either would be a change to what the
/// first signature covers.
#[test]
fn a_later_signature_leaves_need_appearances_forms_alone() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap();
let first = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
// The flag set again after the first signature, and `name` back to its empty placeholder.
let mut cos = pdfcraft_cos::Document::open(first).unwrap();
let root = cos.root().unwrap();
cos.update_dict(root, |c| {
if let Some(pdfcraft_cos::Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.set(b"NeedAppearances".to_vec(), pdfcraft_cos::Object::Bool(true));
}
})
.unwrap();
let placeholder = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(Default::default(), b"/Tx BMC EMC".to_vec())));
cos.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |w| {
let mut ap = pdfcraft_cos::Dict::new();
ap.set(b"N".to_vec(), pdfcraft_cos::Object::Ref(placeholder));
w.set(b"AP".to_vec(), pdfcraft_cos::Object::Dict(ap));
})
.unwrap();
let flagged =
Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap());
let id = s.open("flagged.pdf", None, flagged.clone(), None).unwrap();
let second = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
let (before, after) = (pdfcraft_cos::Document::open(flagged).unwrap(), pdfcraft_cos::Document::open(second.clone()).unwrap());
assert!(pdfcraft_forms::need_appearances(&after));
let widget = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(4, 0));
assert_eq!(widget(&after), widget(&before));
assert_eq!(normal_appearance(&after, 4), "/Tx BMC EMC");
s.mark_signed(id, second, None).unwrap();
assert_eq!(s.get(id).unwrap().signatures.iter().filter(|x| x.signed).count(), 2);
}
/// #3944: a value typed into an XFA `<passwordEdit/>` field stays in the open document (both the
/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`),
/// but is written to no file — neither on the widget nor in the XFA datasets stream.
+8 -3
View File
@@ -31,15 +31,20 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) ->
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
}
/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized
/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes.
/// One Word table: single borders when the page's rules draw its grid, explicit absent borders
/// otherwise (alignment alone cannot prove PDF rules), a grid sized from the detected column
/// edges, `gridSpan` for spanning cells, empty cells for holes.
pub(crate) fn docx_table(t: &Table) -> String {
let mut edges = t.cols.clone();
edges.push(t.rect[2]);
let widths: Vec<i64> = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect();
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
if t.ruled {
s.push_str(&format!("<w:{edge} w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"));
} else {
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
}
}
s.push_str("</w:tblBorders></w:tblPr><w:tblGrid>");
for w in &widths {
+80 -8
View File
@@ -43,6 +43,9 @@ pub struct Page {
/// The colour the whole page is painted before its content (a page background), as sRGB
/// components in 0..=1; `None` for a page with no background of its own.
pub background: Option<[f64; 3]>,
/// The ruled lines the page draws (horizontal and vertical strokes, thin filled bars), each
/// as its box [x0, y0, x1, y1] on the displayed page in points (y up).
pub rules: Vec<[f64; 4]>,
}
/// One cell of a detected table. `span` is how many grid columns the cell covers (a header
@@ -65,6 +68,9 @@ pub struct Table {
pub rect: [f64; 4],
pub cols: Vec<f64>,
pub rows: Vec<Vec<Cell>>,
/// Whether the page's ruled lines separate every pair of neighbouring cells, so the writers
/// can draw the table's borders.
pub ruled: bool,
}
/// What goes into the output, in order.
@@ -248,10 +254,72 @@ fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
}
/// A rule at most this thick (points) is a line, not a filled area.
const RULE_THICK: f64 = 3.0;
/// Rule comparisons one page may spend deciding whether its tables are ruled; past it a table
/// counts as unruled, so a hostile page of many rules and cells stays quick.
const RULE_BUDGET: usize = 4_000_000;
/// Whether `rules` separate every pair of neighbouring cells of a table whose rows are `rows`
/// (block indices, top to bottom) and whose columns start at `edges` and end at `right`: between
/// two columns a vertical rule beside each row (unless a cell of that row spans across), and
/// between two rows a horizontal rule under each column. Every check spends `budget`.
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> bool {
let vertical: Vec<&[f64; 4]> = rules.iter().filter(|r| r[2] - r[0] <= RULE_THICK && r[3] - r[1] > RULE_THICK).collect();
let horizontal: Vec<&[f64; 4]> = rules.iter().filter(|r| r[3] - r[1] <= RULE_THICK && r[2] - r[0] > RULE_THICK).collect();
let mut find = |set: &[&[f64; 4]], hit: &dyn Fn(&[f64; 4]) -> bool| -> bool {
set.iter().any(|r| {
*budget = budget.saturating_sub(1);
*budget > 0 && hit(r)
})
};
// Each row's vertical span.
let spans: Vec<[f64; 2]> = rows
.iter()
.map(|row| {
let bs = || row.iter().filter_map(|&i| blocks.get(i));
[bs().map(|b| b.rect[1]).fold(f64::INFINITY, f64::min), bs().map(|b| b.rect[3]).fold(f64::NEG_INFINITY, f64::max)]
})
.collect();
for (row, span) in rows.iter().zip(&spans) {
let mid = (span[0] + span[1]) / 2.0;
for pair in edges.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
let cells = || row.iter().filter_map(|&i| blocks.get(i));
if cells().any(|b| b.rect[0] < hi - COL_TOL && b.rect[2] > hi + COL_TOL) {
continue;
}
let clear = |x: f64| !cells().any(|b| b.rect[0] + COL_TOL < x && x < b.rect[2] - COL_TOL);
if !find(&vertical, &|r| {
let x = (r[0] + r[2]) / 2.0;
lo + COL_TOL < x && x <= hi + COL_TOL && r[1] <= mid && mid <= r[3] && clear(x)
}) {
return false;
}
}
}
let mids: Vec<f64> = edges.iter().zip(edges.iter().skip(1).chain(std::iter::once(&right))).map(|(a, b)| (a + b) / 2.0).collect();
for pair in spans.windows(2) {
let (below_top, above_bottom) = (pair[1][1], pair[0][0]);
for &x in &mids {
if !find(&horizontal, &|r| {
let y = (r[1] + r[3]) / 2.0;
below_top - COL_TOL <= y && y <= above_bottom + COL_TOL && r[0] <= x && x <= r[2]
}) {
return false;
}
}
}
true
}
/// Detect tables in a page's blocks from their text layout: columns are left edges shared by
/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and,
/// several short blocks, rows are blocks whose vertical spans overlap. Two columns of two rows
/// are a table only when `rules` (the page's ruled lines) draw its grid. Returns the tables and,
/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow).
pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<bool>) {
let mut budget = RULE_BUDGET;
let mut consumed = vec![false; blocks.len()];
// Candidate columns: cluster the left edges of cell-like blocks.
let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect();
@@ -313,7 +381,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
}
// Runs of consecutive rows that have two or more distinct columns are tables.
let mut out = Vec::new();
let flush = |run: &mut Vec<Vec<(usize, usize)>>, out: &mut Vec<Table>, consumed: &mut Vec<bool>| {
let flush = |run: &mut Vec<Vec<(usize, usize)>>, out: &mut Vec<Table>, consumed: &mut Vec<bool>, budget: &mut usize| {
if run.len() < 2 {
run.clear();
return;
@@ -325,12 +393,16 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
edges.sort_by(f64::total_cmp);
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
let ncols = edges.len();
let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS;
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
let is_ruled = (2..=MAX_COLS).contains(&ncols) && !rules.is_empty() && {
let row_blocks: Vec<Vec<usize>> = run.iter().map(|row| row.iter().map(|(_, i)| *i).collect()).collect();
ruled(blocks, rules, &row_blocks, &edges, right, budget)
};
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || is_ruled))) && ncols <= MAX_COLS;
if !ok {
run.clear();
return;
}
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
let mut rows_out = Vec::new();
let mut slots: Vec<Option<(Cell, Vec<usize>)>> = vec![None; ncols];
for row in run.drain(..) {
@@ -385,7 +457,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
right,
used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max),
];
out.push(Table { rect, cols: edges, rows: rows_out });
out.push(Table { rect, cols: edges, rows: rows_out, ruled: is_ruled });
};
// Runs of consecutive multi-column rows are tables. Rows on either side of a run that
// hold a single cell wide enough to span two columns (a header band, a totals row) join
@@ -436,7 +508,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) {
pieces.push(rows[end].clone());
}
flush(&mut pieces, &mut out, &mut consumed);
flush(&mut pieces, &mut out, &mut consumed, &mut budget);
}
(out, consumed)
}
@@ -448,7 +520,7 @@ pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
if i > 0 {
out.push(Item::PageBreak);
}
let (tables, consumed) = tables(&p.blocks);
let (tables, consumed) = tables(&p.blocks, &p.rules);
// Blocks and images by their top edge, top to bottom.
// A paragraph sits where its first run does: its other runs follow along the same line.
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels)
for p in pages {
let (tables, consumed) = crate::tables(&p.blocks);
let (tables, consumed) = crate::tables(&p.blocks, &p.rules);
let title_idx = p
.blocks
.iter()
+4 -1
View File
@@ -46,6 +46,7 @@ fn sample_page() -> Page {
rotation: 0.0,
}],
background: None,
rules: Vec::new(),
}
}
@@ -174,6 +175,7 @@ fn pptx_package_parts_are_complete_and_consistent() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let bytes = pptx(&[sample_page(), table_page], "Two");
let parts = entries(&bytes);
@@ -278,7 +280,7 @@ fn hostile_sizes_do_not_overflow_the_slide_position() {
rotation: 0.0,
})
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None, rules: Vec::new() };
let bytes = pptx(&[p], "Hostile");
let slide = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide.contains("Title") && slide.contains("<p:pic>"), "the slide is still written");
@@ -360,6 +362,7 @@ fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let bytes = pptx(&[p], "Table Check");
let slide_xml = part(&bytes, "ppt/slides/slide1.xml");
+54 -14
View File
@@ -14,7 +14,7 @@ fn varied_page(n: usize) -> Page {
space_around: [false; 2],
})
.collect();
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() }
}
fn lookup_steps(f: impl FnOnce()) -> usize {
@@ -69,6 +69,7 @@ fn page() -> Page {
rotation: 0.0,
}],
background: None,
rules: Vec::new(),
}
}
@@ -136,6 +137,7 @@ fn word_table_cell_widths_follow_the_columns_they_span() {
vec![cell("All", 3)],
vec![cell("First", 1)],
],
ruled: false,
};
let xml = docx_table(&table);
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
@@ -360,6 +362,7 @@ fn table_page() -> Page {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
}
}
@@ -376,7 +379,7 @@ fn grid_text_becomes_a_real_table_in_every_format() {
let d = docx(std::slice::from_ref(&p), "Bill");
assert!(d.starts_with(b"PK"));
assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml"));
let (tables, _) = tables(&p.blocks);
let (tables, _) = tables(&p.blocks, &p.rules);
assert_eq!(tables.len(), 1);
let table_xml = docx_table(&tables[0]);
assert!(table_xml.contains("<w:tblGrid>"));
@@ -421,7 +424,7 @@ fn a_staircase_of_blocks_is_not_a_huge_table() {
})
.collect();
let started = std::time::Instant::now();
let (tables, _) = tables(&blocks);
let (tables, _) = tables(&blocks, &[]);
assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0));
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
}
@@ -451,6 +454,7 @@ fn word_opens_tables_up_to_its_column_limit() {
.collect(),
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches("<w:gridCol ").count();
assert_eq!(grid_cols(&grid(63)), 63, "63 columns is still a table");
@@ -486,6 +490,7 @@ fn a_link_inside_a_sentence_is_not_a_table_column() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let h = html(std::slice::from_ref(&p), "Regulations");
assert!(!h.contains("<table>"), "running text became a table: {h}");
@@ -507,7 +512,7 @@ fn sentences_that_run_on_into_links_are_not_a_table() {
blocks.push(block(lead, 72.0, y, 211.0, y + 12.0));
blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0));
}
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let h = html(std::slice::from_ref(&p), "Event");
assert!(!h.contains("<table>"), "sentences became a table: {h}");
assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
@@ -529,7 +534,7 @@ fn table_spans_do_not_consume_text_they_do_not_emit() {
}
}
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
let (tables, consumed) = tables(&p.blocks);
let (tables, consumed) = tables(&p.blocks, &p.rules);
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].rows[0][0].span, 2);
assert!(!consumed[1], "a covered slot did not emit its text");
@@ -558,11 +563,11 @@ fn a_superscript_beside_a_number_keeps_its_table() {
blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
}
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let h = html(std::slice::from_ref(&p), "Prizes");
assert!(h.contains("<table>"), "the prize table was lost: {h}");
assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
let (tables, consumed) = tables(&p.blocks);
let (tables, consumed) = tables(&p.blocks, &p.rules);
assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells");
let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect();
assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix)));
@@ -591,6 +596,7 @@ fn two_column_body_text_is_not_mistaken_for_a_table() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let h = html(&[p], "Paper");
assert!(!h.contains("<table>"), "tall two-column text must stay paragraphs: {h}");
@@ -675,7 +681,7 @@ fn italic_sentence() -> Vec<Block> {
fn an_italic_word_stays_in_its_sentence() {
let mut blocks = italic_sentence();
blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0));
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -700,7 +706,7 @@ fn an_italic_word_stays_in_its_sentence() {
fn a_sentence_keeps_its_reading_order() {
let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)];
blocks.extend(italic_sentence().into_iter().rev());
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -721,7 +727,7 @@ fn runs_keep_the_spaces_the_source_had_between_them() {
block("E = mc", 72.0, 660.0, 110.0, 672.0),
Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) },
];
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -733,7 +739,7 @@ fn runs_keep_the_spaces_the_source_had_between_them() {
#[test]
fn turned_text_is_not_a_run_of_the_line_beside_it() {
let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }];
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let h = html(std::slice::from_ref(&p), "Turned");
assert!(h.contains("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{h}");
}
@@ -744,7 +750,7 @@ fn a_block_on_the_next_line_starts_a_paragraph() {
block("The first line ends here", 72.0, 700.0, 190.0, 712.0),
Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) },
];
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml");
assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}");
}
@@ -761,7 +767,7 @@ fn table_cells_are_not_runs_of_a_paragraph() {
blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
}
blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b }));
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml");
assert_eq!(xml.matches("<w:tbl>").count(), 1, "{xml}");
for cell in ["st", "nd", "rd", "30,000"] {
@@ -781,7 +787,7 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() {
Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) },
block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0),
];
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml");
assert_eq!(xml.matches("<w:pStyle w:val=\"Heading1\"/>").count(), 1, "{xml}");
assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
@@ -830,6 +836,7 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let md = markdown(&[p]);
assert!(md.contains("\\- not a list item"), "{md}");
@@ -859,3 +866,36 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}");
assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}");
}
/// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps
/// its borders; the same text without rules (or with rules that only underline it) stays paragraphs.
#[test]
fn a_ruled_two_by_two_grid_is_a_table() {
let blocks = vec![
block("Name", 25.0, 250.0, 55.0, 261.0),
block("Value", 165.0, 250.0, 195.0, 261.0),
block("Alpha", 25.0, 225.0, 55.0, 236.0),
block("One", 165.0, 225.0, 185.0, 236.0),
];
let grid = vec![
[20.0, 270.0, 300.0, 270.0],
[20.0, 245.0, 300.0, 245.0],
[20.0, 220.0, 300.0, 220.0],
[20.0, 220.0, 20.0, 270.0],
[160.0, 220.0, 160.0, 270.0],
[300.0, 220.0, 300.0, 270.0],
];
let (found, consumed) = tables(&blocks, &grid);
assert_eq!(found.len(), 1);
assert!(found[0].ruled);
assert_eq!(found[0].rows.len(), 2);
assert!(consumed.iter().all(|used| *used));
let p = Page { width: 320.0, height: 300.0, blocks: blocks.clone(), rules: grid, ..Page::default() };
let xml = part(&docx(std::slice::from_ref(&p), "Grid"), "word/document.xml");
assert_eq!(xml.matches("<w:tbl>").count(), 1, "{xml}");
assert!(xml.contains("w:val=\"single\"") && !xml.contains("w:val=\"nil\""), "{xml}");
assert!(tables(&blocks, &[]).0.is_empty(), "alignment alone does not make two by two a table");
let underlines = [[20.0, 245.0, 300.0, 245.0]];
assert!(tables(&blocks, &underlines).0.is_empty(), "a rule between the rows alone is no grid");
}
+3 -3
View File
@@ -3,7 +3,7 @@
PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3).
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `zlib-rs` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
@@ -38,14 +38,14 @@ encode_jbig2_shared(&[(data, columns, rows)]) -> Result<Option<SharedJbig2>, Fil
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`.
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding.
- `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first.
- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_within` (its strict form), `decode_jbig2_within` and `decode_ccitt_within` are, all spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Spent`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error.
- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_within` (its strict form), `decode_jbig2_within` and `decode_ccitt_within` are, all spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. A Flate stage also pays `FLATE_READ_WORK` (2) steps a byte it reads, before it runs, and again if it reads them a second time as raw deflate: inflating does work for every block it reads, and a stream of empty dynamic blocks took 21 ns a byte to make nothing, about 7 ns a step at that price. flate2 inflates with its zlib-rs backend, which it prefers to miniz_oxide when other dependencies build that too; miniz_oxide, its backend here before, took 1.4 µs a byte on empty fixed blocks. What `work` can't pay for spends all of it, and gives `Spent`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error.
- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
## Behaviour
| Filter | Decode | Encode |
|---|---|---|
| FlateDecode | zlib header checked, then inflated raw: a missing or wrong Adler-32 is tolerated; no valid header → raw deflate; trailing bytes ignored; preset dictionary → Corrupt; truncated → Corrupt (partial in tolerant mode) | zlib level 6 |
| FlateDecode | zlib header checked, then inflated raw: a missing or wrong Adler-32 is tolerated; no valid header → raw deflate; trailing bytes ignored; preset dictionary → Corrupt; truncated, a back-reference to before the start of the output, or a dynamic block without an end-of-block code → Corrupt (partial in tolerant mode) | zlib level 6 |
| LZWDecode | 9–12-bit MSB-first codes, clear (256), EOD (257), `EarlyChange` 0/1; missing EOD/initial clear tolerated; a full table stops growing | greedy, leading clear, clear-table when the table fills, EOD |
| Predictors (Flate/LZW) | 2 = TIFF (1/2/4/8/16 bpc), 10–15 = PNG with per-row tags 0–4; partial final rows decoded; bad params or tags → Corrupt | 2, 10 None, 11 Sub, 12 Up, 13 Average, 14 Paeth, 15 per-row optimum (min sum of signed residuals) |
| ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` |
+145 -14
View File
@@ -5,6 +5,11 @@
//! deflate stream is inflated raw after the 2-byte zlib header;
//! - streams without a valid zlib header are inflated as raw deflate;
//! - data after the end of the deflate stream is ignored.
//!
//! A back-reference to before the start of the output points at nothing, and is damage, as zlib
//! has it: read strictly the stream is refused, and read tolerantly what came before it is kept.
//! (miniz_oxide, flate2's backend here before zlib-rs, copied zeros and went on.) So is a dynamic
//! block whose code has no end-of-block symbol, and nothing of that block is kept.
use std::io::Write;
@@ -21,25 +26,48 @@ fn zlib_header(data: &[u8]) -> Option<(u8, u8)> {
ok.then_some((cmf, flg))
}
pub(crate) fn decode(data: &[u8], max: usize) -> Step {
/// `data` inflated within `max`, and whether it was read twice: a zlib header that only looked
/// valid is read again as raw deflate, within `again`, what a caller paying for what is read has
/// left for it once it has paid for the second reading. `None`, that reading can't be paid for,
/// and is [`FilterError::Spent`](crate::FilterError::Spent).
pub(crate) fn decode(data: &[u8], max: usize, again: Option<usize>) -> (Step, bool) {
match zlib_header(data) {
Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
Some((_, flg)) if flg & 0x20 != 0 => (Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())), false),
Some(_) => match inflate_raw(&data[2..], max) {
// A header that only looked valid: retry the whole input as raw deflate.
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) {
// Keep the original corruption diagnostic only if the retry is corrupt
// too. A limit error must remain fatal even for tolerant decoding. What the
// retry decoded isn't kept, but a caller charging for work has to hear of it.
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(Failure { discarded: retry.partial.len(), ..f }),
retry => retry,
},
r => r,
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => {
let Some(again) = again else {
return (Err(Failure { error: crate::FilterError::Spent, partial: Vec::new(), discarded: 0 }), false);
};
let step = match inflate_raw(data, again) {
// Keep the original corruption diagnostic only if the retry is corrupt
// too. A limit error must remain fatal even for tolerant decoding. What the
// retry decoded isn't kept, but a caller charging for work has to hear of it.
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(Failure { discarded: retry.partial.len(), ..f }),
retry => retry,
};
(step, true)
}
r => (r, false),
},
None => inflate_raw(data, max),
None => (inflate_raw(data, max), false),
}
}
#[cfg(test)]
thread_local! {
/// Bytes inflating gave back on this thread, its partial output included.
pub(crate) static INFLATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn inflate_raw(input: &[u8], max: usize) -> Step {
let step = inflate(input, max);
#[cfg(test)]
INFLATED.with(|c| c.set(c.get() + step.as_ref().map_or_else(|f| f.partial.len(), Vec::len)));
step
}
fn inflate(input: &[u8], max: usize) -> Step {
let mut d = Decompress::new(false);
let mut out: Vec<u8> = Vec::new();
// One byte beyond the limit lets us tell "exactly max" from "more than max".
@@ -87,7 +115,7 @@ mod tests {
use crate::FilterError;
fn dec(d: &[u8]) -> Result<Vec<u8>, FilterError> {
decode(d, 1 << 24).map_err(|f| f.error)
decode(d, 1 << 24, Some(1 << 24)).0.map_err(|f| f.error)
}
#[test]
@@ -144,7 +172,7 @@ mod tests {
fn corrupt_and_truncated() {
let data: Vec<u8> = (0..10_000u32).map(|i| (i % 13) as u8).collect();
let z = encode(&data);
match decode(&z[..z.len() / 2], 1 << 24) {
match decode(&z[..z.len() / 2], 1 << 24, Some(1 << 24)).0 {
Err(f) => {
assert!(matches!(f.error, FilterError::Corrupt { .. }));
assert_eq!(&data[..f.partial.len()], &f.partial[..]);
@@ -155,6 +183,8 @@ mod tests {
assert!(matches!(dec(&[0x78, 0x9c, 0x07, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
// Preset dictionary.
assert!(matches!(dec(&[0x78, 0x20, 0, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
// One whose header is followed by data that inflates: refused all the same.
assert!(matches!(dec(&[0x78, 0xBB, 0x4b, 0x04, 0x00]), Err(FilterError::Corrupt { .. })));
}
#[test]
@@ -168,6 +198,107 @@ mod tests {
}
fn dec_limit(d: &[u8], max: usize) -> Result<Vec<u8>, FilterError> {
decode(d, max).map_err(|f| f.error)
decode(d, max, Some(max)).0.map_err(|f| f.error)
}
/// Deflate's bits: header fields and extra bits least significant first, Huffman codes most
/// significant first (RFC 1951 §3.1.1).
#[derive(Default)]
struct Bits {
out: Vec<u8>,
acc: u32,
n: u32,
}
impl Bits {
fn put(&mut self, value: u32, len: u32) {
for i in 0..len {
self.acc |= ((value >> i) & 1) << self.n;
self.n += 1;
if self.n == 8 {
self.out.push(self.acc as u8);
(self.acc, self.n) = (0, 0);
}
}
}
fn code(&mut self, code: u32, len: u32) {
for i in (0..len).rev() {
self.put((code >> i) & 1, 1);
}
}
fn finish(mut self) -> Vec<u8> {
if self.n > 0 {
self.out.push(self.acc as u8);
}
self.out
}
}
/// A dynamic block whose literal and length code has no end-of-block symbol can't end (RFC 1951
/// §3.2.5): it is refused, and nothing of it is kept.
#[test]
fn a_dynamic_code_without_an_end_is_damage() {
let mut bits = Bits::default();
bits.put(1, 1); // the final block,
bits.put(2, 2); // dynamic Huffman codes (§3.2.7)
bits.put(0, 5); // 257 literal and length codes
bits.put(0, 5); // 1 distance code
bits.put(14, 4); // 18 code length codes, in their order up to the one for 1
for symbol in [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1] {
bits.put(u32::from(symbol == 18 || symbol == 1), 3);
}
// Code length codes: 1 is 0, 18 is 1. 'A' and 'B' get a bit each, 256 none.
let zeros = |bits: &mut Bits, n: u32| {
bits.code(1, 1);
bits.put(n - 11, 7);
};
zeros(&mut bits, 65);
bits.code(0, 1);
bits.code(0, 1);
zeros(&mut bits, 138);
zeros(&mut bits, 52);
bits.code(0, 1); // the distance code: a bit
for _ in 0..4 {
bits.code(0, 1); // 'A'
}
let data = bits.finish();
assert!(zlib_header(&data).is_none(), "read as raw deflate");
match decode(&data, 1 << 20, Some(1 << 20)).0 {
Err(f) => {
assert!(matches!(f.error, FilterError::Corrupt { .. }), "{}", f.error);
assert_eq!(f.partial, b"");
}
Ok(out) => panic!("read as {out:?}"),
}
}
/// A back-reference to before the start of the output points at nothing: read strictly the
/// stream is refused, and what came before it is what a tolerant reading keeps. miniz_oxide,
/// flate2's backend here before zlib-rs, copied zeros and went on, so this also shows which
/// backend inflates (the price of reading Flate, `FLATE_READ_WORK`, is set for zlib-rs).
#[test]
fn a_copy_from_before_the_start_is_damage() {
let literal = |bits: &mut Bits, b: u8| bits.code(0x30 + u32::from(b), 8);
let mut bits = Bits::default();
bits.put(1, 1); // the final block,
bits.put(1, 2); // fixed Huffman codes (§3.2.6)
literal(&mut bits, b'A');
literal(&mut bits, b'B');
bits.code(1, 7); // length 3
bits.code(4, 5); // distance 5 (code 4 and an extra bit of 0), three bytes before the start
bits.put(0, 1);
literal(&mut bits, b'C');
bits.code(0, 7); // end of block
let data = bits.finish();
assert!(zlib_header(&data).is_none(), "read as raw deflate");
match decode(&data, 1 << 20, Some(1 << 20)).0 {
Err(f) => {
assert!(matches!(f.error, FilterError::Corrupt { .. }), "{}", f.error);
assert_eq!(f.partial, b"AB");
}
Ok(out) => panic!("read as {out:?}"),
}
}
}
+130 -32
View File
@@ -189,7 +189,7 @@ pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> {
/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than
/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer.
pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<Vec<u8>, FilterError> {
run(chain, data, max_output, None, true).map(|(v, _, _)| v).map_err(|(e, _)| e)
run(chain, data, max_output, None, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
}
/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
@@ -197,27 +197,28 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res
/// bytes, on success and on failure. A stage's output counts even when a later stage or its
/// predictor shrinks it.
pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, usize), (FilterError, usize)> {
run(chain, data, max_output, Some(max_output), true).map(|(v, _, spent)| (v, spent))
run(chain, data, max_output, Some(max_output), true, false).map(|(v, _, spent)| (v, spent))
}
/// Like [`decode`], but on corrupt data returns what was decoded before the error and
/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
/// chain still run on the partial data. Limit and unsupported errors are still errors.
pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool), FilterError> {
run(chain, data, max_output, None, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
run(chain, data, max_output, None, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
}
/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
run(chain, data, max_output, Some(max_output), false)
run(chain, data, max_output, Some(max_output), false, false)
}
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
/// a step for each byte of `data`, paid before anything is decoded, and one for each byte a stage
/// decodes, so a chain that fails, or a stage that goes past `max_output`, has paid for what it did.
/// No stage decodes past what is left: a chain that `work` can't pay for spends all of it and stops
/// there, so with nothing left nothing is read. A chain that can't be decoded at all costs nothing,
/// whatever is left, so what an image's filters are is known from the error.
/// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each
/// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each
/// byte a stage decodes, so a chain that fails, or a stage that goes past `max_output`, has paid for
/// what it did. No stage decodes past what is left: a chain that `work` can't pay for spends all of
/// it and stops there, so with nothing left nothing is read. A chain that can't be decoded at all
/// costs nothing, whatever is left, so what an image's filters are is known from the error.
///
/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`.
pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
@@ -237,14 +238,15 @@ fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &m
return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) };
}
let left = usize::try_from(*work).unwrap_or(usize::MAX);
match run(chain, data, max_output, Some(left), strict) {
match run(chain, data, max_output, Some(left), strict, true) {
Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out),
// The stage stopped at the lower of the two, which took as much to find out.
Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => {
pay(work, (decoded as u64).saturating_add(max_output as u64))?;
Err(FilterError::LimitExceeded(max_output))
}
Err((FilterError::LimitExceeded(_), _)) => {
// Past what is left, a stage stops, or a Flate stage doesn't start.
Err((FilterError::LimitExceeded(_) | FilterError::Spent, _)) => {
*work = 0;
Err(FilterError::Spent)
}
@@ -256,6 +258,20 @@ fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &m
/// up to the output limit, so the chain length multiplies the work.
pub const MAX_CHAIN: usize = 8;
/// The steps each byte Flate reads costs where decoding is paid for ([`decode_tolerant_within`],
/// [`decode_within`]), besides the step it was paid as input and a step for each byte it makes.
/// Inflating does work for every block it reads, whether or not the block makes anything: an empty
/// fixed-Huffman block is ten bits (RFC 1951 §3.2.6). With flate2's zlib-rs backend (zlib-rs
/// 0.6.8, release build) the slowest blocks measured, empty dynamic ones, took 21 ns a byte read
/// to make nothing, empty fixed ones 9 and fixed ones of a literal each 6, so at this price the
/// worst per byte read comes to about 7 ns a step, inside what a step of JBIG2 decoding takes.
/// (miniz_oxide 0.9.1, flate2's backend here before, took about 1.7 µs for every fixed-Huffman
/// block, empty or not: 1,383 ns a byte read on empty ones.) A stage's start isn't priced, about
/// 0.25 µs: every way to repeat one takes bytes of the file, so what starts cost is bounded by the
/// file's length. Data a real encoder makes took 0.2 to 2.3 ns a byte made, its reading included, so
/// an image pays twice its stored length more than it would unpriced.
pub const FLATE_READ_WORK: u64 = 2;
/// Whether a chain is one [`run`] takes, and if not, the error.
fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> {
if chain.len() > MAX_CHAIN {
@@ -269,22 +285,39 @@ fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> {
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together.
/// `priced`, the count is steps: each Flate stage also pays [`FLATE_READ_WORK`] for each byte it
/// reads, before it runs, so a stage `total` can't pay for doesn't run
/// ([`FilterError::Spent`]).
fn run(
chain: &[(Filter, Params)],
data: &[u8],
max: usize,
total: Option<usize>,
strict: bool,
priced: bool,
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
decodable(chain).map_err(|e| (e, 0))?;
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
let mut partial = false;
let mut spent = 0usize;
let price = usize::try_from(FLATE_READ_WORK).unwrap_or(usize::MAX);
for (filter, params) in chain {
let read = if priced && matches!(filter, Filter::Flate) { price.saturating_mul(cur.len()) } else { 0 };
let room = total.map_or(usize::MAX, |total| total.saturating_sub(spent));
if read > room {
return Err((FilterError::Spent, spent));
}
spent = spent.saturating_add(read);
#[cfg(test)]
STAGE_BYTES.with(|c| c.set(c.get() + cur.len()));
let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent)));
let (step, work) = decode_one(filter, params, &cur, limit);
// A second reading of the same bytes (Flate's raw retry) is paid for as the first was, and
// makes no more than is left once it has been.
let again = room.checked_sub(read.saturating_mul(2)).map(|left| max.min(left));
let (step, work, again) = decode_one(filter, params, &cur, limit, again);
if again {
spent = spent.saturating_add(read);
}
spent = spent.saturating_add(work);
cur = Cow::Owned(match step {
Ok(v) => v,
@@ -299,15 +332,21 @@ fn run(
Ok((cur.into_owned(), partial, spent))
}
/// One stage's result and the bytes its decoder produced (before any predictor).
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Step, usize) {
/// One stage's result, the bytes its decoder produced (before any predictor), and whether it
/// read its input twice, which it does only within `again` ([`flate::decode`]).
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option<usize>) -> (Step, usize, bool) {
let once = |(step, n): (Step, usize)| (step, n, false);
match filter {
Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"),
Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"),
Filter::AsciiHex => counted(asciihex::decode(data, max)),
Filter::Ascii85 => counted(ascii85::decode(data, max)),
Filter::RunLength => counted(runlength::decode(data, max)),
other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new(), discarded: 0 }), 0),
Filter::Flate => {
let (step, twice) = flate::decode(data, max, again);
let (step, n) = with_predictor(step, params, "FlateDecode");
(step, n, twice)
}
Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode")),
Filter::AsciiHex => once(counted(asciihex::decode(data, max))),
Filter::Ascii85 => once(counted(ascii85::decode(data, max))),
Filter::RunLength => once(counted(runlength::decode(data, max))),
other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new(), discarded: 0 }), 0, false),
}
}
@@ -553,7 +592,7 @@ mod tests {
let data = encode_flate(&hex);
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
let whole = decode(&chain, &data, 1 << 20).unwrap();
let cost = (data.len() + hex.len() + whole.len()) as u64;
let cost = (data.len() + hex.len() + whole.len()) as u64 + FLATE_READ_WORK * data.len() as u64;
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole);
assert_eq!(u64::MAX - work, cost);
@@ -585,8 +624,11 @@ mod tests {
assert_eq!(work, 0);
super::STAGE_BYTES.with(|c| c.get())
};
assert_eq!(read((bomb.len() + inner.len()) as u64 + 10), bomb.len() + inner.len(), "the second stage stops");
assert_eq!(read(bomb.len() as u64 + 10), bomb.len(), "the first stops, and the second never runs");
let paid = |n: usize| (1 + FLATE_READ_WORK) * n as u64;
assert_eq!(read(paid(bomb.len()) + paid(inner.len()) + 10), bomb.len() + inner.len(), "the second stage stops");
assert_eq!(read(paid(bomb.len()) + FLATE_READ_WORK * inner.len() as u64 - 1), bomb.len(), "the second can't pay to read");
assert_eq!(read(paid(bomb.len()) + 10), bomb.len(), "the first stops, and the second never runs");
assert_eq!(read(paid(bomb.len()) - 1), 0, "the first can't pay to read");
assert_eq!(read(bomb.len() as u64 - 1), 0, "it can't pay to be read");
}
@@ -598,18 +640,19 @@ mod tests {
let chain = [(Filter::Flate, Params::default())];
let mut work = u64::MAX;
assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000))));
assert_eq!(u64::MAX - work, data.len() as u64 + 1000);
let read = |n: usize| FLATE_READ_WORK * n as u64;
assert_eq!(u64::MAX - work, data.len() as u64 + read(data.len()) + 1000);
// The later stage of two, which pays for the first as well.
let bomb = encode_flate(&data);
let two = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())];
let mut work = u64::MAX;
assert!(matches!(decode_tolerant_within(&two, &bomb, data.len(), &mut work), Err(FilterError::LimitExceeded(_))));
assert_eq!(u64::MAX - work, (bomb.len() + 2 * data.len()) as u64);
assert_eq!(u64::MAX - work, (bomb.len() + 2 * data.len()) as u64 + read(bomb.len()) + read(data.len()));
// Both limits at once: a run that can't go on is what a caller needs to hear.
let mut work = data.len() as u64 + 1000;
let mut work = data.len() as u64 + read(data.len()) + 1000;
assert!(refused(&decode_tolerant_within(&chain, &data, 1000, &mut work).unwrap_err()));
assert_eq!(work, 0);
let mut work = data.len() as u64 + 1001;
let mut work = data.len() as u64 + read(data.len()) + 1001;
assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000))));
assert_eq!(work, 1, "the image's limit, with a step to spare");
}
@@ -625,21 +668,22 @@ mod tests {
assert!(!partial.is_empty());
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &cut, 1 << 20, &mut work).unwrap(), partial);
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64);
let read = |n: usize| FLATE_READ_WORK * n as u64;
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64 + read(cut.len()));
// Read strictly it is an error, for the same work.
let mut work = u64::MAX;
assert!(matches!(decode_within(&chain, &cut, 1 << 20, &mut work), Err(FilterError::Corrupt { .. })));
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64);
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64 + read(cut.len()));
let whole = encode_flate(&data);
let mut work = u64::MAX;
assert_eq!(decode_within(&chain, &whole, 1 << 20, &mut work).unwrap(), data);
assert_eq!(u64::MAX - work, (whole.len() + data.len()) as u64);
assert_eq!(u64::MAX - work, (whole.len() + data.len()) as u64 + read(whole.len()));
// A predictor that comes to nothing, after all of this was inflated for it.
let p = Params { predictor: 99, ..Params::default() };
let data = encode_flate(&vec![0; 1 << 16]);
let mut work = u64::MAX;
assert!(decode_tolerant_within(&[(Filter::Flate, p)], &data, 1 << 20, &mut work).unwrap().is_empty());
assert_eq!(u64::MAX - work, data.len() as u64 + (1 << 16));
assert_eq!(u64::MAX - work, data.len() as u64 + read(data.len()) + (1 << 16));
}
/// A stream that reads as zlib only until it fails, and as raw deflate until that fails too,
@@ -661,13 +705,67 @@ mod tests {
assert_eq!(spent, 0x0601);
let mut work = u64::MAX;
assert!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap().is_empty());
assert_eq!(u64::MAX - work, (data.len() + 0x0601) as u64);
// Read twice: as zlib, then as raw deflate.
assert_eq!(u64::MAX - work, (data.len() + 0x0601) as u64 + 2 * FLATE_READ_WORK * data.len() as u64);
// Through a predictor, which would otherwise lose the count.
let p = Params { predictor: 12, columns: 4, ..Params::default() };
let (_, _, spent) = decode_tolerant_counted(&[(Filter::Flate, p)], &data, 1 << 20).unwrap();
assert_eq!(spent, 0x0601);
}
/// Inflating does work for every block it reads, whether or not the block makes anything: empty
/// fixed-Huffman blocks, ten bits each, make nothing however many there are. Each byte of them
/// is paid for at Flate's price before any is read.
#[test]
fn a_flate_stage_pays_for_what_it_reads() {
// Four empty blocks in five bytes, as raw deflate, then an empty final block.
let mut hollow = [0x02, 0x08, 0x20, 0x80, 0x00].repeat(1 << 12);
hollow.extend([0x03, 0x00]);
let chain = [(Filter::Flate, Params::default())];
assert_eq!(decode(&chain, &hollow, 1 << 20).unwrap(), b"");
let cost = (1 + FLATE_READ_WORK) * hollow.len() as u64;
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &hollow, 1 << 20, &mut work).unwrap(), b"");
assert_eq!(u64::MAX - work, cost);
let mut work = cost;
assert!(decode_tolerant_within(&chain, &hollow, 1 << 20, &mut work).is_ok(), "exactly enough");
assert_eq!(work, 0);
super::STAGE_BYTES.with(|c| c.set(0));
let mut work = cost - 1;
assert!(refused(&decode_tolerant_within(&chain, &hollow, 1 << 20, &mut work).unwrap_err()));
assert_eq!((work, super::STAGE_BYTES.with(|c| c.get())), (0, 0), "nothing was inflated");
// Counted for a byte allowance, as a page's content is, reading costs nothing.
assert_eq!(decode_counted(&chain, &hollow, 1 << 20).unwrap(), (Vec::new(), 0));
}
/// A zlib header that only looked valid is read again as raw deflate, and the second reading
/// is paid for as the first was. One that can't be paid for is refused, not read as damaged.
#[test]
fn a_second_reading_is_paid_for() {
// As zlib: a block of a reserved type at once. As raw deflate: a stored block of this many
// zero bytes, then an empty final block.
let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9];
data.extend(vec![0u8; 0x0601]);
data.extend([0x03, 0x00]);
let chain = [(Filter::Flate, Params::default())];
assert_eq!(decode(&chain, &data, 1 << 20).unwrap(), vec![0u8; 0x0601]);
let read = FLATE_READ_WORK * data.len() as u64;
let mut work = (data.len() + 0x0601) as u64 + 2 * read;
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), vec![0u8; 0x0601]);
assert_eq!(work, 0, "exactly enough");
// Enough to read it once and not twice: it isn't read again.
super::flate::INFLATED.with(|c| c.set(0));
let mut work = data.len() as u64 + 2 * read - 1;
assert!(refused(&decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap_err()));
assert_eq!((work, super::flate::INFLATED.with(|c| c.get())), (0, 0));
// Enough to read it twice, and a byte short of what it makes: the second reading makes no
// more than is left once it has been paid for.
super::flate::INFLATED.with(|c| c.set(0));
let mut work = (data.len() + 0x0601) as u64 + 2 * read - 1;
assert!(refused(&decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap_err()));
assert!(super::flate::INFLATED.with(|c| c.get()) < 0x0601, "it stopped short");
}
/// With nothing left nothing is read. What can't be decoded at all never costs anything, and
/// says so whatever is left. Without filters, what is read is the data.
#[test]
+8 -5
View File
@@ -156,13 +156,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
let program = doc.resolve(&program);
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() };
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
Some(Object::Stream(map)) => {
let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() };
let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() };
bit_set_below_64k(
map.as_chunks::<2>()
.0
@@ -189,7 +189,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem == 0.0 {
@@ -691,7 +691,7 @@ impl Metrics {
m.codes = Codes::Two;
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
&& let Object::Stream(s) = &*e
&& let Ok(data) = s.decoded_within(MAX_CMAP_BYTES)
&& let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES)
{
let (spaces, cids) = parse_cmap(&data);
if !spaces.is_empty() {
@@ -1293,7 +1293,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
// With `/DecodeParms` given by reference, as every stream of the font is read here,
// and as the redaction proof reads the map: the two must get the same text, or a copy
// redaction rightly left counts as a survivor.
if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
+115 -18
View File
@@ -104,6 +104,18 @@ fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict {
d
}
/// `data` behind Flate and a PNG predictor whose `/DecodeParms` is a reference: read without
/// it, every row of four bytes keeps its tag byte.
fn behind_parms_by_reference(doc: &mut Document, data: &[u8]) -> Object {
let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(4))])));
let mut data = data.to_vec();
data.resize(data.len().next_multiple_of(4), 0);
let rows: Vec<u8> = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut stream = Stream::flate(Dict::new(), &rows);
stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
Object::Ref(doc.add(Object::Stream(stream)))
}
#[test]
fn embedded_editor_fonts_are_validated_without_installing_or_exporting() {
use pdfcraft_cos::Stream;
@@ -124,34 +136,46 @@ fn embedded_editor_fonts_are_validated_without_installing_or_exporting() {
}
/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name
/// ("Mono" would mean Courier, 600 units for every glyph).
/// ("Mono" would mean Courier, 600 units for every glyph), the program's `/DecodeParms` given
/// directly or by reference.
#[test]
fn a_font_without_widths_measures_with_its_embedded_program() {
use pdfcraft_cos::Stream;
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let upem = f64::from(face.metrics(size, location).units_per_em);
let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem;
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c));
for by_reference in [false, true] {
let mut doc = Document::new_empty();
let program = if by_reference {
behind_parms_by_reference(&mut doc, bytes)
} else {
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))))
};
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), program);
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!(
(m.width(u32::from(c)) - advance(c)).abs() < 1e-9,
"{c}, by reference {by_reference}: {} vs {}",
m.width(u32::from(c)),
advance(c)
);
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6, by reference {by_reference}");
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
}
#[test]
@@ -342,6 +366,79 @@ fn cmap_streams_decode_within_a_size_cap() {
}
}
#[test]
fn cmap_streams_read_their_parameters_given_by_reference() {
// Both maps behind a predictor whose `/DecodeParms` is a reference: read without it, every
// row keeps its tag byte inside the map's tokens.
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange";
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 begincidrange <0000> <FFFF> 0 endcidrange";
let mut doc = Document::new_empty();
let (tu, enc) = (behind_parms_by_reference(&mut doc, to_unicode), behind_parms_by_reference(&mut doc, encoding));
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
let f = font(
&mut doc,
vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.decode(&[0, 0x24]), "A", "ToUnicode");
assert!(matches!(m.codes, Codes::Ranges(_)), "Encoding");
}
/// A Type 0 subset over Inter with `/DW` alone, whose CIDs 1 to 7 draw "A B C D" (ToUnicode
/// gives CIDs 1 and 2), its program and its `/CIDToGIDMap` each behind a predictor whose
/// `/DecodeParms` is a reference, or behind Flate alone.
fn inter_subset(program_by_reference: bool, map_by_reference: bool) -> Metrics {
use skrifa::MetadataProvider as _;
let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let gid = |c: char| u16::try_from(face.charmap().map(c).unwrap().to_u32()).unwrap();
let gids: Vec<u8> = std::iter::once(0).chain("A B C D".chars().map(gid)).flat_map(u16::to_be_bytes).collect();
let stream = |doc: &mut Document, data: &[u8], by_reference: bool| {
if by_reference { behind_parms_by_reference(doc, data) } else { Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))) }
};
let mut doc = Document::new_empty();
let program = stream(&mut doc, bytes, program_by_reference);
let map = stream(&mut doc, &gids, map_by_reference);
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 2 beginbfchar <0001> <0041> <0002> <0020> endbfchar";
let to_unicode = stream(&mut doc, to_unicode, false);
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), program);
let mut desc = Dict::new();
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
desc.set(b"DW".to_vec(), Object::Int(600));
desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
desc.set(b"CIDToGIDMap".to_vec(), map);
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("ABCDEF+Inter")),
("Encoding", Object::name("Identity-H")),
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
("ToUnicode", to_unicode),
],
);
Metrics::from_dict(&doc, &f)
}
/// A subset with `/DW` alone has a glyph for a CID when the program's glyph it maps to has an
/// outline, so the space, Inter's empty glyph, is refused. Its program, or its `/CIDToGIDMap`,
/// behind parameters given by reference says the same: read without them, no program says
/// which glyphs are empty, or the map points the CIDs at other glyphs.
#[test]
fn a_subsets_program_and_cid_map_read_their_parameters_given_by_reference() {
let direct = inter_subset(false, false);
assert!(!direct.outlined_cids.is_empty());
assert_eq!(direct.encode("A").as_deref(), Some(&[0, 1][..]));
assert_eq!(direct.encode(" "), None, "the space's glyph is empty");
for (program, map) in [(true, false), (false, true)] {
let m = inter_subset(program, map);
assert_eq!(m.outlined_cids, direct.outlined_cids, "program by reference: {program}, map: {map}");
assert_eq!(m.encode(" "), None, "program by reference: {program}, map: {map}");
}
}
#[test]
fn composite_fonts_use_to_unicode() {
let mut doc = Document::new_empty();
+4 -1
View File
@@ -22,11 +22,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
A page whose content doesn't decode, or is damaged partway, is left as it is: written again from
what decoded, it would lose the rest.
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
through form XObjects, up to 12 deep; the smallest over all uses).
Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches.
If a use can't be
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
measured (forms nested deeper or drawn too many times, or content that doesn't decode or is
damaged partway, which may draw an image larger past the damage), no image is resampled. Colour and grayscale images above a
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is.
+2 -2
View File
@@ -394,8 +394,8 @@ mod tests {
let mut budget = ImageBudget::with_work(0);
assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0);
assert_eq!(budget.refused(), 1);
// Reading each strip, and building the picture of 8 rows of 8 they make.
let cost = raw + 8 * (8 + 8) + 8 * 8;
// Reading each strip, inflating it, and building the picture of 8 rows of 8 they make.
let cost = (1 + pdfcraft_filters::FLATE_READ_WORK) * raw + 8 * (8 + 8) + 8 * 8;
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8);
assert_eq!(u64::MAX - budget.work_left(), cost);
+9 -8
View File
@@ -32,9 +32,10 @@ fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> {
}
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
/// doesn't decode or the budget runs out: the content is not all known.
/// doesn't decode, is damaged partway or the budget runs out: the content is not all known.
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
let reads = budget.decode_contents_with_damage(doc, contents);
let pieces = reads.into_iter().map(|read| read.ok().filter(|(_, damaged)| !damaged).map(|(data, _)| data)).collect::<Option<Vec<_>>>()?;
Some(Pieces::join(&pieces).data().to_vec())
}
@@ -214,7 +215,7 @@ impl Walk<'_> {
self.complete = false;
return;
}
let Ok(data) = self.budget.decode(s) else {
let Ok((data, false)) = self.budget.decode_with_damage(doc, s, usize::MAX) else {
self.complete = false;
return;
};
@@ -245,9 +246,9 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool {
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
///
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
/// among those missed.
/// If forms nest too deeply, are drawn too many times, or a page's or form's content doesn't
/// decode within its budget or is damaged partway, every image is given `INFINITY` (never
/// resampled), since its largest use may be among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
walk_pages(doc, pages).0
}
@@ -1699,7 +1700,7 @@ mod dimension_tests {
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = Stream::flate(dict, &samples);
let read = (image.raw.len() + samples.len()) as u64 + 256 * 256;
let read = (image.raw.len() + samples.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * image.raw.len() as u64 + 256 * 256;
let original = doc.add(Object::Stream(image.clone()));
let mut xobjects = Dict::new();
xobjects.set(b"I0".to_vec(), Object::Ref(original));
@@ -1791,7 +1792,7 @@ mod dimension_tests {
};
let mut doc = Document::new_empty();
let mask = image("DeviceGray");
let cost = (mask.raw.len() + noise.len()) as u64 + 256 * 256;
let cost = (mask.raw.len() + noise.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * mask.raw.len() as u64 + 256 * 256;
let masked = doc.add(Object::Stream(mask));
let mut with_mask = image("DeviceGray");
with_mask.dict.set(b"SMask".to_vec(), Object::Ref(masked));
+81
View File
@@ -3157,6 +3157,87 @@ fn images_that_do_not_tile_a_picture_are_left_alone() {
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
Stream::from_raw(dict, raw)
}
/// A page whose content is damaged partway isn't all known: its strips aren't merged, which would
/// write its content again without what follows the damage.
#[test]
fn strips_on_a_page_damaged_partway_are_left_as_they_are() {
let (mut doc, _) = printed_page(40, |_| false, |_, s| s);
let p = pdfcraft_annot::page_refs(&doc).unwrap()[0];
let bad = damaged_flate(b"0 g\n", b"q 1000 0 0 1 0 0 cm /R0 Do Q");
let damaged = doc.add(Object::Stream(bad.clone()));
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
let contents = Object::Array(vec![first, Object::Ref(damaged)]);
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), contents.clone())).unwrap();
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}");
assert_eq!(doc.get(p).as_dict().unwrap().get(b"Contents"), Some(&contents));
assert_eq!(stream(&doc, damaged), bad);
}
/// An image may be drawn larger past the damage in a page's or a form's content, so no image is
/// resampled for the uses before it.
#[test]
fn content_damaged_partway_leaves_every_image_at_its_resolution() {
for in_form in [false, true] {
let mut doc = Document::new_empty();
let img = image(&mut doc, 720, 720, 1, false, None);
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
assert_eq!(effective_resolutions(&doc, &[p])[&img], 720.0);
let mut bad = damaged_flate(b"q 72 0 0 72 0 0 cm /Im Do Q\n", b"q 720 0 0 720 0 0 cm /Im Do Q");
let added = if in_form {
bad.dict.set(b"Subtype".to_vec(), Object::name("Form"));
bad.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
bad.dict.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Im", img)]))));
let f = doc.add(Object::Stream(bad));
let res = Object::Dict(resources(xobjects(&[("Im", img), ("F", f)])));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), res)).unwrap();
content(&mut doc, "/F Do")
} else {
doc.add(Object::Stream(bad))
};
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), Object::Array(vec![first, Object::Ref(added)]))).unwrap();
// Not measured, or measured as never resampled: the image pass reads only what was measured.
let ppi = effective_resolutions(&doc, &[p]).get(&img).copied().unwrap_or(f64::INFINITY);
assert_eq!(ppi, f64::INFINITY, "in a form: {in_form}");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 0, "{r:?}");
}
}
/// One page damaged partway stops resampling for the whole document: another page drawing the same
/// image at its full resolution, whole, doesn't get it resampled either.
#[test]
fn content_damaged_partway_on_one_page_leaves_the_image_another_page_draws() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 720, 720, 1, false, None);
let damaged = page(&mut doc, &[("Im", img)], "");
let bad = doc.add(Object::Stream(damaged_flate(b"0 g\n", b"q 720 0 0 720 0 0 cm /Im Do Q")));
doc.update_dict(damaged, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap();
let whole = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
assert_eq!(effective_resolutions(&doc, &[whole])[&img], 720.0);
let ppi = effective_resolutions(&doc, &[damaged, whole]).get(&img).copied().unwrap_or(f64::INFINITY);
assert_eq!(ppi, f64::INFINITY);
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 0, "{r:?}");
assert_eq!(doc.get(img).as_dict().and_then(|d| d.int(b"Width")), Some(720));
}
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");
+3 -2
View File
@@ -499,9 +499,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
let bbox = rect(b"CropBox").or_else(|| rect(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
// Content: the page's streams, decoded and joined.
// Content: the page's streams, decoded and joined. Of a stream damaged partway, the copy draws
// what decoded before the damage.
let content = match own.get(b"Contents") {
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?,
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?.0,
None => Vec::new(),
};
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
+83 -49
View File
@@ -40,12 +40,15 @@ enum Owner {
struct Pruner<'a> {
doc: &'a Document,
used: HashMap<XKey, HashSet<Vec<u8>>>,
/// Dictionaries whose drawn names can't be known for certain (undecodable content, nesting
/// past the depth limit, Type 3 glyphs drawing through them). They are left untouched:
/// pruning must never drop an XObject something still draws.
/// Dictionaries whose drawn names can't be known for certain (undecodable content, Type 3
/// glyphs drawing through them). They are left untouched: pruning must never drop an XObject
/// something still draws.
unsure: HashSet<XKey>,
/// An overlarge glyph-resource graph is not safe to prune at all.
glyph_budget_exhausted: bool,
/// Nor is a file with forms nested past [`MAX_FORM_DEPTH`]: what the forms there draw isn't
/// read, and they may draw it through resources shared with content that was.
too_deep: bool,
glyph_work_left: usize,
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
@@ -53,6 +56,9 @@ struct Pruner<'a> {
/// 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)>,
/// Owners of resources that content not read whole drew against, whose every form was read
/// as drawn ([`Pruner::unread`]): once is enough.
partly_read: HashSet<Owner>,
}
impl Pruner<'_> {
@@ -185,7 +191,7 @@ impl Pruner<'_> {
return;
};
if depth > MAX_FORM_DEPTH {
self.unsure.insert(key);
self.too_deep = true;
return;
}
for op in pdfcraft_content::parse(data).ops {
@@ -194,54 +200,83 @@ impl Pruner<'_> {
}
let Some(name) = op.name(0) else { continue };
self.used.entry(key).or_default().insert(name.to_vec());
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
let Object::Stream(s) = &*self.doc.get(r) else { continue };
if s.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
let (inner, inner_owner) = match s.dict.get(b"Resources") {
Some(res) => match res.as_ref() {
Some(rr) => match self.doc.get(rr).as_dict().cloned() {
Some(d) => (d, Owner::Resources(rr)),
None => continue,
},
None => match self.doc.resolve(res).as_dict().cloned() {
// The form carries its resources inline: filter them on the form itself.
Some(d) => (d, Owner::Resources(r)),
None => continue,
},
self.form(&xobjects, name, resources, owner, depth);
}
}
/// For content that can't be read whole (damaged partway, or not decoding at all): what it
/// draws is unknown, so its `/XObject` dictionary is kept whole, and every form there is read
/// as drawn, which also reads every form the part before any damage draws. A form whose
/// resources are shared keeps what it draws from them too.
fn unread(&mut self, resources: &Dict, owner: Owner, depth: u8) {
self.protect_type3_resources(resources, owner);
let Some((xobjects, key)) = self.xobjects(resources, owner) else { return };
self.unsure.insert(key);
if depth > MAX_FORM_DEPTH {
self.too_deep = true;
return;
}
if !self.partly_read.insert(owner) {
return;
}
for (name, _) in xobjects.iter() {
self.form(&xobjects, name, resources, owner, depth);
}
}
/// Record what the XObject `name` of `xobjects` draws when it is a form, drawn from content
/// with `resources`.
fn form(&mut self, xobjects: &Dict, name: &[u8], resources: &Dict, owner: Owner, depth: u8) {
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { return };
let Object::Stream(s) = &*self.doc.get(r) else { return };
// A `/Subtype` given by reference reads as no form here, as wherever the workspace tells
// forms from images (redaction, edit and Optimize among them): resolving it is a change
// across all of them.
if s.dict.name(b"Subtype") != Some(b"Form") {
return;
}
let (inner, inner_owner) = match s.dict.get(b"Resources") {
Some(res) => match res.as_ref() {
Some(rr) => match self.doc.get(rr).as_dict().cloned() {
Some(d) => (d, Owner::Resources(rr)),
None => return,
},
// 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.
Err(_) => {
self.protect_type3_resources(&inner, inner_owner);
self.distrust(&inner, inner_owner);
}
}
None => match self.doc.resolve(res).as_dict().cloned() {
// The form carries its resources inline: filter them on the form itself.
Some(d) => (d, Owner::Resources(r)),
None => return,
},
},
// 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)) {
return;
}
#[cfg(test)]
crate::work(1);
match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) {
Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1),
Ok((_, true)) | Err(_) => self.unread(&inner, inner_owner, depth + 1),
}
}
}
/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An
/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`], and
/// whether a stream is damaged partway (its part is then what decoded before the damage). An
/// error when a stream can't be decoded or the budget runs out: the page may draw names that
/// can't be seen.
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<(Vec<u8>, bool), CosError> {
let mut out = Vec::new();
for data in DecodeBudget::page().decode_contents(doc, contents) {
out.extend(data?);
let mut damaged = false;
for read in DecodeBudget::page().decode_contents_with_damage(doc, contents) {
let (data, partial) = read?;
damaged |= partial;
out.extend(data);
out.push(b' ');
}
Ok(out)
Ok((out, damaged))
}
/// The resources of a page (as `walk` reports them) with their owner.
@@ -313,10 +348,12 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
used: HashMap::new(),
unsure: HashSet::new(),
glyph_budget_exhausted: false,
too_deep: false,
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
forms_seen: HashSet::new(),
partly_read: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
@@ -324,16 +361,13 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue };
let Some(contents) = pd.get(b"Contents").cloned() else { continue };
match content_bytes(pruner.doc, &contents) {
Ok(data) if data.is_empty() => {}
Ok(data) => pruner.content(&data, &res_dict, owner, 0),
Err(_) => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
Ok((data, false)) if data.is_empty() => {}
Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0),
Ok((_, true)) | Err(_) => pruner.unread(&res_dict, owner, 0),
}
}
let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner;
if glyph_budget_exhausted {
let Pruner { used, unsure, glyph_budget_exhausted, too_deep, .. } = pruner;
if glyph_budget_exhausted || too_deep {
return Ok(0);
}
let mut removed = 0;
+256
View File
@@ -2628,6 +2628,262 @@ fn extract_keeps_images_an_undecodable_page_might_draw() {
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay");
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A page or form damaged partway may draw images past the damage, which other readers may still
/// read: pruning keeps them, as it does for content that doesn't decode.
#[test]
fn extract_keeps_images_content_damaged_partway_might_draw() {
use pdfcraft_cos::ObjRef;
let mut page = doc_images_with(&raw("/ImA Do"), "");
page.set(ObjRef::new(6, 0), damaged_flate(b"/ImA Do\n", b"/ImB Do"));
let mut form = doc_images_with(&raw("/Fm Do"), "");
let mut fm = damaged_flate(b"/ImA Do\n", b"/ImB Do");
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = form.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
form.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
for (src, what) in [(page, "page"), (form, "form")] {
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: an image drawn past the damage must stay: {names:?}");
}
}
/// Two pages: the first draws `/Fm` through resources of its own, `/Fm` draws `/Nested` through
/// resources of its own too, and `/Nested` draws `/ImA` through resources it shares with the second
/// page, which draws `/ImB` through them. The three streams are the first page's content, `/Fm`
/// and `/Nested`.
fn doc_shared_form_resources(page: pdfcraft_cos::Stream, fm: pdfcraft_cos::Stream, nested: pdfcraft_cos::Stream) -> Document {
use pdfcraft_cos::ObjRef;
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Fm 7 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 8 0 R >>".into(),
raw(""),
raw("/ImB Do"),
raw(""),
"<< /XObject << /ImA 9 0 R /ImB 10 0 R >> >>".into(),
image(1, 1, 1),
image(1, 1, 2000),
raw(""),
];
let mut doc = open(build(&b, "/Root 1 0 R"));
doc.set(ObjRef::new(5, 0), page);
let nested_res: Dict =
[(b"XObject".to_vec(), Object::Dict([(b"Nested".to_vec(), Object::Ref(ObjRef::new(11, 0)))].into_iter().collect()))].into_iter().collect();
for (num, mut form, resources) in [(7, fm, Object::Dict(nested_res)), (11, nested, Object::Ref(ObjRef::new(8, 0)))] {
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
form.dict.set(b"Resources".to_vec(), resources);
doc.set(ObjRef::new(num, 0), form);
}
doc
}
/// Every form the resources of a page or form damaged partway name may be drawn, before the
/// damage or past it, so each is read: one whose resources another page shares keeps what it draws
/// from them. So is each one content that doesn't decode may draw.
#[test]
fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources() {
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let undecodable = |content: &[u8]| {
let mut s = whole(content);
s.dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
s
};
let cases = [
("page, before the damage", damaged_flate(b"/Fm Do\n", b"0 g"), whole(b"/Nested Do")),
("page, past the damage", damaged_flate(b"0 g\n", b"/Fm Do"), whole(b"/Nested Do")),
("page that doesn't decode", undecodable(b"/Fm Do"), whole(b"/Nested Do")),
("form, before the damage", whole(b"/Fm Do"), damaged_flate(b"/Nested Do\n", b"0 g")),
("form, past the damage", whole(b"/Fm Do"), damaged_flate(b"0 g\n", b"/Nested Do")),
("form that doesn't decode", whole(b"/Fm Do"), undecodable(b"/Nested Do")),
];
for (what, page, fm) in cases {
let part = full_roundtrip(&extract_pages(&doc_shared_form_resources(page, fm, whole(b"/ImA Do")), &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}");
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: {names:?}");
}
// What nothing draws still goes.
let src = doc_shared_form_resources(whole(b"0 g"), whole(b"/Nested Do"), whole(b"/ImA Do"));
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()]);
}
/// Forms are read 8 deep (`prune.rs`'s `MAX_FORM_DEPTH`). A damaged form that deep still has
/// every form it names read as drawn: here `/Nested`, which draws through resources another page
/// shares. Read 9 deep, it leaves the file unpruned, so what it draws from them stays.
#[test]
fn a_damaged_form_at_the_depth_limit_has_its_forms_read() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let mut src = doc_shared_form_resources(whole(b"/C Do"), damaged_flate(b"0 g\n", b"/Nested Do"), whole(b"/ImA Do"));
// Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep.
let mut next = ObjRef::new(7, 0);
for _ in 1..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}");
}
/// Past the depth limit a form isn't read, nor what the forms it draws draw: one of those may draw
/// through resources another page shares, and keep images there that page doesn't draw, so nothing
/// is pruned. Here `/Fm` is 9 deep, whole or damaged partway, and `/Nested`, which it draws, draws
/// `/ImA` through the resources the second page draws `/ImB` through.
#[test]
fn nothing_is_pruned_past_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
for (what, fm) in [("whole", whole(b"/Nested Do")), ("damaged", damaged_flate(b"0 g\n", b"/Nested Do"))] {
let mut src = doc_shared_form_resources(whole(b"/C Do"), fm, whole(b"/ImA Do"));
// Eight forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 9 deep.
let mut next = ObjRef::new(7, 0);
for _ in 0..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}");
}
}
/// Down to the depth limit pruning goes on: under a page whole or damaged partway, a form 8 deep,
/// whole or damaged, is read, and the image nothing draws, `/ImA`, still goes from the resources
/// the second page draws `/ImB` through.
#[test]
fn pruning_goes_on_down_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let cases = [
("whole", whole(b"/C Do"), whole(b"0 g")),
("damaged form", whole(b"/C Do"), damaged_flate(b"0 g\n", b"0 g")),
("damaged page", damaged_flate(b"/C Do\n", b"0 g"), whole(b"0 g")),
];
for (what, page, fm) in cases {
let mut src = doc_shared_form_resources(page, fm, whole(b""));
// `/Fm` names an image, not `/Nested`: nothing is read 9 deep.
let image: Dict = [(b"Img".to_vec(), Object::Ref(ObjRef::new(9, 0)))].into_iter().collect();
let resources = Object::Dict([(b"XObject".to_vec(), Object::Dict(image))].into_iter().collect());
src.update_dict(ObjRef::new(7, 0), |f| f.set(b"Resources".to_vec(), resources)).unwrap();
// Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep.
let mut next = ObjRef::new(7, 0);
for _ in 1..8 {
let mut form = whole(b"/C Do");
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()], "{what}");
}
}
/// A chain of forms nested far past the depth limit, whole or each damaged partway, is read only
/// down to it: the walk's depth stays bounded however long the chain is, and the run ends without
/// an error, having pruned nothing.
#[test]
fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let xobjects = |names: Dict| Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect());
for damaged in [false, true] {
let mut doc = doc_shared_form_resources(whole(b"/C Do"), whole(b""), whole(b""));
let mut names = Dict::new();
for _ in 0..100_000 {
let mut form = if damaged { damaged_flate(b"", b"/C Do") } else { whole(b"/C Do") };
form.dict.set(b"Subtype".to_vec(), Object::name("Form"));
form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
form.dict.set(b"Resources".to_vec(), xobjects(names));
let next = doc.add(Object::Stream(form));
names = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
}
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Resources".to_vec(), xobjects(names))).unwrap();
assert!(matches!(crate::prune::prune_unused_xobjects(&mut doc), Ok(0)), "damaged: {damaged}");
}
}
/// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is
/// kept, not lost to content read with the predictor's tag bytes left in it.
#[test]
fn extract_keeps_images_a_form_with_parameters_by_reference_draws() {
use pdfcraft_cos::ObjRef;
let content = b"q 10 0 0 10 0 0 cm /ImB Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let mut src = doc_images_with(&raw("/Fm Do"), "");
let parms = src.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
let mut fm = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
fm.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = src.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
src.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let part = full_roundtrip(&extract_pages(&src, &[1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{names:?}");
assert!(!names.iter().any(|n| n.as_slice() == b"ImA"), "what the part doesn't draw still goes: {names:?}");
}
#[test]
fn extract_keeps_images_type3_glyphs_may_draw() {
// A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`.
+1
View File
@@ -20,6 +20,7 @@ pdfcraft-annot = { workspace = true }
pdfcraft-xfa = { workspace = true }
# CCITT Group 4 and JBIG2 encoding for cleared one-bit images.
pdfcraft-filters = { workspace = true }
regex = { workspace = true }
thiserror = { workspace = true }
sha2 = "0.11"
+1 -1
View File
@@ -1000,7 +1000,7 @@ fn xobject(
}
// Strict, like page content: a Form read only in part counts as undecodable.
let data = if scope.budget.enter_form() {
scope.budget.decode_strict(s)
scope.budget.decode_strict(doc, s)
} else {
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
};
+8 -2
View File
@@ -32,6 +32,7 @@ mod tests;
mod tests_verify;
pub(crate) mod type3;
pub(crate) mod type3_tables;
pub mod user_regex;
pub mod verify;
use interp::{Mode, Scope, process};
@@ -290,7 +291,7 @@ pub(crate) fn page_streams_within(
match &*doc.resolve(o) {
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
// still draws in viewers that read further; anything not read can't be cleaned.
Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Stream(s) => data.push(budget.decode_strict(doc, s).map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Null => data.push(Vec::new()),
_ => return Err(RedactError::Unreadable(index + 1)),
}
@@ -820,7 +821,12 @@ fn redact_page_content(
// longer draws so the removed image or form bytes cannot survive in the saved file.
let page_content: Vec<Vec<u8>> = new_list
.iter()
.map(|o| match &*doc.resolve(o) {
.zip(&list)
.zip(&data)
.map(|((now, was), read)| match &*doc.resolve(now) {
// Left as it was: what was read above, with its parameters by reference.
_ if now == was => read.clone(),
// Written above by `Stream::flate`, which leaves no `/DecodeParms`.
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
})
+85
View File
@@ -143,6 +143,91 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
assert!(!content(&doc, 0).contains("1234"));
}
/// `content` behind a PNG Up predictor whose `/DecodeParms` is an indirect object added to `doc`,
/// in a stream with `dict`'s entries too.
fn predicted(doc: &mut Document, dict: pdfcraft_cos::Dict, content: &[u8]) -> pdfcraft_cos::Stream {
use pdfcraft_cos::{Dict, Object, Stream};
// One column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let parms = doc.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
let mut s = Stream::flate(Dict::new(), &rows);
for (k, v) in dict.iter() {
s.dict.set(k.to_vec(), v.clone());
}
s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
s
}
/// Content whose `/DecodeParms` is given by reference is read through the reference, on the page
/// and in a form: the text under a mark goes. Read with the predictor's tag bytes left in, no glyph
/// was found under the mark, and the redaction left the text in the file and said it was done.
#[test]
fn content_with_parameters_given_by_reference_is_redacted() {
use pdfcraft_cos::{Dict, ObjRef, Object};
let secret = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET";
let marked = [[5.0, 90.0, 120.0, 115.0]];
let mut page = one_page(b"", "", vec![]);
let s = predicted(&mut page, Dict::new(), secret);
page.set(ObjRef::new(4, 0), s);
// The parameters take object 7 and the form 8.
let mut form = one_page(b"/Fm Do", "/XObject << /Fm 8 0 R >>", vec![]);
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Form"));
dict.set(b"BBox".to_vec(), Object::Array([0, 0, 300, 300].into_iter().map(Object::Int).collect()));
dict.set(
b"Resources".to_vec(),
Object::Dict(
[(b"Font".to_vec(), Object::Dict([(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)))].into_iter().collect()))].into_iter().collect(),
),
);
let s = predicted(&mut form, dict, secret);
form.set(ObjRef::new(8, 0), s);
for (mut doc, what) in [(page, "page"), (form, "form")] {
assert_eq!(under(&mut doc, 0, &marked), 6, "{what}");
mark(&mut doc, 0, &marked, "");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.marks, r.glyphs), (1, 6), "{what}");
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &marked), 0, "{what}");
// Nor is the text left in a stream the page no longer draws, read with its parameters.
for (r, o) in saved.scan_objects() {
let Object::Stream(s) = &*o else { continue };
let data = pdfcraft_cos::DecodeBudget::new(usize::MAX, 0).decode_with_damage(&saved, s, usize::MAX).map(|(d, _)| d).unwrap_or_default();
assert!(!data.windows(6).any(|w| w == b"SECRET"), "{what}: {r:?}");
}
}
}
/// An image removed under a mark stays in the page's resources while another of its content
/// streams, with `/DecodeParms` given by reference, still draws it. Read without the parameters,
/// that stream seemed not to draw it, and the page lost the image there.
#[test]
fn an_image_drawn_again_in_content_with_parameters_by_reference_stays() {
use pdfcraft_cos::{Dict, ObjRef, Object};
let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [255, 255, 255, 255]);
let mut doc = one_page(b"q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 7 0 R >>", vec![img]);
let again = predicted(&mut doc, Dict::new(), b"q 10 0 0 10 200 200 cm /Im1 Do Q");
let again = doc.add(Object::Stream(again));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(again)])))
.unwrap();
mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.images_removed, 1, "{r:?}");
let doc = reopen(&doc);
assert!(content(&doc, 0).contains("200 200 cm /Im1 Do"), "{}", content(&doc, 0));
let p = pdfcraft_model::pages(&doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
// The save numbers objects afresh: the image is told by what it is.
let Some(Object::Stream(image)) = xo.reference(b"Im1").map(|r| (*doc.get(r)).clone()) else { panic!("no /Im1: {xo:?}") };
assert_eq!((image.dict.name(b"Subtype"), image.decoded().ok()), (Some(&b"Image"[..]), Some(vec![255; 4])));
}
/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
/// lie past a mark that covers all of it on screen, and was kept.
+39
View File
@@ -332,6 +332,45 @@ fn a_survivor_in_an_xfa_packet_is_found() {
assert!(!proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_survivor_behind_parameters_given_by_reference_is_found() {
// Object 7 holds the text behind Flate and a PNG predictor whose `/DecodeParms` is object 8,
// a reference. Read without them, its rows keep their tag bytes, which split the text; the
// sweep reads them as redaction does, and reports the copy.
let text = format!("note: {SECRET} xx");
let rows: Vec<u8> = text.as_bytes().chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let packed = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref());
let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec();
let doc = marked(Spec { extra: vec![packed, parms], ..Spec::default() });
let snap = Snapshot::capture(&doc, None).unwrap();
let proof = snap.prove_saved(doc.bytes(), None, &ProofOptions::default());
let s = surface(&proof, Surface::DecodedStreams);
assert_eq!(s.verdict, Verdict::Survivor, "{}", proof.to_text());
assert!(s.locations.iter().any(|l| l == "object 7 (decoded)"), "{:?}", s.locations);
assert!(!proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_unicode_map_behind_parameters_given_by_reference_is_read_alike() {
// The font's /ToUnicode (object 7) reads codes A..H as "SECRETVA", behind Flate and a PNG
// predictor whose `/DecodeParms` is object 8, a reference. The page draws the codes twice,
// once under the mark. Redaction takes the removed text from that map, and the proof the page
// text it allows for: read by one and not the other, the copy that stays counted as a
// survivor of a sound redaction.
let cmap = b"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /Test def\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n8 beginbfchar\n<41> <0053>\n<42> <0045>\n<43> <0043>\n<44> <0052>\n<45> <0045>\n<46> <0054>\n<47> <0056>\n<48> <0041>\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
let mut data = cmap.to_vec();
data.resize(data.len().next_multiple_of(4), b' ');
let rows: Vec<u8> = data.chunks(4).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let to_unicode = stream("/Filter /FlateDecode /DecodeParms 8 0 R", Stream::flate(Dict::new(), &rows).raw.as_ref());
let parms = b"<< /Predictor 12 /Columns 4 >>".to_vec();
let content = b"BT /F1 10 Tf 10 200 Td (ABCDEFGH) Tj ET BT /F1 10 Tf 10 100 Td (ABCDEFGH) Tj ET";
let mut doc = marked_content(content, Spec { extra: vec![to_unicode, parms], ..Spec::default() });
doc.update_dict(ObjRef::new(5, 0), |font| font.set(b"ToUnicode".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap();
let (result, proof) = run(&mut doc);
assert!(result.is_ok(), "{result:?}");
assert!(proof.passed(), "{}", proof.to_text());
}
#[test]
fn a_survivor_in_an_unused_object_is_found() {
// Object 7 is reachable from nothing: no catalog entry, no page resource. A reader that
+235
View File
@@ -0,0 +1,235 @@
//! Caller-supplied regular expressions for Search & Redact (`redact_mark regex`).
//!
//! The expression is untrusted input. It runs on the `regex` crate, which matches in time
//! linear in the text (no backtracking, no look-around or back-references), and is compiled
//! under hard limits on source length, nesting and compiled size, so a hostile expression is
//! refused with an error instead of exhausting time or memory. An optional [`Validator`]
//! keeps a match only when its checksum holds.
use std::ops::Range;
use regex::RegexBuilder;
use thiserror::Error;
/// Longest expression accepted, in bytes.
pub const MAX_REGEX_LEN: usize = 1024;
/// Most matches accepted for one page; one more makes [`UserRegex::find`] fail, so a page is never
/// marked only in part.
pub const MAX_MATCHES_PER_TEXT: usize = 10_000;
/// Cap on the compiled program and on the lazy-DFA cache, in bytes.
const SIZE_LIMIT: usize = 1 << 20;
/// Cap on parenthesis nesting.
const NEST_LIMIT: u32 = 32;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RegexError {
#[error("regex is empty")]
Empty,
#[error("regex is {0} bytes long; at most {MAX_REGEX_LEN} are allowed")]
TooLong(usize),
#[error("invalid or too complex regex: {0}")]
Invalid(String),
#[error("regex matches the empty string, so it would mark nothing useful; require at least one character")]
MatchesEmpty,
#[error("regex has more than {MAX_MATCHES_PER_TEXT} matches on one page; narrow it so that every match can be marked")]
TooManyMatches,
}
/// Checksum a match must pass to be kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Validator {
/// Luhn (ISO/IEC 7812): digits only, spaces and hyphens ignored.
Luhn,
/// ISO 7064 MOD 97-10: letters count as 10..35 and the number mod 97 must be 1. Letters and
/// digits only, spaces and hyphens ignored.
Mod97,
/// IBAN check (ISO 13616): the first four characters move to the end, then MOD 97-10.
Iban,
}
/// Ids accepted for `validate`.
pub const VALIDATORS: [Validator; 3] = [Validator::Luhn, Validator::Mod97, Validator::Iban];
impl Validator {
pub fn id(self) -> &'static str {
match self {
Validator::Luhn => "luhn",
Validator::Mod97 => "mod97",
Validator::Iban => "iban",
}
}
pub fn from_id(id: &str) -> Option<Self> {
VALIDATORS.into_iter().find(|v| v.id() == id)
}
/// Whether `s` passes the checksum.
pub fn check(self, s: &str) -> bool {
match self {
Validator::Luhn => luhn(s),
Validator::Mod97 => mod97(s.chars()),
Validator::Iban => {
let kept: Vec<char> = s.chars().filter(|c| *c != ' ' && *c != '-').collect();
let (head, tail) = kept.split_at(kept.len().min(4));
mod97(tail.iter().chain(head).copied())
}
}
}
}
fn luhn(s: &str) -> bool {
let (mut sum, mut digits, mut double) = (0u32, 0usize, false);
for c in s.chars().rev() {
if c == ' ' || c == '-' {
continue;
}
let Some(d) = c.to_digit(10).filter(|_| c.is_ascii_digit()) else { return false };
let d = if double { if d * 2 > 9 { d * 2 - 9 } else { d * 2 } } else { d };
sum = sum.saturating_add(d);
digits += 1;
double = !double;
}
digits >= 2 && sum % 10 == 0
}
fn mod97(s: impl Iterator<Item = char>) -> bool {
let (mut rem, mut n) = (0u32, 0usize);
for c in s {
if c == ' ' || c == '-' {
continue;
}
if !c.is_ascii_alphanumeric() {
return false;
}
// Digits stay one decimal digit, letters become two (A = 10 .. Z = 35).
let v = c.to_digit(36).unwrap_or(0);
rem = if v >= 10 { (rem * 100 + v) % 97 } else { (rem * 10 + v) % 97 };
n += 1;
}
n >= 2 && rem == 1
}
/// Whether `re` can match the empty string, which `is_match("")` alone misses for assertions such
/// as `\b`. Tries a few short texts with letters, digits, space, newline and a non-ASCII letter.
fn can_match_empty(re: &regex::Regex) -> bool {
["", " ", "a", "1", "a a", "a\n", "\n", "é", "aa 11"].iter().any(|p| re.find_iter(p).any(|m| m.is_empty()))
}
/// A compiled caller expression with an optional checksum.
#[derive(Debug, Clone)]
pub struct UserRegex {
re: regex::Regex,
validator: Option<Validator>,
}
impl UserRegex {
pub fn new(source: &str, validator: Option<Validator>) -> Result<Self, RegexError> {
if source.is_empty() {
return Err(RegexError::Empty);
}
if source.len() > MAX_REGEX_LEN {
return Err(RegexError::TooLong(source.len()));
}
let re = RegexBuilder::new(source)
.size_limit(SIZE_LIMIT)
.dfa_size_limit(SIZE_LIMIT)
.nest_limit(NEST_LIMIT)
.build()
.map_err(|e| RegexError::Invalid(e.to_string()))?;
if can_match_empty(&re) {
return Err(RegexError::MatchesEmpty);
}
Ok(Self { re, validator })
}
/// Character ranges of `text` the expression matches (non-overlapping, left to right).
///
/// Fails with [`RegexError::TooManyMatches`] when more than [`MAX_MATCHES_PER_TEXT`] matches
/// are kept, instead of returning a partial list the caller would take for complete.
pub fn find(&self, text: &[char]) -> Result<Vec<Range<usize>>, RegexError> {
let s: String = text.iter().collect();
// Char index of every byte offset that starts a char (and of the end).
let mut byte_to_char = vec![usize::MAX; s.len().saturating_add(1)];
for (ci, (bi, _)) in s.char_indices().enumerate() {
if let Some(slot) = byte_to_char.get_mut(bi) {
*slot = ci;
}
}
if let Some(slot) = byte_to_char.get_mut(s.len()) {
*slot = text.len();
}
let mut out = Vec::new();
for m in self.re.find_iter(&s) {
if m.is_empty() || self.validator.is_some_and(|v| !v.check(m.as_str())) {
continue;
}
if let (Some(&a), Some(&b)) = (byte_to_char.get(m.start()), byte_to_char.get(m.end()))
&& a < b
&& b != usize::MAX
{
if out.len() >= MAX_MATCHES_PER_TEXT {
return Err(RegexError::TooManyMatches);
}
out.push(a..b);
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chars(s: &str) -> Vec<char> {
s.chars().collect()
}
#[test]
fn finds_matches_as_char_ranges_with_multibyte_text() {
let r = UserRegex::new(r"\d{2}( \d{2}){2}", None).unwrap();
let t = chars("Ré 85 05 78 et 91 12 34 fin");
let found: Vec<String> = r.find(&t).unwrap().into_iter().map(|g| t[g].iter().collect()).collect();
assert_eq!(found, ["85 05 78", "91 12 34"]);
}
#[test]
fn validators_filter_matches() {
let t = chars("ok 4539 1488 0343 6467 bad 4539 1488 0343 6468");
let r = UserRegex::new(r"\d{4}( \d{4}){3}", Some(Validator::Luhn)).unwrap();
assert_eq!(r.find(&t).unwrap().len(), 1);
let t = chars("GB82 WEST 1234 5698 7654 32 and GB83 WEST 1234 5698 7654 32");
let r = UserRegex::new(r"GB\d\d WEST( \d{4}){3} \d\d", Some(Validator::Iban)).unwrap();
assert_eq!(r.find(&t).unwrap().len(), 1);
assert!(Validator::Mod97.check("98") && !Validator::Mod97.check("99"));
assert!(!Validator::Luhn.check("12a4") && !Validator::Mod97.check("é1") && !Validator::Luhn.check("0"));
}
#[test]
fn hostile_expressions_are_refused_or_stay_linear() {
assert_eq!(UserRegex::new("", None).unwrap_err(), RegexError::Empty);
assert!(matches!(UserRegex::new(&"a".repeat(MAX_REGEX_LEN + 1), None), Err(RegexError::TooLong(_))));
assert!(matches!(UserRegex::new("(unclosed", None), Err(RegexError::Invalid(_))));
assert!(matches!(UserRegex::new(r"(a)\1", None), Err(RegexError::Invalid(_))), "no back-references");
for empty in ["a*", r"\b", "(?m)^", r"\B", "(?m)$", r"\d*|x"] {
assert_eq!(UserRegex::new(empty, None).unwrap_err(), RegexError::MatchesEmpty, "{empty}");
}
assert!(UserRegex::new(r"\bfoo\b", None).is_ok());
assert!(matches!(UserRegex::new(r"\p{L}{1000}{1000}", None), Err(RegexError::Invalid(_))), "compiled size is capped");
assert!(matches!(UserRegex::new(&format!("{}a{}", "(".repeat(100), ")".repeat(100)), None), Err(RegexError::Invalid(_))));
// Classic catastrophic pattern finishes at once on a long non-matching input.
let r = UserRegex::new(r"(a+)+$", None).unwrap();
let mut t = chars(&"a".repeat(50_000));
t.push('!');
assert!(r.find(&t).unwrap().is_empty());
}
#[test]
fn more_than_the_cap_of_matches_is_an_error_not_a_partial_list() {
let r = UserRegex::new("x", None).unwrap();
let at_cap = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT));
assert_eq!(r.find(&at_cap).unwrap().len(), MAX_MATCHES_PER_TEXT);
let over = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT + 1));
assert_eq!(r.find(&over).unwrap_err(), RegexError::TooManyMatches);
}
}
+3 -3
View File
@@ -58,7 +58,7 @@ impl Decoder {
let mut d = Decoder { map: HashMap::new(), width: if composite { 2 } else { 1 }, opaque: false };
let tu = font.get(b"ToUnicode").map(|t| doc.resolve(t));
if let Some(Object::Stream(s)) = tu.as_deref() {
let data = ctx.decode(s)?;
let data = ctx.decode(doc, s)?;
if data.len() > MAX_CMAP {
return Err(());
}
@@ -435,7 +435,7 @@ pub(super) fn page_content(doc: &Document, page: &Dict, ctx: &Ctx, skip_overlay:
for o in &list {
match &*resolve_strict(doc, o)? {
Object::Stream(s) => {
let d = ctx.decode(s)?;
let d = ctx.decode(doc, s)?;
if skip_overlay && ctx.is_overlay(&d) {
continue;
}
@@ -536,7 +536,7 @@ impl Extractor<'_> {
self.stack.push(r);
}
let result = (|| {
let data = self.ctx.decode(s)?;
let data = self.ctx.decode(self.doc, s)?;
let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned());
self.scope(&data, own.as_ref().unwrap_or(parent), ctm, depth, placed)
})();
+4 -4
View File
@@ -98,7 +98,7 @@ impl Geo<'_> {
// own placement: where it lands comes from each `/Rect`, so the first
// placement tells nothing about the others. Repeated decodes spend the
// same bounded budget as everything else.
let Ok(data) = self.ctx.decode(&st) else {
let Ok(data) = self.ctx.decode(self.doc, &st) else {
self.unverifiable("an annotation appearance can't be read in full");
continue;
};
@@ -278,7 +278,7 @@ impl Geo<'_> {
{
return;
}
let Ok(data) = self.ctx.decode(s) else {
let Ok(data) = self.ctx.decode(self.doc, s) else {
self.unverifiable("a form under the region can't be decoded in full");
return;
};
@@ -336,7 +336,7 @@ impl Geo<'_> {
if depth > MAX_DEPTH {
return Err(());
}
let data = self.ctx.decode(s)?;
let data = self.ctx.decode(self.doc, s)?;
let own = s.dict.get(b"Resources").and_then(|r| self.doc.resolve(r).as_dict().cloned());
let res = own.as_ref().unwrap_or(parent);
let xobjects = res_dict(self.doc, res, b"XObject");
@@ -464,7 +464,7 @@ pub(super) fn image_cleared(doc: &Document, ctx: &Ctx, s: &Stream, ctm: &Matrix,
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || ncomp == 0 || ncomp > 32 {
return Err(());
}
let data = ctx.decode(s)?;
let data = ctx.decode(doc, s)?;
let bits = ncomp.checked_mul(bpc).ok_or(())?;
let row = w.checked_mul(bits).ok_or(())?.div_ceil(8);
if data.len() < row.checked_mul(h).ok_or(())? {
+5 -5
View File
@@ -252,7 +252,7 @@ pub struct ProofOptions {
#[cfg(test)]
pub(crate) mod tests_support {
use pdfcraft_content::parse;
use pdfcraft_cos::{Dict, Object, Stream};
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::extract::{carries_glyphs, parse_cmap};
use super::limits::{MAX_STREAM, MAX_TOTAL_DECODED, MAX_WORK, MIN_STRUCTURED_NEEDLE};
@@ -308,9 +308,9 @@ pub(crate) mod tests_support {
pub fn decode_budget_runs_out() -> (bool, bool) {
let ctx = Ctx::new(Default::default());
let s = Stream::from_raw(Dict::new(), vec![0u8; 100]);
let before = ctx.decode(&s).is_ok();
let before = ctx.decode(&Document::new_empty(), &s).is_ok();
let spent = Ctx::with_decode_left(50);
(before, spent.decode(&s).is_err())
(before, spent.decode(&Document::new_empty(), &s).is_err())
}
/// Does a decode that fails past the one-stream cap pay for the attempt, so the same bomb
@@ -322,9 +322,9 @@ pub(crate) mod tests_support {
drop(big);
let small = Stream::from_raw(Dict::new(), vec![0u8; 100]);
let ctx = Ctx::with_decode_left(u64::try_from(MAX_STREAM + 50).unwrap_or(u64::MAX));
let refused = ctx.decode(&bomb).is_err();
let refused = ctx.decode(&Document::new_empty(), &bomb).is_err();
let charged = ctx.decoded.get() == MAX_TOTAL_DECODED - 50;
let gone = ctx.decode(&small).is_err();
let gone = ctx.decode(&Document::new_empty(), &small).is_err();
(refused, charged, gone)
}
+1 -1
View File
@@ -200,7 +200,7 @@ impl Snapshot {
for r in created {
let obj = doc.try_get(r.num).map_err(|_| RedactError::OverlayUnreadable)?;
let Object::Stream(s) = &*obj else { continue };
let data = ctx.decode(s).map_err(|_| RedactError::OverlayUnreadable)?;
let data = ctx.decode(doc, s).map_err(|_| RedactError::OverlayUnreadable)?;
digests.insert(sha256(&[data]));
}
self.overlay = digests;
+4 -4
View File
@@ -495,7 +495,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac
for n in acc.bytes(Surface::DecodedStreams, needles, &s.raw, MIN_STRUCTURED_NEEDLE) {
acc.survivor(Surface::DecodedStreams, format!("{loc} (raw)"), Some(n));
}
match acc.ctx.decode(s) {
match acc.ctx.decode(doc, s) {
Ok(d) => {
let loc = format!("{loc} (decoded)");
for (n, c) in acc.counts(Surface::DecodedStreams, needles, &d, MIN_STRUCTURED_NEEDLE) {
@@ -508,7 +508,7 @@ fn sweep_objects(needles: &[Needle], doc: &Document, depth: usize, tag: &str, ac
Err(()) => acc.raw_only(Surface::DecodedStreams),
}
} else {
match acc.ctx.decode(s) {
match acc.ctx.decode(doc, s) {
// The boxes and labels this run drew are not text to look for.
Ok(d) if acc.ctx.is_overlay(&d) => overlay_raw.push(s.raw.to_vec()),
Ok(d) => {
@@ -578,7 +578,7 @@ fn sweep_attachments(needles: &[Needle], doc: &Document, embedded: &BTreeSet<u32
acc.problem(Surface::EmbeddedFiles, format!("{loc} is not a stream"));
continue;
};
let payload = match acc.ctx.decode(s) {
let payload = match acc.ctx.decode(doc, s) {
Ok(p) => p,
Err(()) => {
acc.problem(Surface::EmbeddedFiles, format!("{loc} can't be decoded in full"));
@@ -742,7 +742,7 @@ pub(super) fn sweep_xmp(needles: &[Needle], doc: &Document, tag: &str, acc: &mut
acc.problem(Surface::Xmp, format!("{loc} is not a stream"));
return;
};
let Ok(data) = acc.ctx.decode(s) else {
let Ok(data) = acc.ctx.decode(doc, s) else {
acc.problem(Surface::Xmp, format!("{loc} can't be decoded in full"));
return;
};
+3 -2
View File
@@ -33,11 +33,12 @@ impl Ctx {
/// attempt is charged to the cumulative allowance, failures included: a decode that fails
/// past the one-stream cap pays the cap, and one that fails mid-way pays what it decoded
/// before the error — the work of finding out is not work a repeated reference gets for free.
pub(super) fn decode(&self, s: &Stream) -> Result<Vec<u8>, ()> {
/// `/DecodeParms` given by reference are read in `doc`, as redaction itself reads them.
pub(super) fn decode(&self, doc: &Document, s: &Stream) -> Result<Vec<u8>, ()> {
let left = MAX_TOTAL_DECODED.saturating_sub(self.decoded.get());
let cap = usize::try_from(left).unwrap_or(usize::MAX).min(MAX_STREAM);
let mut budget = DecodeBudget::new(cap, usize::MAX);
let result = budget.decode_strict(s);
let result = budget.decode_strict(doc, s);
// The attempt's own accounting covers a success (what it decoded), a stream cut short
// by corruption (what it decoded first) and one refused past the cap (the cap): charge
// it through, whichever way the attempt went.
+1 -1
View File
@@ -30,7 +30,7 @@ pub use graphic::Graphic;
pub use keys::{DigestAlg, PrivateKey, PublicKey};
pub use pdf::{
Appearance, AppearanceLabels, Detail, DigestCache, Fill, Modification, SignOptions, SignatureInfo, Status, TrustStore, WORDINGS, Words,
in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate,
has_signatures, in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate,
};
pub use pkcs12::DigitalId;
pub use status_layers::{StatusCaptions, has_status_layers, show_status_layers, status_rgb};
+1 -1
View File
@@ -38,7 +38,7 @@ use listing::{DISCOVERY_CACHE_LIMIT, Field, MAX_ENTRIES, MAX_FIELDS, MAX_NAME_BY
pub use listing::{DigestCache, list, list_cached};
pub(crate) use listing::{walk_sig_fields, widget_shown};
pub(crate) use signing::fmt;
pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, sign, sign_with_timestamp, timestamp_document};
pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, has_signatures, sign, sign_with_timestamp, timestamp_document};
pub use types::{Detail, Fill, Modification, Status, TrustStore, WORDINGS, Words, in_english, kind};
pub(crate) use validate::signature_contents;
pub use validate::validate;
+50 -1
View File
@@ -119,14 +119,44 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S
/// Placeholder `/ByteRange` values: fixed width, patched after writing.
const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
/// Whether `doc` already holds a signature or a document timestamp: a signature dictionary whose
/// `/Contents` holds a nonzero byte (a field whose `/V` has an empty or zero-filled `/Contents`
/// is not signed yet). A signature field without a name (`/T`) counts too: indirect signature
/// dictionaries and document timestamps are found in one pass over the file (by `/ByteRange`,
/// or as timestamps), not through the field tree.
pub fn has_signatures(doc: &Document) -> bool {
let signed = |d: &Dict| {
let contents = d.get(b"Contents").map(|c| doc.resolve(c));
// Hex strings decode to the DER when parsed; a literal string carries hex text.
let der = contents
.as_deref()
.and_then(Object::as_string)
.and_then(|s| if s.hex { Some(s.bytes.clone()) } else { super::validate::unhex(&s.bytes) });
der.is_some_and(|der| der.iter().any(|b| *b != 0))
};
sig_fields(doc).iter().any(|f| f.dict.get(b"V").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).is_some_and(signed))
|| doc
.scan_objects()
.any(|(_, o)| o.as_dict().is_some_and(|d| (d.contains(b"ByteRange") || super::validate::is_doc_timestamp(d)) && signed(d)))
}
/// Sign `doc` with `id`; returns the signed file (an incremental update when possible).
///
/// The document's first signature (no [`has_signatures`]) removes the AcroForm's
/// `/NeedAppearances` flag: Acrobat does not recognise a signature while it is set. Later
/// signatures leave the flag as it is. This function draws no field appearances: once the flag is
/// gone, viewers show each field's stored normal appearance (`/AP /N`), so a filled field whose
/// stored appearance draws no text (an empty placeholder such as `/Tx BMC EMC`, or a look left
/// from an earlier value) shows that instead of its value. A caller that wants the values kept
/// draws the fields first, as `Session::sign` in the engine does.
pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8>, SignError> {
sign_inner(doc, id, opts, None)
}
/// Sign and embed an RFC 3161 signature timestamp from `tsa` (PAdES B-T). The transport is the
/// caller's: this crate never opens a socket, and a rejected or malformed token fails the
/// signing before any file is produced.
/// signing before any file is produced. The form's `/NeedAppearances` flag is handled as in
/// [`sign`].
pub fn sign_with_timestamp(
doc: &Document,
id: &DigitalId,
@@ -153,6 +183,7 @@ fn sign_inner(
}
check_signing_permissions(doc, opts)?;
let mut doc = doc.clone();
let earlier_signatures = has_signatures(&doc);
let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?;
let alg = id.key.preferred_digest();
let reserve =
@@ -305,6 +336,24 @@ fn sign_inner(
doc.update_dict(r, |af| af.set(b"SigFlags".to_vec(), Object::Int(3)))?;
}
}
// Acrobat does not recognise a signature in a form that asks viewers to redraw its fields
// (`/NeedAppearances true`): redrawing would change what the signature covers. The first
// signature drops the flag; a later one leaves it, as removing it would count as a change
// to the earlier signatures. Without the flag viewers draw each field's stored `/AP /N`, so
// a filled field whose stored appearance draws no text (an empty placeholder, or a stale
// look) shows that instead of its value unless the caller drew the fields first (see `sign`).
if !earlier_signatures {
if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) {
doc.update_dict(r, |af| {
af.remove(b"NeedAppearances");
})?;
}
doc.update_dict(root, |c| {
if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.remove(b"NeedAppearances");
}
})?;
}
// The appearance.
let ap = appearance(&mut doc, rect, turn, &name, &id.certificate, opts)?;
let ap_ref = doc.add(Object::Stream(ap));
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -
list(doc, bytes, trust).into_iter().find(|s| s.field == field)
}
fn unhex(s: &[u8]) -> Option<Vec<u8>> {
pub(super) fn unhex(s: &[u8]) -> Option<Vec<u8>> {
let digits: Vec<u8> = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect();
let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect()
+116
View File
@@ -122,6 +122,122 @@ fn signing_then_validating_with_and_without_trust() {
}
}
/// The form dictionary of `bytes`, with the catalog's reference resolved.
fn form_of(bytes: &[u8]) -> Dict {
let doc = open(bytes);
let form = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).unwrap();
form.as_dict().unwrap().clone()
}
/// A form with `/NeedAppearances true` makes Acrobat ignore the signature (#5939): the first
/// signature drops the flag, whether the AcroForm is inline or indirect, and keeps /Fields and
/// sets /SigFlags.
#[test]
fn signing_drops_need_appearances() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let inline = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[]);
let indirect =
fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm 7 0 R >>", &[b"<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >>"]);
for (what, bytes) in [("inline", inline), ("indirect", indirect)] {
assert_eq!(form_of(&bytes).get(b"NeedAppearances"), Some(&Object::Bool(true)), "{what}");
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let form = form_of(&signed);
assert!(form.get(b"NeedAppearances").is_none(), "{what}");
assert_eq!(form.get(b"SigFlags"), Some(&Object::Int(3)), "{what}");
assert!(form.get(b"Fields").is_some(), "{what}");
let doc = open(&signed);
assert!(signatures(&doc, &signed, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed), "{what}");
}
}
/// A field whose /V has an empty /Contents is no earlier signature: the first real one still
/// drops `/NeedAppearances`.
#[test]
fn an_empty_signature_value_does_not_count_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let bytes = fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>",
&[
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Empty) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /Contents <> >>",
],
);
assert!(!pdfcraft_sign::has_signatures(&open(&bytes)));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
assert!(form_of(&signed).get(b"NeedAppearances").is_none());
}
/// A signature dictionary whose /Contents is still the zero-filled placeholder (hex or literal
/// hex text, with a /ByteRange) is not signed either: the first real signature drops the flag.
#[test]
fn a_zero_filled_signature_placeholder_does_not_count_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
for contents in [&b"<0000000000000000>"[..], b"(00000000)"] {
let sig = [&b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents "[..], contents, b" >>"]
.concat();
let bytes = fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>",
&[b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Pending) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", &sig],
);
assert!(!pdfcraft_sign::has_signatures(&open(&bytes)), "{}", String::from_utf8_lossy(contents));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
assert!(form_of(&signed).get(b"NeedAppearances").is_none());
assert!(pdfcraft_sign::has_signatures(&open(&signed)));
}
}
/// A document timestamp outside the field tree counts by its /Contents alone, even without a
/// /ByteRange; a zero-filled one does not.
#[test]
fn a_document_timestamp_counts_unless_its_contents_are_zero_filled() {
for (contents, stamped) in [(&b"<3080>"[..], true), (b"<0000>", false)] {
let ts = [&b"<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 /Contents "[..], contents, b" >>"].concat();
let bytes = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[&ts]);
assert_eq!(pdfcraft_sign::has_signatures(&open(&bytes)), stamped, "{}", String::from_utf8_lossy(contents));
}
}
/// A signed field without a name (`/T` is optional) is an earlier signature too: signing another
/// field leaves `/NeedAppearances` as it is.
#[test]
fn an_unnamed_signed_field_counts_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let bytes = fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>",
&[
b"<< /Type /Annot /Subtype /Widget /FT /Sig /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents <3080> >>",
],
);
assert!(pdfcraft_sign::has_signatures(&open(&bytes)));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
assert_eq!(form_of(&signed).get(b"NeedAppearances"), Some(&Object::Bool(true)));
}
/// A second signer leaves the form's flags alone: changing them would count as a change to
/// what the first signature covered.
#[test]
fn a_later_signature_does_not_change_need_appearances() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let first = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let mut doc = open(&first);
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.set(b"NeedAppearances".to_vec(), Object::Bool(true));
}
})
.unwrap();
if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) {
doc.update_dict(r, |af| af.set(b"NeedAppearances".to_vec(), Object::Bool(true))).unwrap();
}
let flagged = write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let second = pdfcraft_sign::sign(&open(&flagged), &id, &opts()).unwrap();
assert_eq!(form_of(&second).get(b"NeedAppearances"), Some(&Object::Bool(true)));
let doc = open(&second);
assert!(signatures(&doc, &second, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed));
}
#[test]
fn signing_an_existing_field_and_counter_signing() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
+21
View File
@@ -251,8 +251,29 @@ pub(super) fn notices(
fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, text: String, buttons: impl FnOnce(&mut egui::Ui)) {
const ROW: f32 = 28.0;
const ICON: f32 = 16.0;
/// Content width below which the buttons stack under the message.
const STACK_BELOW: f32 = 200.0;
/// Content width under which stacking is not tried: the message would wrap to a column of a
/// few characters, thousands of points tall, and push the hotbar off the screen. Such a width
/// is below the smallest supported window, so the single row stays as it was.
const STACK_ABOVE: f32 = 100.0;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
let width = ui.available_width();
if (STACK_ABOVE..STACK_BELOW).contains(&width) {
// A narrow document area (a half-width window): the message takes the full width and
// the buttons sit on a row of their own below it, rather than squeezing it to nothing.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
let (slot, _) = ui.allocate_exact_size(vec2(ICON, ICON), Sense::hover());
icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color);
ui.add(egui::Label::new(egui::RichText::new(text).color(t.text)).wrap());
});
// The buttons wrap onto further rows when even the full width is too narrow for them all.
ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center).with_main_wrap(true), |ui| {
ui.set_min_height(ROW);
buttons(ui);
});
return;
}
ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.set_min_height(ROW);
buttons(ui);
+42 -4
View File
@@ -477,6 +477,12 @@ fn tab(ui: &mut egui::Ui, spec: TabSpec<'_>, close: &mut Option<usize>) -> egui:
theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown })
}
/// Width the print, save and info buttons and their gaps take in the mode bar: three buttons, the
/// 4 pt `item_spacing` after each of them (three gaps, not two), and the 8 pt spacer.
const MODE_BAR_RIGHT: f32 = 3.0 * 32.0 + 3.0 * 4.0 + 8.0;
/// Narrowest search field the mode bar shows; below it the field becomes an icon button.
const MODE_BAR_SEARCH_MIN: f32 = 140.0;
pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("mode_bar")
@@ -494,11 +500,36 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
let modes = [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")];
// What the right side needs (three 32 px buttons, gaps) and the least search box worth showing.
let avail = ui.available_width();
let tabs_w: f32 = modes.iter().map(|(_, l)| widgets::mode_tab_width(ui, tl!(*l))).sum::<f32>() + 2.0 * (modes.len() as f32 - 1.0);
let inline_tabs = avail >= tabs_w + MODE_BAR_RIGHT + MODE_BAR_SEARCH_MIN;
if inline_tabs {
for (mode, label) in modes {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
}
}
} else {
// Narrow window: the modes fold into one menu named after the current one.
// Read is not one of the four choices but can be active (View command): name it too.
let current = if app.mode == Mode::Read {
tl!("Read")
} else {
tl!(modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l))
};
let resp = widgets::ghost_button(ui, "chevron-down", current);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(160.0);
for (mode, label) in modes {
if ui.add(egui::Button::selectable(app.mode == mode, tl!(label))).clicked() {
app.switch_mode(mode, true);
}
}
});
}
let left = ui.available_width();
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let has_doc = app.active.is_some();
@@ -523,7 +554,14 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
});
ui.add_space(8.0);
if widgets::search_box(ui, tl!("Find tools and commands"), 260.0).clicked() {
let search_w = (left - MODE_BAR_RIGHT).clamp(0.0, 260.0);
let opened = if search_w >= MODE_BAR_SEARCH_MIN {
widgets::search_box(ui, tl!("Find tools and commands"), search_w).clicked()
} else {
// Too narrow for the field: a button opens the same command search.
icons::button(ui, "search", 32.0, false, tl!("Find tools and commands")).clicked()
};
if opened {
app.palette_open = true;
}
});
+6
View File
@@ -172,6 +172,12 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
if icons::button(ui, "hand", cell, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() {
app.quick_tool = QuickTool::Hand;
}
let may_crop = command("page.crop").is_some_and(|spec| app.command_enabled(spec));
ui.add_enabled_ui(may_crop, |ui| {
if icons::button(ui, "crop", cell, app.quick_tool == QuickTool::Crop, tl!("Crop pages")).clicked() {
app.execute("page.crop");
}
});
// Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
// group selects its last-used tool; clicking it again opens the flyout.
for g in 0..comments::GROUPS.len() {
+6
View File
@@ -67,6 +67,12 @@ mod widgets;
use pdfcraft_engine::{DocId, Session};
pub use canvas::{DocView, RasterMemory};
/// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale
/// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a
/// 1440-wide screen at 150 % scaling, the tightest common half-screen split on Windows; the mode
/// bar, tab strip and notice bars adapt below the old 820 minimum (#1913).
pub const MIN_WINDOW_SIZE: [f32; 2] = [480.0, 520.0];
pub use editing::{CloseRequest, SaveTarget};
pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan};
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore, default_dictionary_dir};
+82 -5
View File
@@ -99,6 +99,24 @@ pub(crate) fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool)
}
}
/// The inline rename field (#5529). On the frame it takes focus it selects the whole title, so
/// typing replaces the old text at once; later frames leave the user's own caret and selection
/// alone. Same pattern as the zoom box in `chrome.rs`.
fn rename_field(ui: &mut egui::Ui, text: &mut String, id: egui::Id) -> egui::Response {
let first_frame = !ui.memory(|m| m.has_focus(id));
let resp = ui.add(crate::widgets::line(text).desired_width(ui.available_width() - 8.0).id(id));
if !resp.has_focus() && !resp.lost_focus() {
if first_frame {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
resp.request_focus();
}
resp
}
/// How a bookmark's title is drawn: in its own style and colour (ISO 32000-2 §12.3.3, Tables 151
/// and 152), bold in the heavier face and italic slanted, its colour kept readable on the panel.
/// Top-level bookmarks without a style keep the medium face.
@@ -149,11 +167,7 @@ pub(super) fn outline_item(
ui.horizontal(|ui| {
ui.add_space(x_text);
crate::widgets::begin_dialog(ui);
let edit = crate::widgets::line(text).desired_width(ui.available_width() - 8.0).id(id.with("rename"));
let resp = ui.add(edit);
if !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
let resp = rename_field(ui, text, id.with("rename"));
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
} else if resp.lost_focus() {
@@ -376,3 +390,66 @@ mod bookmark_filter {
assert!(outline_matches(&outline, &fold("izmir")).is_empty());
}
}
#[cfg(test)]
mod rename_field {
use super::rename_field as field;
fn cursor_range(ctx: &egui::Context, id: egui::Id) -> Option<(usize, usize)> {
egui::TextEdit::load_state(ctx, id)
.and_then(|s| s.cursor.char_range())
.map(|r| (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0)))
}
/// #5529: the rename field opens with the whole title selected, so typing replaces it.
#[test]
fn first_focus_selects_the_whole_title() {
let ctx = egui::Context::default();
let id = egui::Id::unique("bm-rename-test");
let mut text = String::from("Untitled");
for _ in 0..2 {
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
field(ui, &mut text, id);
});
output.drop_without_applying_deltas();
}
assert_eq!(cursor_range(&ctx, id), Some((0, 8)));
assert!(ctx.memory(|m| m.has_focus(id)), "the field takes focus");
}
/// A Unicode title is counted in characters, not bytes.
#[test]
fn a_multibyte_title_is_selected_in_characters() {
let ctx = egui::Context::default();
let id = egui::Id::unique("bm-rename-unicode");
let mut text = String::from("図面(一階平面図)");
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
field(ui, &mut text, id);
});
output.drop_without_applying_deltas();
assert_eq!(cursor_range(&ctx, id), Some((0, 9)));
}
/// A later frame must not reset the selection the user made; a caret stays a caret.
#[test]
fn a_later_frame_keeps_the_users_selection() {
let ctx = egui::Context::default();
let id = egui::Id::unique("bm-rename-keep");
let mut text = String::from("Chapter");
for _ in 0..2 {
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
field(ui, &mut text, id);
});
output.drop_without_applying_deltas();
}
// The user moves the caret to 3 with no selection.
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(3))));
state.store(&ctx, id);
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
field(ui, &mut text, id);
});
output.drop_without_applying_deltas();
assert_eq!(cursor_range(&ctx, id), Some((3, 3)), "the user's caret is kept");
}
}
@@ -1101,6 +1101,12 @@ pub(crate) fn image_context_menu(
}
});
// Grouped Form artwork: draw its content in place, so its text and pictures edit one by
// one (#2964, #3005).
if img.is_form && item(ui, true, tl!("Ungroup")) {
view.image_selection = None;
view.pending_edit = edit(E::Ungroup);
}
if item(ui, raster, tl!("Replace image")) {
*action = Some(ImageAction::Replace(page, hit));
}
+7
View File
@@ -113,6 +113,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) };
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
// Keep in step with `mode_tab_width`.
let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label));
let press = theme::Press::track(ui, &resp);
@@ -133,6 +134,12 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
theme::hand(resp)
}
/// Width [`mode_tab`] takes for `label` (the semibold, active, form is the widest).
pub fn mode_tab_width(ui: &egui::Ui, label: &str) -> f32 {
let t = Tokens::get(ui.ctx());
ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), theme::semibold(13.5), t.text).size().x) + 22.0
}
/// Rounded pill button; `primary` fills with the accent.
pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
pill_button_marked(ui, label, primary, false)
+34
View File
@@ -119,6 +119,40 @@ fn the_panel_keeps_its_width_while_the_pointer_moves() {
assert_eq!(panel_width(&h), before, "the Bookmarks panel drifted wider");
}
/// #5529: the title is selected when the rename editor opens, so typing replaces it at once,
/// with no Ctrl+A. New bookmarks and double-click rename both get this.
#[test]
fn typing_replaces_the_title_when_the_editor_opens() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
h.run_steps(3);
field(&h, "Untitled").type_text("Intro"); // the whole default is replaced
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(outline(&h), [("Intro".into(), Some(0), 0)]);
menu(&mut h, "Intro", "Rename");
field(&h, "Intro").type_text("Introduction");
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(outline(&h)[0].0, "Introduction");
}
/// The selection is one-shot: after the user collapses it to a caret, later frames must not
/// reselect, so typing at that caret inserts.
#[test]
fn a_rename_editor_keeps_the_users_caret_across_frames() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
h.run_steps(3);
h.key_press(Key::ArrowLeft); // collapses the opening selection to the caret at 0
h.run_steps(2);
field(&h, "Untitled").type_text("I"); // inserts at the caret, does not replace
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(outline(&h)[0].0, "IUntitled");
}
#[test]
fn escape_cancels_a_rename() {
let mut h = harness();
+2 -1
View File
@@ -147,7 +147,8 @@ fn after_enhance_page_crop_and_organize_take_over() {
h.get_by_label("Enhance page").click();
h.run_steps(4);
assert_eq!(h.state().views[0].camera_done.map(|d| d.page), Some(0));
h.get_by_label(button).click();
// The quick toolbar also has a "Crop pages" button; the camera panel's pill is the first match.
h.get_all_by_label(button).next().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().quick_tool, then, "{button}");
assert_eq!(h.state().views[0].camera_done, None, "{button}");
+66 -1
View File
@@ -5824,7 +5824,9 @@ fn a_quick_flick_on_either_edge_rewraps_the_paragraph() {
assert_eq!(text, ["Page", "1"], "narrowed from the right edge");
// Flick the right edge outward, past the box and its handle: back onto one line.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
// 1 px outside the box: the grab zone's outer limit sits near 2 px, where a pixel of page
// scale (the canvas width depends on the rail) decides whether the press lands on the handle.
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(1.0, 0.0);
flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once");
@@ -10456,6 +10458,69 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
}
}
/// #3005: a table drawn inside one Form XObject is a single piece of artwork in Edit text &
/// images. Its right-click menu offers Ungroup, which draws the form in place so the table's
/// text can be edited; one Undo puts the form back.
#[test]
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
stream("<<", b"q /Fm1 Do Q"),
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("table.pdf", None, pdf.clone()).expect("opens");
app
});
h.run_steps(4);
let id = h.state().views[0].id;
let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0).len();
assert_eq!(blocks(&h), 0, "the table's text is inside the form");
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
// An empty spot of the form: the artwork, not a paragraph.
let at = r.left_top() + egui::vec2(r.width() * 0.8, r.height() * 0.85);
right_click_at(&mut h, at);
h.run_steps(1);
let selection = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
assert_eq!(selection, Some((0, 0)), "the right-click selects the form");
assert!(h.query_by_label("Ungroup").is_some(), "the artwork menu offers Ungroup");
// The page's own menu opens over the artwork menu (#2637, fixed by #2155), so the item is
// applied the way it queues its edit rather than clicked through the overlap.
h.state_mut().views[0].pending_edit =
Some(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Ungroup });
h.run_steps(4);
assert!(blocks(&h) >= 1, "the table's rows are page text now");
let images = h.state().session.get(id).unwrap().page_images(0);
assert!(images.is_empty(), "no artwork left: {images:?}");
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(3);
assert_eq!(blocks(&h), 0, "undo puts the form back");
}
#[test]
fn outward_image_grip_drags_grow_on_rotated_pages() {
for rotation in [0, 90, 180, 270] {
+1
View File
@@ -51,6 +51,7 @@ mod javascript;
mod layer_actions;
mod links;
mod marks;
mod min_window;
mod object_selection;
mod ocr;
mod open_action;
+102
View File
@@ -0,0 +1,102 @@
//! The smallest window (#1913): at `MIN_WINDOW_SIZE` the mode bar folds the modes into a menu
//! and keeps the command search reachable, instead of hiding them under the search field.
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{MIN_WINDOW_SIZE, PdfCraftApp};
fn harness(size: egui::Vec2) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(size).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(8);
h
}
#[test]
fn at_the_minimum_width_the_modes_fold_into_a_menu_and_search_stays() {
let mut h = harness(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1]));
assert!(h.query_by_role_and_label(Role::Button, "Convert").is_none(), "no room for the tabs");
assert!(h.query_by_role_and_label(Role::Button, "Find tools and commands").is_some(), "command search is reachable");
h.get_by_role_and_label(Role::Button, "All tools").click();
h.run_steps(4);
h.get_by_role_and_label(Role::Button, "E-Sign").click();
h.run_steps(4);
assert!(h.query_by_role_and_label(Role::Button, "E-Sign").is_some(), "the menu button is named after the mode");
assert!(h.query_by_role_and_label(Role::MenuItem, "File").is_some(), "main menu reachable");
}
#[test]
fn a_wide_window_keeps_the_mode_tabs() {
let h = harness(egui::vec2(1400.0, 900.0));
for mode in ["All tools", "Edit", "Convert", "E-Sign"] {
assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab");
}
}
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.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", objs.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", objs.len() + 1).as_bytes());
out
}
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions)
}
fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
/// A narrow window: the stacked notice bar keeps both buttons inside the window and apart.
#[test]
fn the_stacked_notice_keeps_its_buttons_in_view() {
let mut h = Harness::builder().with_size(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1])).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap();
app
});
h.run_steps(8);
let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1]));
let msg = h.get_by_label_contains("This document is secured").rect();
let (unlock, settings) = (h.get_by_label("Unlock…").rect(), h.get_by_label("Security settings").rect());
for (name, r) in [("unlock", unlock), ("settings", settings)] {
assert!(window.contains_rect(r), "{name} stays inside the window: {r:?}");
assert!(r.left() >= msg.left() - 30.0, "{name} is not cut off at the bar's left edge: {r:?} vs {msg:?}");
assert!(r.top() >= msg.bottom() - 0.5 || r.bottom() <= msg.top() + 0.5, "{name} does not cover the message");
}
assert!(!unlock.intersects(settings), "the buttons do not overlap: {unlock:?} {settings:?}");
}
+61 -3
View File
@@ -1,7 +1,7 @@
//! Crop tool, Set Page Boxes and Duplicate pages in the real shell (egui_kittest).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
const FIXTURE: &[u8] = b"%PDF-1.7
@@ -37,6 +37,64 @@ fn the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select() {
assert!(h.state_mut().execute("page.crop"));
assert_eq!(h.state().quick_tool, QuickTool::Crop);
h.run_steps(2);
crop_middle_half(&mut h);
}
#[test]
fn quick_toolbar_crop_crops_the_page_and_undo_restores_it() {
let mut h = harness();
h.get_by_label("Crop pages").click();
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Crop);
assert!(!h.state().views[0].organize);
crop_middle_half(&mut h);
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(3);
assert_eq!(size(&h, 0), (300.0, 400.0));
}
#[test]
fn quick_toolbar_crop_is_disabled_when_page_assembly_is_forbidden() {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(FIXTURE.to_vec())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: 0b0100,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("restricted.pdf", None, bytes).unwrap();
app.set_option("left", "closed").unwrap();
app
});
h.run_steps(6);
assert!(h.get_by_label("Crop pages").accesskit_node().is_disabled());
assert!(!h.state_mut().execute("page.crop"));
assert_eq!(h.state().quick_tool, QuickTool::Select);
assert_eq!(size(&h, 0), (300.0, 400.0));
}
#[test]
fn quick_toolbar_crop_is_hidden_on_home_and_in_read_mode() {
let mut h = harness();
h.state_mut().execute("view.read_mode");
h.run_steps(3);
assert!(h.query_by_label("Crop pages").is_none());
h.state_mut().execute("view.read_mode");
h.run_steps(3);
h.get_by_label("Crop pages");
h.state_mut().active = None;
h.run_steps(3);
assert!(h.query_by_label("Crop pages").is_none());
}
fn crop_middle_half(h: &mut Harness<'static, PdfCraftApp>) {
let r = h.state().views[0].page_screen_rect(0).unwrap();
// The middle half of the page in both directions.
let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75);
@@ -50,9 +108,9 @@ fn the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select() {
}
h.drop_at(b);
h.run_steps(4);
let (w, hh) = size(&h, 0);
let (w, hh) = size(h, 0);
assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}");
assert_eq!(size(&h, 1), (300.0, 400.0));
assert_eq!(size(h, 1), (300.0, 400.0));
assert_eq!(h.state().quick_tool, QuickTool::Select);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Crop page"));
}
+4 -3
View File
@@ -18,9 +18,10 @@ content, resources, clipping box, and transparency settings remain intact. Movin
deleting one use of a shared figure does not alter its other placements or pages.
**Replace Image** and **Save Image As** apply only to raster Image XObjects; these menu
items are disabled for grouped Form artwork. The tool does not ungroup Forms, edit their
individual internal elements, select arbitrary ungrouped vector paths, or edit inline
images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
items are disabled for grouped Form artwork. **Ungroup** (right-click) draws one placement's
content in place so its text and pictures can be edited; while grouped, the tool does not edit
a Form's individual internal elements. It does not select arbitrary ungrouped vector paths or
edit inline images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
For automation, `page_images` lists both kinds with `kind: "image"` or `kind: "form"`.
Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use the returned
+5 -1
View File
@@ -6,6 +6,8 @@
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged.
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
@@ -216,7 +218,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. Crop pages is directly available in the quick toolbar (#5330). |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -296,6 +298,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
## Revision history
**2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged.
**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged.
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
+20 -8
View File
@@ -1895,8 +1895,8 @@ milestone = "M7"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md"
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads"]
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share (ISO 32000-2 §8.6.5.6), each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
[[feature]]
id = "edit.text-reflow"
@@ -3226,8 +3226,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.crop"]
tools = ["page_set_box"]
evidence = ["crates/ui-egui/tests/it/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"]
notes = "drag to crop; double-click opens Set Page Boxes"
evidence = ["crates/ui-egui/tests/it/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_crops_the_page_and_undo_restores_it", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_is_disabled_when_page_assembly_is_forbidden", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_is_hidden_on_home_and_in_read_mode"]
notes = "drag to crop; double-click opens Set Page Boxes; Crop pages is also directly available in the document quick toolbar (#5330), using the existing command availability and assembly permission check. The quick toolbar is hidden on Home and in Read/full-screen modes; arbitrary toolbar customization remains outside this feature."
[[feature]]
id = "organize.remove-white-margins"
@@ -3322,8 +3322,8 @@ milestone = "M4"
status = "shipped"
commands = []
tools = ["bookmark_rename"]
evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/it/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"]
notes = ""
evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/it/bookmarks.rs::escape_cancels_a_rename", "crates/ui-egui/tests/it/bookmarks.rs::typing_replaces_the_title_when_the_editor_opens", "crates/ui-egui/tests/it/bookmarks.rs::a_rename_editor_keeps_the_users_caret_across_frames", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"]
notes = "The inline editor opens with the whole title selected, so typing replaces it at once (#5529); a caret the user moves afterwards is kept by later frames."
[[feature]]
id = "organize.bookmark-delete"
@@ -5826,6 +5826,18 @@ tools = ["redact_mark"]
evidence = ["crates/redact/src/patterns.rs::labelled_italian_international_slash_numbers_match_whole", "crates/automation/tests/it/automation.rs::redacting_italian_international_slash_numbers_through_tools", "crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/it/automation.rs::redacting_through_tools"]
notes = "phone (North American, E.164 with + country code, national with trunk 0, a slash after a trunk-0 area code; labelled Italian +39 geographic numbers with one slash after the area code match with their complete country code (#3205 follow-up), with or without one space after +39; international slash numbers without a phone label, or with country codes other than +39, remain unsupported; numbers without separators only after a phone label such as Tel. or cell.), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded"
[[feature]]
id = "protect.redact-regex"
title = "Redact by caller-supplied regular expression"
area = "G"
tier = "P2"
milestone = "M8"
status = "shipped"
commands = []
tools = ["redact_mark"]
evidence = ["crates/redact/src/user_regex.rs::hostile_expressions_are_refused_or_stay_linear", "crates/redact/src/user_regex.rs::validators_filter_matches", "crates/automation/tests/it/automation.rs::redacting_with_a_caller_regex_through_tools"]
notes = "Headless only (redact_mark regex, optional validate luhn, mod97 or iban; issue #1886): no UI entry yet. Rust regex syntax, matched in linear time with no look-around or back-references; expression capped at 1024 bytes, compiled size and nesting capped, empty-matching expressions refused. Matches run on the page text as the pattern matchers see it (words and lines joined by one space), so a match cannot span a page break. Missing: a UI field, a mask for checksums that need more than the whole match (for example the French social security key), and a check against Acrobat's own custom-pattern syntax (not observed)."
[[feature]]
id = "protect.redact-pattern-locales"
title = "Pattern sets per locale"
@@ -7487,8 +7499,8 @@ milestone = "M11"
status = "partial"
commands = ["optimize.advanced"]
tools = ["doc_optimize"]
evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/src/workflows/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"]
notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit"
evidence = ["crates/automation/tests/it/optimize_content_boundaries.rs::fragment_merge_preserves_a_comment_across_content_pieces", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_to_a_target_size", "crates/ui-egui/src/workflows/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/it/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/it/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"]
notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit; headless-only optional target_bytes retries up to 5 stronger image steps (an automation extension, not an Acrobat feature)"
[[feature]]
id = "optimize.downsample-images"