Merge remote-tracking branch 'upstream/main' into lane1-6501

This commit is contained in:
mtlcl410 committed 2026-11-08 20:31:22 +08:00
commit ca146cd23f
27 files changed
+969 -46

No files matched your search

Generated
+24
View File
@@ -4641,6 +4641,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -5521,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"
+3
View File
@@ -84,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"
+1
View File
@@ -231,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.
+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);
+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)))
+5 -2
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" },
@@ -1633,7 +1635,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!({
@@ -1645,6 +1647,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" },
+85
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");
+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};
+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}");
}
}
+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);
}
}
+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;
+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
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};
+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);
}
}
+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
@@ -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};
+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)
+3 -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");
+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:?}");
}
+14 -2
View File
@@ -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"