Merge r2 track t11: redaction, mixed: combine, compare, crate:edit, measure, ocr

# Conflicts:
#	ATTRIBUTION.toml
#	crates/automation/src/tools.rs
#	crates/edit/src/lib.rs
#	crates/ui-egui/tests/compare.rs
#	crates/ui-egui/tests/ui_group/marks.rs
This commit is contained in:
aiongg
2026-10-22 02:21:38 +00:00
55 changed files with 1434 additions and 136 deletions
+1
View File
@@ -245,6 +245,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
+1 -1
View File
@@ -10,7 +10,7 @@ It reports two kinds of finding:
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
extractable underneath;
extractable underneath; opaque filled Square annotations count as covers too;
- **unapplied mark**: a Redact annotation that was marked but never applied.
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
+57 -13
View File
@@ -12,8 +12,9 @@
//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l`
//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are
//! considered when closed by a fill operator — other filled paths, shadings and patterns are
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation
//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign;
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque
//! filled `/Square` annotations count as covers, but annotation rectangles are compared in
//! unrotated user space, so pages with `/Rotate` may misalign;
//! covers inside form XObjects are found, but only text drawn directly in the page content
//! is checked under them, so text inside a form XObject is not audited.
//!
@@ -114,11 +115,15 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
}
}
// 2. Redact annotations that were never applied.
// 2. Annotation covers over extractable text.
for annot in annots(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if covers(&annot, line) {
let hidden = match &annot {
AnnotationCover::Redact(rect) => covers(rect, line),
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
};
if hidden {
if !covered.is_empty() {
covered.push(' ');
}
@@ -126,7 +131,11 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
}
}
if !covered.trim().is_empty() {
out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered });
let (kind, rect) = match annot {
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
};
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
}
}
}
@@ -191,9 +200,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
(hi + 0.05) / (lo + 0.05)
}
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
/// means no marks; a malformed one fails the audit rather than silently passing.
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
enum AnnotationCover {
Redact([f64; 4]),
Square { rect: [f64; 4], rgb: [f64; 3] },
}
/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no
/// covers; a malformed one fails the audit rather than silently passing.
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
@@ -207,16 +221,46 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
pdfcraft_cos::Object::Dict(d) => d.clone(),
_ => continue,
};
if dict.name(b"Subtype") != Some(b"Redact") {
continue;
}
let v: Vec<f64> = match dict.get(b"Rect") {
None => continue,
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
};
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
if v.len() != 4 || !v.iter().all(|x| x.is_finite()) {
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
match dict.name(b"Subtype") {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & 2 != 0 || flags & 32 != 0 {
continue;
}
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
if alpha >= 1.0
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
{
out.push(AnnotationCover::Square { rect, rgb });
}
}
_ => {}
}
}
Ok(out)
}
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
if !values.iter().all(|v| v.is_finite()) {
return None;
}
match values.as_slice() {
[g] => Some([g.clamp(0.0, 1.0); 3]),
[r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
[c, m, y, k] => {
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)])
}
_ => None,
}
}
+63
View File
@@ -216,6 +216,69 @@ fn a_box_grazing_a_word_is_not_flagged() {
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn opaque_filled_square_annotation_over_text_is_found() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
/// A Hidden Square (`/F 2`) is never drawn, so the text under it is not a finding.
#[test]
fn hidden_square_annotation_is_not_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 2 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
/// A NoView Square (`/F 32`) is never drawn on screen, so the text under it is not a finding.
#[test]
fn no_view_square_annotation_is_not_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 32 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
/// A Print-only Square (`/F 4`) is still drawn on screen, so the text under it is a finding.
#[test]
fn print_flag_square_annotation_is_still_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 4 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
}
#[test]
fn background_before_text_is_not_flagged() {
// An opaque dark rectangle painted *before* the text is a background, not a cover.
+1
View File
@@ -1315,6 +1315,7 @@ impl Automation {
if let Some(s) = a.opt_int("start_number")? {
hf.start_number = s.clamp(1, u32::MAX as i64) as u32;
}
hf.shrink = a.opt_bool("shrink")?.unwrap_or(false);
Edit::AddHeaderFooter { pages, settings: hf, replace }
}
"doc_watermark" => {
+18 -4
View File
@@ -2,7 +2,7 @@
use crate::{Args, Automation, Result, ToolError, failed};
use pdfcraft_engine::{
Edit, Style,
measure::{self, Kind, NewMeasurement, Scale},
measure::{self, Kind, NewMeasurement, PageUnit, Scale},
};
use serde_json::{Value, json};
@@ -78,16 +78,30 @@ impl Automation {
let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone();
let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?;
let unit = a.opt_str("unit")?.unwrap_or("in");
if ["points", "units_per_point", "page_distance"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
return Err(failed("pass one of calibration points, units_per_point or page_distance"));
}
if a.get("page_unit").is_some() && a.get("page_distance").is_none() {
return Err(failed("page_unit is the unit of page_distance; pass page_distance too"));
}
let scale = if a.get("points").is_some() {
if a.get("units_per_point").is_some() {
return Err(failed("pass calibration points or units_per_point, not both"));
}
let (_, points) = self.measurement_points(a)?;
let [from, to] = points.as_slice() else {
return Err(failed("scale calibration needs exactly two points"));
};
let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?;
Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)?
} else if let Some(page_distance) = a.opt_num("page_distance")? {
// A ratio, "page_distance page_unit on the page = distance unit"; the page unit
// defaults to the real-world one, as "1:50" means 1 mm = 50 mm.
let page_unit = match a.opt_str("page_unit")? {
Some(u) => PageUnit::from_label(u).ok_or_else(|| failed("page_unit must be pt, in, mm or cm"))?,
None => PageUnit::from_label(unit).ok_or_else(|| failed("pass page_unit (pt, in, mm or cm) when unit is not one of them"))?,
};
let length = a.opt_num("distance")?.ok_or_else(|| failed("a scale ratio needs the real-world distance"))?;
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?;
Scale::ratio(from, to, page_distance, page_unit, length, unit, precision).map_err(failed)?
} else if let Some(factor) = a.opt_num("units_per_point")? {
{
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
+13 -3
View File
@@ -14,9 +14,19 @@ impl Automation {
pub(crate) fn redact_mark(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let (id, n) = (doc.id, doc.info.pages.len());
let pages: Vec<usize> = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
if a.get("page").is_some() && a.get("pages").is_some() {
return Err(ToolError::InvalidArgs("pass page or pages, not both".into()));
}
if a.get("rect").is_some() && a.get("pages").is_some() {
return Err(ToolError::InvalidArgs("rect requires page, not pages".into()));
}
let pages: Vec<usize> = if a.get("page").is_some() {
vec![self.page(a)?]
} else {
match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
}
};
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
+7 -6
View File
@@ -440,12 +440,12 @@ 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: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). 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: 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.",
)
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
@@ -453,7 +453,7 @@ pub fn tools() -> Vec<ToolDef> {
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
"overlay": { "type": "string" },
"code_set": { "type": "string", "enum": ["foia", "privacy-act"] },
"codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." },
@@ -635,8 +635,8 @@ pub fn tools() -> Vec<ToolDef> {
.ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])),
t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])),
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using a ratio (page_distance in page_unit on the page = distance in unit, e.g. 1 mm = 50 mm for a 1:50 plan; page_unit defaults to unit), units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"page_distance":{"type":"number","exclusiveMinimum":0},"page_unit":{"type":"string","enum":["pt","in","mm","cm"]},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.")
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
@@ -889,7 +889,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_header_footer",
"Add header and footer",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
@@ -903,6 +903,7 @@ pub fn tools() -> Vec<ToolDef> {
"color": { "type": "string", "description": "#RRGGBB or a colour name." },
"margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." },
"start_number": { "type": "integer", "minimum": 1 },
"shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." },
"replace": { "type": "boolean" },
}),
&["doc"],
+154
View File
@@ -2255,6 +2255,43 @@ fn headers_watermarks_and_backgrounds_through_tools() {
assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier")));
}
#[test]
fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() {
let dir = workdir("hf-shrink");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
// The box of "Page 1" (left, top, width, height in points), read back from a saved copy.
let mut saved = 0;
let mut text_box = |a: &mut Automation| -> [f64; 4] {
saved += 1;
let path = format!("shrunk{saved}.pdf");
ok(a, "doc_save", json!({ "doc": doc, "path": path }));
let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
let found = ok(a, "text_find", json!({ "doc": copy, "query": "Page 1" }));
let r = found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("Page 1 is on the page: {found}"));
let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap()).collect();
[v[0], v[1], v[2] - v[0], v[3] - v[1]]
};
let before = text_box(&mut a);
ok(
&mut a,
"doc_header_footer",
json!({ "doc": doc, "pages": [1], "header_center": "ACME", "footer_right": "<<Bates Number#6#1##>>", "shrink": true }),
);
let after = text_box(&mut a);
// 200 × 300 page, 8 pt text and 36 pt margins: the header takes 48 pt, the footer 46.4 pt.
let k = (300.0 - 48.0 - 46.4) / 300.0;
assert!((after[2] / before[2] - k).abs() < 0.02 && (after[3] / before[3] - k).abs() < 0.02, "scaled by {k}: {before:?} → {after:?}");
assert!((after[0] - (20.0 * k + (200.0 - 200.0 * k) / 2.0)).abs() < 1.0, "centred across: {before:?} → {after:?}");
assert!(page_text(&mut a, doc)[0].contains("000001"), "the Bates number is drawn");
// Margins that leave no room are refused; Remove brings the content back to full size.
let full = json!({ "doc": doc, "header_center": "X", "margins": [295, 36, 72, 72], "shrink": true, "replace": true });
assert!(matches!(a.call("doc_header_footer", &full), Err(ToolError::Failed(_))), "no room");
ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "header_footer" }));
let restored = text_box(&mut a);
assert!(restored.iter().zip(before).all(|(r, b)| (r - b).abs() < 0.01), "{before:?} → {restored:?}");
}
#[test]
fn watermarks_move_from_the_page_centre_through_tools() {
let dir = workdir("wm-offset");
@@ -3467,6 +3504,36 @@ fn rotating_a_field_through_tools() {
assert_eq!((rot, back), (0, before));
}
#[test]
fn redact_mark_respects_single_page_and_rejects_conflicting_selectors() {
let dir = workdir("redact-single-page");
std::fs::write(dir.join("memo.txt"), "Private 123-45-6789").unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [1] }));
for selector in [json!({ "find": "Private" }), json!({ "words": ["Private"] }), json!({ "pattern": "ssn" }), json!({ "whole_pages": true })] {
let mut args = selector;
args["doc"] = json!(doc);
args["page"] = json!(1);
let result = ok(&mut a, "redact_mark", args);
assert_eq!(result["marked"], 1);
ok(&mut a, "redact_clear", json!({ "doc": doc }));
}
for args in [
json!({ "doc": doc, "find": "Private", "page": 1, "pages": [2] }),
json!({ "doc": doc, "rect": [1, 1, 10, 10], "pages": [2] }),
json!({ "doc": doc, "find": "Private", "page": 3 }),
] {
assert!(matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_))));
}
let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Private", "page": 1 }));
assert_eq!(result["marks_pending"], 1, "invalid selections must not add marks");
ok(&mut a, "redact_apply", json!({ "doc": doc }));
let text = page_text(&mut a, doc);
assert!(!text[0].contains("Private"));
assert!(text[1].contains("Private"));
}
#[test]
fn redacting_through_tools() {
let dir = workdir("redact");
@@ -3491,6 +3558,48 @@ fn redacting_through_tools() {
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
}
/// One page with the text "Secret 1234" and a Redact annotation that has neither a usable
/// QuadPoints array nor a Rect, so there is no area to redact.
fn redact_pdf_with_unreadable_mark() -> Vec<u8> {
let body = "BT /F1 24 Tf 20 150 Td (Secret 1234) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>".into(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Annot /Subtype /Redact /QuadPoints /oops >>".into(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
/// A mark that can't be read is refused by `redact_apply`, with the reason in the reply, and the
/// page keeps its text. (Before, the mark was skipped and the reply said there were no marks.)
#[test]
fn an_unreadable_redaction_mark_is_refused_with_the_reason() {
let dir = workdir("redact-unreadable");
std::fs::write(dir.join("broken.pdf"), redact_pdf_with_unreadable_mark()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap();
let Err(ToolError::Failed(reason)) = a.call("redact_apply", &json!({ "doc": doc })) else {
panic!("redact_apply must refuse a mark with no readable area");
};
assert!(reason.contains("1 redaction mark(s) have no readable area") && reason.contains("nothing was redacted"), "{reason}");
assert!(page_text(&mut a, doc)[0].contains("Secret 1234"), "the page is unchanged");
}
#[test]
fn redacting_with_codes_through_tools() {
let dir = workdir("redact-codes");
@@ -6062,6 +6171,51 @@ fn measurements_calibrate_draw_save_reopen_and_export() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurement_scale_ratio_takes_the_page_side_in_a_unit() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm.
let dir = workdir("measurement-ratio");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
let mm = 72.0 / 25.4;
let line = json!([[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]]);
let read = |a: &mut Automation, doc: u64| ok(a, "measure_info", json!({"doc":doc,"page":1,"points":line}))["reading"]["value"].as_f64().unwrap();
let set = ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"mm","distance":50,"unit":"mm"}));
assert_eq!(set["scale"]["ratio"], "1 mm = 50 mm");
assert!((read(&mut a, doc) - 500.0).abs() < 1e-9);
// The PDF keeps its factors per point: 50 mm for every 25.4 / 72 mm of page.
assert!((set["scale"]["x"].as_f64().unwrap() - 50.0 / mm).abs() < 1e-9);
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert!((read(&mut a, doc) - 10.0 / 25.4).abs() < 1e-9, "true size in inches without a scale");
ok(&mut a, "edit_redo", json!({"doc":doc}));
assert!((read(&mut a, doc) - 500.0).abs() < 1e-9);
ok(&mut a, "doc_save", json!({"doc":doc,"path":"ratio.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"ratio.pdf"}))["doc"].as_u64().unwrap();
assert!((read(&mut a, reopened) - 500.0).abs() < 1e-9);
assert_eq!(ok(&mut a, "measure_scale", json!({"doc":reopened,"page":1,"at":[20,30]}))["scale"]["ratio"], "1 mm = 50 mm");
// The page side defaults to the real-world unit; any page unit converts to points.
ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":2,"distance":1,"unit":"cm"}));
assert!((read(&mut a, doc) - 0.5).abs() < 1e-9);
ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":0.25,"page_unit":"in","distance":1,"unit":"ft"}));
assert!((read(&mut a, doc) - 10.0 / 25.4 * 4.0).abs() < 1e-9);
// Units per point keep meaning points.
ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"units_per_point":50,"unit":"mm"}));
assert!((read(&mut a, doc) - 500.0 * mm).abs() < 1e-9);
let before = ok(&mut a, "doc_list", json!({}));
for args in [
json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"ft","distance":1,"unit":"ft"}),
json!({"doc":doc,"page":1,"page_distance":1,"distance":1,"unit":"m"}),
json!({"doc":doc,"page":1,"page_distance":1,"unit":"mm"}),
json!({"doc":doc,"page":1,"page_unit":"mm","units_per_point":1}),
json!({"doc":doc,"page":1,"page_distance":1,"units_per_point":1,"distance":1}),
json!({"doc":doc,"page":1,"page_distance":1,"points":[[0,0],[10,0]],"distance":1}),
] {
assert!(a.call("measure_scale", &args).is_err(), "{args}");
assert_eq!(ok(&mut a, "doc_list", json!({})), before);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn measurements_bad_arguments_leave_document_and_history_unchanged() {
let dir = workdir("measurement-errors");
+63
View File
@@ -69,6 +69,10 @@ pub struct HeaderFooter {
pub margins: [f64; 4],
/// The number `<<1>>` shows on the first page of the range.
pub start_number: u32,
/// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance
/// Options): the original content is scaled down evenly into the band the header and footer
/// leave free, centred across the page. Remove and Update take the scaling away again.
pub shrink: bool,
}
impl Default for HeaderFooter {
@@ -82,6 +86,7 @@ impl Default for HeaderFooter {
underline: false,
margins: [36.0, 36.0, 72.0, 72.0],
start_number: 1,
shrink: false,
}
}
}
@@ -95,6 +100,42 @@ impl HeaderFooter {
pub fn first_undrawable(&self) -> Option<char> {
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
}
/// The room the header and the footer take at the top and the bottom of a page, in points:
/// the margin, the lines of the tallest box, and a clearance of half the font size. A side
/// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free.
pub fn bands(&self) -> (f64, f64) {
let size = self.font_size;
let line_h = size * 1.2;
let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max();
let (header, footer) = self.text.split_at(3);
// Header lines hang below the top margin (a full line each); the footer's last line sits
// on the bottom margin, so only its capitals (0.8 of the size) rise above it.
let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0);
let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0);
(top, bottom)
}
}
/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free,
/// for a page shown `size` (display space) with view matrix `view` (display to user space): an
/// even scale, centred across, the content's bottom on the footer's band.
fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> {
let (w, h) = size;
let (top, bottom) = hf.bands();
let free = h - top - bottom;
if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) {
return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into()));
}
let k = free / h;
let v = kurbo::Affine::new(view);
let det = v.determinant();
if !(det.is_finite() && det.abs() > 1e-12) {
return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into()));
}
let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]);
// User space to display space, shrink there, and back.
Ok((v * shrink * v.inverse()).as_coeffs())
}
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
@@ -606,6 +647,18 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Ok(())
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
/// on it and is left alone.
fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) };
let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
list.insert(open + 1, stream);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
}
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
@@ -686,6 +739,11 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
u64::MAX
)));
}
// Shrinking needs room on every page: refuse before anything is drawn or replaced.
let shrinks = pages
.iter()
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
.collect::<Result<Vec<_>, _>>()?;
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
@@ -746,6 +804,11 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
}
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
let cm = format!("{} cm\n", m.map(matrix_number).join(" "));
let page = &page_list(doc)[i];
place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?;
}
}
Ok(())
}
+94
View File
@@ -270,6 +270,100 @@ fn replace_and_remove_restore_the_original_content() {
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
/// The six numbers of the first `… cm` in `content`.
fn first_cm(content: &str) -> [f64; 6] {
let line = content.lines().find(|l| l.ends_with(" cm")).unwrap_or_else(|| panic!("no cm in {content:?}"));
let v: Vec<f64> = line.split_whitespace().take(6).map(|x| x.parse().unwrap()).collect();
<[f64; 6]>::try_from(v).unwrap()
}
fn map_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01
}
fn shrinking(header: &str, footer: &str) -> HeaderFooter {
let mut hf = HeaderFooter { shrink: true, ..HeaderFooter::default() };
hf.text[1] = header.into();
hf.text[5] = footer.into();
hf
}
#[test]
fn shrink_scales_the_original_content_into_the_band_between_header_and_footer() {
let mut doc = fixture();
// 8 pt text, margins 36 top and bottom: the header takes 36 + 8 (+ 4 clearance) at the top,
// the footer 36 + 6.4 (its capitals above the baseline, + 4) at the bottom.
add_header_footer(&mut doc, &[0], &shrinking("ACME", "<<Bates Number#6#1##>>"), false, &cx()).unwrap();
let doc = reopen(&doc);
let s = streams(&doc, 0);
// q-wrapper, shrink, original, Q-wrapper, header/footer.
assert_eq!(s.len(), 5, "{s:?}");
assert_eq!((s[0].as_str(), s[3].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n"));
assert!(s[2].contains("(Hi) Tj"), "the original content follows the shrink: {s:?}");
let m = first_cm(&s[1]);
let k = (800.0 - 48.0 - 46.4) / 800.0;
assert!((m[0] - k).abs() < 1e-3 && (m[3] - k).abs() < 1e-3 && m[1] == 0.0 && m[2] == 0.0, "uniform scale {k}: {m:?}");
// The page's corners land on the band, centred across.
assert!(near(map_point(m, 0.0, 0.0), ((600.0 - 600.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, 600.0, 800.0), ((600.0 + 600.0 * k) / 2.0, 800.0 - 48.0)), "{m:?}");
assert!(s[4].contains("(000001) Tj"), "{}", s[4]);
}
#[test]
fn shrink_follows_the_display_space_of_a_rotated_page() {
let mut doc = fixture();
// Page 2 is rotated 90°: shown 800 wide and 600 high. A footer alone keeps the top.
add_header_footer(&mut doc, &[1], &shrinking("", "<<1>>"), false, &cx()).unwrap();
let s = streams(&doc, 1);
let m = first_cm(&s[1]);
let page = &pdfcraft_model::pages(&doc)[1];
let v = page.view_matrix(&doc);
let k = (600.0 - 46.4) / 600.0;
// Display (x, y) is user v·(x, y): the shrunk content's display corners.
let to_user = |x: f64, y: f64| map_point(v, x, y);
assert!(near(map_point(m, to_user(0.0, 0.0).0, to_user(0.0, 0.0).1), to_user((800.0 - 800.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, to_user(800.0, 600.0).0, to_user(800.0, 600.0).1), to_user((800.0 + 800.0 * k) / 2.0, 600.0)), "{m:?}");
}
#[test]
fn shrink_is_undone_by_remove_and_not_doubled_by_update() {
let mut doc = fixture();
let original = streams(&doc, 0);
add_background(&mut doc, &[0], &Background::default(), false).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("First", ""), false, &cx()).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("Second", ""), true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains(" cm") && !x.contains("/PCMark")).count(), 2, "one shrink besides the original's own cm: {s:?}");
assert!(s[0].contains("/PCMark /Background"), "the background stays behind, full size: {s:?}");
// Update without shrinking: back to full size.
let mut plain = shrinking("Third", "");
plain.shrink = false;
add_header_footer(&mut doc, &[0], &plain, true, &cx()).unwrap();
assert_eq!(streams(&doc, 0).len(), 5, "background, wrapper, original, wrapper, header: {:?}", streams(&doc, 0));
add_header_footer(&mut doc, &[0], &shrinking("Fourth", ""), true, &cx()).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 2, "the header and its shrink");
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
#[test]
fn shrink_with_no_room_left_is_refused_without_drawing() {
let mut doc = fixture();
let mut hf = shrinking("Top", "Bottom");
hf.margins = [400.0, 400.0, 72.0, 72.0];
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("no room"), "{err}");
assert!(!doc.is_modified());
// A page without content has nothing to shrink: it just gets the header.
let mut doc = fixture();
add_header_footer(&mut doc, &[2], &shrinking("Top", ""), false, &cx()).unwrap();
assert_eq!(streams(&doc, 2).len(), 1);
}
#[test]
fn watermarks_rotate_fade_and_can_go_behind() {
let mut doc = fixture();
+2 -2
View File
@@ -4,9 +4,9 @@ Layer L4 implements distance, connected-line perimeter and simple polygon area m
Measurements are ordinary Line, PolyLine or Polygon annotations with a rectilinear Measure dictionary and a normal appearance containing the numeric caption. Their scale is stored with the annotation, so changing a drawing scale does not change earlier measurements. Moving, restyling, replying to and deleting them uses the existing comment workflow.
Scale::calibrate derives a conversion from two points and a known real-world distance. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation.
Scale::calibrate derives a conversion from two points and a known real-world distance. Scale::ratio takes a scale as a plan writes it, `1 mm = 50 mm` at 1:50: its page side is in pt, in, mm or cm and is converted to points, so the stored /X, /D and /A factors stay per user-space unit and the /R string keeps the units as entered. Scales read from files are used as stored. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation.
The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. Tool coordinates and explicit units-per-point scales use physical display points.
The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. The panel's drawing scale and measure_scale's `page_distance`/`page_unit` give the page side in a unit, by default the real-world one. Tool coordinates and explicit units-per-point scales use physical display points.
Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 20,000 segments, 40,000 endpoint and midpoint targets, 16 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits.
+51
View File
@@ -110,6 +110,19 @@ impl Scale {
}
Self::new(real_distance / length, unit, precision)
}
/// A scale as a plan writes it: `page` `page_unit` on the page is `real` `unit` in the real
/// world ("1 mm = 50 mm" at 1:50). `a` and `b` are the user-space ends of one display point,
/// as for [`Scale::calibrate`]. The page side is converted to points, so the stored factors
/// stay per user-space unit; the ratio string keeps the units as entered.
pub fn ratio(a: Point, b: Point, page: f64, page_unit: PageUnit, real: f64, unit: &str, precision: u8) -> Result<Self> {
if !page.is_finite() || page <= 0.0 || !real.is_finite() || real <= 0.0 {
return Err(invalid("both sides of a scale ratio must be finite and positive"));
}
let mut out = Self::calibrate(a, b, real / (page * page_unit.points()), unit, precision)?;
out.ratio = format!("{} {} = {} {unit}", ratio_number(page), page_unit.label(), ratio_number(real));
out.validate()?;
Ok(out)
}
pub fn validate(&self) -> Result<()> {
for f in [self.x, self.y, self.distance_factor, self.area_factor] {
if !f.is_finite() || !(1e-12..=1e12).contains(&f) {
@@ -202,6 +215,44 @@ impl Scale {
Ok(out)
}
}
/// The unit of the page side of a scale ratio. The PDF keeps scales per user-space unit
/// (ISO 32000-2 §12.9), so a ratio given in one of these is converted to points.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PageUnit {
Pt,
In,
Mm,
Cm,
}
impl PageUnit {
pub const ALL: [Self; 4] = [Self::Pt, Self::In, Self::Mm, Self::Cm];
pub fn label(self) -> &'static str {
match self {
Self::Pt => "pt",
Self::In => "in",
Self::Mm => "mm",
Self::Cm => "cm",
}
}
pub fn from_label(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|u| u.label() == s.trim())
}
/// Points in one of this unit.
pub fn points(self) -> f64 {
match self {
Self::Pt => 1.0,
Self::In => 72.0,
Self::Mm => 72.0 / 25.4,
Self::Cm => 72.0 / 2.54,
}
}
}
/// A side of a scale ratio as people write it: at most six decimals, no trailing zeros.
fn ratio_number(v: f64) -> String {
let fixed = format!("{v:.6}");
let short = fixed.trim_end_matches('0').trim_end_matches('.');
if short == "0" || short.len() > 24 { format!("{v:e}") } else { short.into() }
}
pub(crate) fn check_points(points: &[Point]) -> Result<()> {
if points.len() > MAX_POINTS || points.iter().flatten().any(|v| !v.is_finite() || v.abs() > MAX_COORD) {
return Err(invalid("measurement geometry must be finite, within 1e9 user units, and have at most 2048 vertices"));
+2 -1
View File
@@ -218,7 +218,8 @@ impl Walker<'_> {
match s.decoded_within(remaining) {
Ok(data) => {
joined.extend(data);
joined.push(b'\n');
// A space, as the renderer joins pieces: a trailing comment runs on.
joined.push(b' ');
}
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
}
+40
View File
@@ -363,3 +363,43 @@ fn user_unit_is_read_as_acrobat_reads_it() {
close(view[1], 72.0);
}
}
#[test]
fn a_scale_ratio_converts_its_page_side_to_points() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm.
// The page side used to be points: 1 pt = 50 mm read the line as 1417 mm (50 × 72 / 25.4).
let mm = 72.0 / 25.4;
let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]];
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 1.0, PageUnit::Mm, 50.0, "mm", 2).unwrap();
close(reading(Kind::Distance, &line, &scale).unwrap().value, 500.0);
assert_eq!(reading(Kind::Distance, &line, &scale).unwrap().label, "500.00 mm");
assert_eq!(scale.ratio, "1 mm = 50 mm");
// The PDF stores the factors per point (ISO 32000-2 §12.9): X converts user units to mm.
let dict = scale.dictionary().unwrap();
let x = dict.get(b"X").unwrap().as_array().unwrap()[0].as_dict().unwrap();
close(x.get(b"C").and_then(Object::as_f64).unwrap(), 50.0 / mm);
assert_eq!(Scale::read(&fixture("", "", &[]), &Object::Dict(dict)).unwrap(), scale);
// Every page unit converts: one of it on the page is one metre.
for (unit, points) in [(PageUnit::Pt, 1.0), (PageUnit::In, 72.0), (PageUnit::Mm, mm), (PageUnit::Cm, 10.0 * mm)] {
assert_eq!(PageUnit::from_label(unit.label()), Some(unit));
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 2.0, unit, 2.0, "m", 3).unwrap();
close(reading(Kind::Distance, &[[0.0, 0.0], [0.0, 3.0 * points]], &scale).unwrap().value, 3.0);
assert_eq!(scale.ratio, format!("2 {} = 2 m", unit.label()));
}
assert_eq!(PageUnit::from_label("ft"), None);
// On a UserUnit 2 page one display point spans half a user unit; the ratio still holds on screen.
let mut d = fixture("", "/UserUnit 2", &[]);
let (a, b) = (view_to_user(&d, 0, [0.0, 0.0]).unwrap(), view_to_user(&d, 0, [1.0, 0.0]).unwrap());
let scale = Scale::ratio(a, b, 1.0, PageUnit::Cm, 0.5, "m", 2).unwrap();
let ends = [view_to_user(&d, 0, [10.0, 10.0]).unwrap(), view_to_user(&d, 0, [10.0 + 4.0 * 10.0 * mm, 10.0]).unwrap()];
close(reading(Kind::Distance, &ends, &scale).unwrap().value, 2.0);
// Saved and reopened, the viewport keeps the factors and the ratio as written.
set_scale(&mut d, 0, [0.0, 0.0, 612.0, 792.0], "plan", &scale).unwrap();
let d = Document::open(Arc::new(write_full(&d, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(scale_at(&d, 0, ends[0]).unwrap(), scale);
// Points per unit keep meaning points, and both sides must be finite and positive.
assert_eq!(Scale::calibrate([0.0, 0.0], [1.0, 0.0], 50.0, "mm", 2).unwrap().ratio, "1 pt = 50 mm");
for (page, real) in [(0.0, 1.0), (-1.0, 1.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0), (1.0, 0.0), (1.0, f64::NAN), (1e-300, 1e300)] {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
+41
View File
@@ -175,3 +175,44 @@ fn high_curvature_geometry_hits_segment_cap() {
let geometry = check_snap_invariants(&stress_curve);
assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segments.len());
}
fn two_piece_pdf(first: &[u8], second: &[u8]) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources << >> >>".to_vec(),
];
for content in [first, second] {
let mut object = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
object.extend_from_slice(content);
object.extend_from_slice(b"\nendstream");
objects.push(object);
}
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn snap_at_box_corner(first: &[u8]) -> Option<snap::Snap> {
let pdf = two_piece_pdf(first, b"0 0 1 RG 15 95 150 30 re S\n");
let doc = Document::open(Arc::new(pdf)).unwrap();
snap::geometry(&doc, 0).unwrap().snap([15.0, 105.0], 4.0, SnapOptions::default()).unwrap()
}
#[test]
fn trailing_comment_in_previous_piece_hides_next_piece_first_line() {
assert!(snap_at_box_corner(b"q % tail comment, no end of line").is_none(), "snapped to a rectangle the renderer comments out");
assert!(snap_at_box_corner(b"q % tail comment\n").is_some(), "control: the rectangle is drawn");
}
+8 -2
View File
@@ -32,7 +32,9 @@ clear_marks(&mut doc, None)?; // remove marks without app
A verification pass re-reads each redacted page and fails the operation if any glyph or inline
image is still under an area. Content that can't be decoded makes the operation fail rather than
leave it unredacted.
leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable
`/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of
skipping that mark. The count covers the marks on the pages being applied.
Before changing any page, `apply` prepares the redaction appearances with a shared 8 MiB
budget for their uncompressed PDF content across the selected pages. It bounds repeated-text
@@ -42,7 +44,11 @@ the document. Reduce the marked area, disable repeated text, or apply fewer page
This limits appearance generation; it is not a limit on the input PDF or the whole process's memory.
Structure cleanup resolves named MCIDs and indirect content references. Form content
references are updated to the cleaned copy; unchanged sibling descriptions remain.
references are updated to the cleaned copy; unchanged sibling descriptions remain. The structure
tree's object references (`/OBJR`) to the annotations and form fields `apply` removes are dropped,
as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed comment or field value.
A tree where that can't be done (an element written in place, or one too large to walk) makes
`apply` fail with `RedactError::Tags` rather than leave the annotation reachable.
Replacement descriptions are removed only from marked-content spans changed by cleanup.
Unchanged spans retain their descriptions; unused property entries are retired during redaction
+37 -4
View File
@@ -38,6 +38,12 @@ pub enum RedactError {
NothingToApply,
#[error("page {0} has content that can't be read, so it can't be redacted safely")]
Unreadable(usize),
/// Redact annotations with no readable area (neither usable `/QuadPoints` nor a usable `/Rect`).
/// Skipping them would leave the text under them in the file while the others report success.
#[error(
"{0} redaction mark(s) have no readable area (neither usable QuadPoints nor a usable Rect), so the text under them can't be known; nothing was redacted. Remove those marks or mark the text again, then apply again"
)]
UnreadableMark(usize),
#[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")]
Residue(usize),
/// A mark's overlay text has a character Helvetica can't draw; nothing was redacted.
@@ -127,7 +133,8 @@ pub struct Report {
pub annotations: usize,
pub fields: usize,
pub replacement_text: usize,
/// Structure elements that lost alternate/actual text or emptied marked content.
/// Structure elements that lost alternate/actual text or emptied marked content, and
/// structure references to removed annotations.
pub tags: usize,
/// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field
/// values, and from which a relayout could rebuild them) were removed.
@@ -153,9 +160,21 @@ pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec<Object> {
page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default()
}
/// Every annotation the pages list. Compared before and after a removal, what is no longer listed
/// leaves the structure tree too ([`tags::drop_object_refs`]).
pub(crate) fn listed_annotations(doc: &Document) -> HashSet<ObjRef> {
pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect()
}
/// Every redaction mark in the document, page by page.
pub fn marks(doc: &Document) -> Vec<Mark> {
let mut out = Vec::new();
marks_checked(doc).0
}
/// [`marks`], and the 0-based page of each Redact annotation that has no readable area, which
/// [`apply`] refuses rather than skipping.
pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
let (mut out, mut unreadable) = (Vec::new(), Vec::new());
for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() {
for a in annots_of(doc, &p.dict) {
let Some(r) = a.as_ref() else { continue };
@@ -190,13 +209,14 @@ pub fn marks(doc: &Document) -> Vec<Mark> {
rects.push(r);
}
if rects.is_empty() {
unreadable.push(pi);
continue;
}
let overlay = d.get(b"OverlayText").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
out.push(Mark { page: pi, obj: r, rects, fill: color(doc, d, b"IC"), overlay, look: overlay_look(doc, d) });
}
}
out
(out, unreadable)
}
/// A page's content streams: (the references or inline objects as listed, their decoded data).
@@ -319,7 +339,13 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
/// sensitive content still present. Call this on a working clone and replace the original only
/// on success. On any error, discard the working clone; do not save or reuse it.
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
let all_marks = marks(doc);
let (all_marks, unreadable) = marks_checked(doc);
// A mark with no readable area is not applied, and applying the others would look complete:
// refuse before anything changes. Only the pages being applied count.
let unreadable = unreadable.iter().filter(|page| pages.is_none_or(|p| p.contains(page))).count();
if unreadable > 0 {
return Err(RedactError::UnreadableMark(unreadable));
}
let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect();
if chosen.is_empty() {
return Err(RedactError::NothingToApply);
@@ -351,6 +377,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
let mut retired_properties = Vec::new();
let widget_owner: Vec<(ObjRef, String)> =
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
let listed_before = listed_annotations(doc);
for &(pi, page_marks) in &groups {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
@@ -523,6 +550,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
}
}
// 3a. A tagged PDF's structure tree still points at the removed comments and widgets
// (`/OBJR`, #2874): drop those references, or a full rewrite keeps them reachable, the
// comment text and the field value included. Widgets of a removed field on other pages count.
let gone: HashSet<ObjRef> = listed_before.difference(&listed_annotations(doc)).copied().collect();
report.tags += tags::drop_object_refs(doc, &gone)?;
// 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout
// rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the
// redacted value in the file and lets a later relayout put it back. Once redactions are
+108 -7
View File
@@ -56,7 +56,12 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize,
let c = s[i];
if c.is_ascii_digit() {
n += 1;
} else if !(seps.contains(&c) && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) || (c == '(' || c == ')') && seps.contains(&c)) {
} else if !(seps.contains(&c)
&& (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'('))
// A slash only ever joins two digits ("0426/123456").
&& (c != '/' || digit(s.get(i + 1)) && digit(i.checked_sub(1).and_then(|p| s.get(p))))
|| (c == '(' || c == ')') && seps.contains(&c))
{
break;
}
i += 1;
@@ -84,8 +89,56 @@ fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool {
}
}
/// Words that label the number after them as a phone number, lowercased ("Tel.", "cell.", "Fax:").
const PHONE_LABELS: [&str; 24] = [
"tel",
"tél",
"tele",
"telefon",
"telefono",
"teléfono",
"telephone",
"téléphone",
"phone",
"ph",
"cell",
"cel",
"cellulare",
"cellphone",
"mobile",
"mob",
"mobil",
"móvil",
"movil",
"handy",
"fax",
"tlf",
"telf",
"gsm",
];
/// Whether the text before `i` is a phone label, with one to four spaces, colons or dots between.
fn labelled(s: &[char], i: usize) -> bool {
let before = |k: usize| k.checked_sub(1).and_then(|p| s.get(p)).copied();
let mut j = i;
while i - j < 4 && before(j).is_some_and(|c| matches!(c, ' ' | ':' | '.' | '\u{a0}')) {
j -= 1;
}
let end = j;
while end - j < 12 && before(j).is_some_and(char::is_alphabetic) {
j -= 1;
}
if end == i || before(j).is_some_and(char::is_alphanumeric) {
return false;
}
let label: String = s.get(j..end).unwrap_or_default().iter().flat_map(|c| c.to_lowercase()).collect();
PHONE_LABELS.contains(&label.as_str())
}
fn phone(s: &[char], i: usize) -> Option<usize> {
const SEPS: [char; 5] = [' ', '-', '.', '(', ')'];
// A slash joins the area code to the number in Italian and German landlines ("0426/123456").
const RUN_SEPS: [char; 6] = [' ', '-', '.', '(', ')', '/'];
let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit();
if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') {
return None;
@@ -97,20 +150,26 @@ fn phone(s: &[char], i: usize) -> Option<usize> {
}
let plus = s[i] == '+';
let first = if plus { i + 1 } else { i };
let (end, n) = digit_run(s, first, &SEPS, 28);
let (end, n) = digit_run(s, first, &RUN_SEPS, 28);
let run = s.get(first..end)?;
let seps = run.iter().filter(|c| !c.is_ascii_digit()).count();
// Always grouped by at least one separator, so plain numbers never match; nor does the
// head of a run too long to be a phone number.
if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) {
// Grouped by at least one separator, or labelled ("cell. 3451234567", #3205), so plain
// numbers never match; nor does the head of a run too long to be a phone number.
if seps == 0 && !labelled(s, i) || digit(s.get(end)) || run_continues(s, end.checked_sub(1)?, 1, &SEPS) {
return None;
}
// Only an area code with a trunk 0 takes a slash, so "2024/123456" is no phone number; and
// only one, right after it ("0426/123456", not "0426/12/3456" or "0426 12/3456").
let slash = run.contains(&'/');
if run.iter().filter(|c| **c == '/').count() > 1 || slash && !run.iter().take_while(|c| **c != '/').all(char::is_ascii_digit) {
return None;
}
let ok = if plus {
// E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456").
(8..=15).contains(&n)
!slash && (8..=15).contains(&n)
} else {
// North American: 10 digits, 11 with a country code ("(555) 123-4567").
let nanp = (10..=11).contains(&n);
let nanp = (10..=11).contains(&n) && !slash;
// National with a trunk 0 and 6–14 more digits ("0211 123456", "01 23 45 67 89").
// The first group needs two digits so decimals ("0.1234567") don't match, and
// day-first dates ("01.10.2026") are left to the date pattern.
@@ -404,6 +463,48 @@ mod tests {
);
}
/// #3205: Italian numbers written with a slash, and, after a phone label, without separators.
#[test]
fn slashed_and_labelled_unseparated_phone_numbers() {
let lines = [
"A tel. 0426 123456",
"B tel. +39 0426 123456",
"C cell. 345 1234567",
"D cell. 3451234567",
"E tel. 0426/123456",
"F cell. 345.123.4567",
"G tel. 0426.123456",
"H cell. +393451234567",
"Fax: 0211123456",
"Telefono 0426/123456",
];
assert_eq!(
found(Pattern::Phone, &lines.join("\n")),
[
"0426 123456",
"+39 0426 123456",
"345 1234567",
"3451234567",
"0426/123456",
"345.123.4567",
"0426.123456",
"+393451234567",
"0211123456",
"0426/123456"
]
);
// Without a label, or after a word that only ends like one, unseparated numbers stay
// unmarked; a slash needs a trunk 0 and digits on both sides, and dates stay dates.
assert_eq!(
found(
Pattern::Phone,
"id 0211123456, +4921511234567, hotel 3451234567, prot. 2024/123456, on 01/10/2026, \
tel. 12345, tel 0426/ 123456, +39 0426/123456, tel 0426/12/3456, tel 0426 12/3456"
),
Vec::<String>::new()
);
}
#[test]
fn the_tail_of_a_longer_number_is_not_a_phone_number() {
// #1884: the last groups of a French social security number, an IBAN and a SIRET used
+33 -8
View File
@@ -8,7 +8,7 @@ use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::interp::{Mode, Scope, process};
use crate::{RedactError, Report, annots_of, page_streams};
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams};
/// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -97,17 +97,45 @@ fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize {
leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::<usize>()
}
/// The most distinct outline items one count reads, over all levels together.
const OUTLINE_ITEM_BUDGET: usize = 100_000;
fn outline_len(doc: &Document, first: Option<&Object>, depth: usize) -> usize {
let (mut seen, mut counted) = (std::collections::HashSet::new(), 0);
outline_len_seen(doc, first, depth, &mut seen, &mut counted)
}
/// Counts an outline level and everything below it, each item once. `seen` holds the indirect items
/// already counted, and `counted` every item counted so far, inline ones included, which the total
/// budget applies to. An item that two others name (as `/First` or `/Next`) is counted where the walk
/// first reaches it, and a level stops at an item already seen, because the walk that reached that
/// item counts the rest of its level. A hostile outline therefore costs one step per item, not 2^n
/// steps for n levels of shared children. The depth limit of 64 only matters for outlines nested that
/// deep: an item first reached through a deeper path is not visited again from a shallower one, so
/// items nested beyond the limit may stay uncounted.
fn outline_len_seen(
doc: &Document,
first: Option<&Object>,
depth: usize,
seen: &mut std::collections::HashSet<ObjRef>,
counted: &mut usize,
) -> usize {
let mut n = 0;
let mut cur = first.cloned();
let mut guard = 0;
while let Some(o) = cur {
guard += 1;
if guard > 100_000 || depth > 64 {
if guard > 100_000 || depth > 64 || *counted >= OUTLINE_ITEM_BUDGET {
break;
}
if let Object::Ref(r) = &o
&& !seen.insert(*r)
{
break;
}
let Some(d) = doc.resolve(&o).as_dict().cloned() else { break };
n += 1 + outline_len(doc, d.get(b"First"), depth + 1);
*counted += 1;
n += 1 + outline_len_seen(doc, d.get(b"First"), depth + 1, seen, counted);
cur = d.get(b"Next").cloned();
}
n
@@ -287,10 +315,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
};
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet<ObjRef> {
pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect()
};
let listed_before = listed(doc);
let listed_before = listed_annotations(doc);
if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
@@ -363,7 +388,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
// A tagged PDF's structure tree still points at the removed links, comments and flattened
// widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs,
// text and field values included.
let still = listed(doc);
let still = listed_annotations(doc);
let gone: std::collections::HashSet<ObjRef> = listed_before.difference(&still).copied().collect();
crate::tags::drop_object_refs(doc, &gone)?;
let off = hidden_layers(doc);
+276
View File
@@ -1057,6 +1057,75 @@ fn unreadable_content_fails_closed() {
assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1)));
}
/// Leaves a mark with nothing readable: /QuadPoints becomes a name and /Rect is removed.
fn break_area(doc: &mut Document, obj: ObjRef) {
doc.update_dict(obj, |d| {
d.set(b"QuadPoints".to_vec(), Object::name("oops"));
d.remove(b"Rect");
})
.unwrap();
}
/// A Redact annotation with no readable area (neither usable QuadPoints nor a usable Rect) used to
/// vanish: `marks` skipped it, so applying the readable mark next to it reported success and left
/// the text under the broken mark in place. Now the whole operation is refused before anything
/// changes, and the count is of the unreadable marks.
#[test]
fn a_mark_with_no_readable_area_is_refused_instead_of_vanishing() {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]);
mark(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]], "");
mark(&mut doc, 0, &[[0.0, 95.0, 10.0, 110.0]], "");
for m in marks(&doc) {
break_area(&mut doc, m.obj);
}
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
assert_eq!(marks(&doc).len(), 1, "only the readable mark is listed");
assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(2)));
// Nothing changed: the readable mark is still pending and its text is still drawn.
assert_eq!(marks(&doc).len(), 1);
assert!(content(&doc, 0).contains("(AB1234CD) Tj"), "{}", content(&doc, 0));
}
/// A mark whose QuadPoints can't be used still redacts through its Rect, as it always did.
#[test]
fn a_mark_with_only_a_usable_rect_still_redacts() {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]);
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
let m = marks(&doc).swap_remove(0);
doc.update_dict(m.obj, |d| {
d.set(b"QuadPoints".to_vec(), Object::name("oops"));
d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(20), Object::Int(95), Object::Int(40), Object::Int(110)]));
})
.unwrap();
assert_eq!(marks(&doc).len(), 1, "the Rect keeps the mark readable");
let r = apply(&mut doc, None).unwrap();
assert_eq!((r.marks, r.glyphs), (1, 4));
assert!(!content(&doc, 0).contains("1234"));
}
/// Only the pages being applied decide the refusal: an unreadable mark on another page does not
/// stop the marks of the selected page from being applied.
#[test]
fn an_unreadable_mark_on_another_page_does_not_stop_the_selected_page() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET"),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 10 100 Td (XY) Tj ET"),
]);
mark(&mut doc, 1, &[[10.0, 95.0, 30.0, 110.0]], "");
let broken = marks(&doc).swap_remove(0);
break_area(&mut doc, broken.obj);
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)));
assert_eq!(apply(&mut doc, Some(&[0])).map(|r| r.glyphs), Ok(4));
assert!(!content(&doc, 0).contains("1234"));
}
/// A document with something in every hidden-information category.
fn hidden_fixture() -> Document {
let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC";
@@ -1141,6 +1210,130 @@ fn hidden_information_is_counted_and_removed() {
assert!(shown, "the field's value stays visible as page content");
}
/// A one-page document whose catalog names an outline root (object 5). `items` are the outline
/// items, objects 6 onward.
fn outlined_document(items: Vec<Vec<u8>>) -> Document {
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Outlines /First 6 0 R >>".to_vec(),
];
objs.extend(items);
pdf(objs)
}
/// An outline of `levels` levels with two items each. Both items of a level name the first item
/// of the next level as their /First, so each of those items has two parents, and a walk that
/// follows every path takes 2^levels steps. Items a and b of level k are objects 6 + 2k and 7 + 2k.
fn hostile_outline(levels: usize) -> Document {
let mut items = Vec::new();
for k in 0..levels {
let (a, b) = (6 + 2 * k, 7 + 2 * k);
let child = if k + 1 < levels { format!(" /First {} 0 R", a + 2) } else { String::new() };
items.push(format!("<< /Title (a) /Next {b} 0 R{child} >>").into_bytes());
items.push(format!("<< /Title (b){child} /Parent {a} 0 R >>").into_bytes());
}
outlined_document(items)
}
/// Runs `work` on another thread and returns its result. A walk that spins fails the test after
/// `bound` instead of hanging the whole run; the thread stays behind and ends with the process.
fn finishes_within<T: Send + 'static>(bound: std::time::Duration, work: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(work());
});
match rx.recv_timeout(bound) {
Ok(out) => out,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("the walk did not finish within {bound:?}: it is spinning on shared outline items")
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the walk panicked"),
}
}
/// Generous for a slow machine: the bounded walk takes a few milliseconds.
const WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
/// An outline whose items name shared children is counted item by item, each item once, and the
/// walk ends. Counting along every path instead takes 2^60 steps.
#[test]
fn an_outline_whose_items_share_children_is_counted_without_spinning() {
use crate::sanitize::{Hidden, scan};
let doc = hostile_outline(60);
let counts = finishes_within(WALK_BOUND, move || scan(&doc));
assert_eq!(counts.iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 120)), "60 levels of two distinct items");
}
/// Removing the bookmarks counts them through the same walk first, so it must finish too, and
/// it must take the outline out of the catalog.
#[test]
fn removing_bookmarks_from_an_outline_whose_items_share_children_finishes() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = hostile_outline(60);
let (done, outlines_left) = finishes_within(WALK_BOUND, move || {
let done = remove_hidden(&mut doc, &[Hidden::Bookmarks]);
let root = doc.root().unwrap();
let left = doc.get(root).as_dict().is_some_and(|c| c.contains(b"Outlines"));
(done, left)
});
assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 120)]);
assert!(!outlines_left, "the outline is removed from the catalog");
}
/// Nesting and siblings: every item of an ordinary outline is counted once, as before.
#[test]
fn a_nested_outline_is_counted_item_by_item() {
use crate::sanitize::{Hidden, scan};
let doc = outlined_document(vec![
b"<< /Title (Chapter 1) /Parent 5 0 R /First 7 0 R /Last 8 0 R /Next 9 0 R >>".to_vec(),
b"<< /Title (1.1) /Parent 6 0 R /Next 8 0 R >>".to_vec(),
b"<< /Title (1.2) /Parent 6 0 R /Prev 7 0 R >>".to_vec(),
b"<< /Title (Chapter 2) /Parent 5 0 R /Prev 6 0 R /First 10 0 R >>".to_vec(),
b"<< /Title (2.1) /Parent 9 0 R >>".to_vec(),
]);
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 5)));
}
/// Two levels of 60,000 distinct items: each level is under the per-level cap, so only the total
/// cap stops the count at 100,000 (without it the count would be 120,000).
#[test]
fn an_outline_counts_no_more_items_than_its_total_budget() {
use crate::sanitize::{Hidden, scan};
let per_level = 60_000;
let mut items = Vec::with_capacity(2 * per_level);
// Item j is object 6 + j. Level one is items 0..per_level; its first item leads to level two.
for i in 0..per_level {
let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + i) } else { String::new() };
let first = if i == 0 { format!(" /First {} 0 R", 6 + per_level) } else { String::new() };
items.push(format!("<< /Title (a){next}{first} >>").into_bytes());
}
for i in 0..per_level {
let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + per_level + i) } else { String::new() };
items.push(format!("<< /Title (b){next} >>").into_bytes());
}
let doc = outlined_document(items);
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000)));
}
/// Items written inline (not as indirect objects) count towards the total budget too. Each of
/// 60,000 indirect siblings has one inline child, so 120,000 items are reached, and the total must
/// still stop at 100,000.
#[test]
fn inline_outline_children_count_towards_the_total_budget() {
use crate::sanitize::{Hidden, scan};
let siblings = 60_000;
let mut items = Vec::with_capacity(siblings);
for i in 0..siblings {
let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() };
items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes());
}
let doc = outlined_document(items);
assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000)));
}
/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
/// annotations, the URL and the comment text included, while the page no longer lists them.
@@ -1242,6 +1435,89 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() {
assert!(!field_left, "the flattened field didn't survive the save through the structure tree");
}
/// Issue #2874: applying redactions removes the comment and the form field under a mark, and
/// with them the structure tree's object references (`/OBJR`) to them, so a full rewrite of a
/// tagged PDF can't keep the comment's text or the field's value. The field's widget on another
/// page goes with it; a comment outside the mark keeps its reference.
#[test]
fn applied_redactions_drop_structure_tree_references_to_what_they_remove() {
let objs: Vec<Vec<u8>> = vec![
// 1 catalog with a form and a structure tree
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 9 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R 16 0 R] /Count 2 >>".to_vec(),
// 3 page 1: a label, a text field's widget and a comment under the mark, a comment outside
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R 8 0 R 15 0 R] /StructParents 0 >>".to_vec(),
stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"),
FONT.replace("95 0 R", "14 0 R").into_bytes(),
// 6 the field, 7 its widget on page 1 (17 is its widget on page 2), 8 the comment
b"<< /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Kids [7 0 R 17 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 3 0 R /Rect [50 225 200 240] /StructParent 1 /AP << /N 13 0 R >> >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(),
// 9 struct tree root, 10 document, 11 form element (direct OBJRs to both widgets),
// 12 annotation element (OBJR to the comment), 18 annotation element for the kept comment
b"<< /Type /StructTreeRoot /K 10 0 R /ParentTree << /Nums [1 11 0 R 2 12 0 R 3 18 0 R 4 11 0 R] >> /ParentTreeNextKey 5 >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 9 0 R /K [11 0 R 12 0 R 18 0 R] >>".to_vec(),
b"<< /Type /StructElem /S /Form /P 10 0 R /Pg 3 0 R /Alt (FIELDSECRETVALUE) /K [<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >> << /Type /OBJR /Obj 17 0 R /Pg 16 0 R >>] >>".to_vec(),
b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 8 0 R /Pg 3 0 R >>] >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"),
widths(),
// 15 the comment outside the mark
b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [250 10 270 30] /StructParent 3 /Contents (Kept note) >>".to_vec(),
// 16 page 2, with the field's second widget
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [17 0 R] /StructParents 4 >>".to_vec(),
b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 16 0 R /Rect [50 50 200 65] /StructParent 4 /AP << /N 13 0 R >> >>".to_vec(),
b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 15 0 R /Pg 3 0 R >>] >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[40.0, 190.0, 230.0, 245.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!((report.annotations, report.fields), (1, 1), "{report:?}");
assert!(report.tags >= 3, "the three references to the comment and both widgets are counted: {report:?}");
// Without object streams, whose compression would hide the strings from the search.
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(has(b"(Name:)") && has(b"(Kept note)"), "the search sees the saved objects");
assert!(!has(b"COMMENTSECRETNOTE"), "the redacted comment's text survived the save through the structure tree");
assert!(!has(b"FIELDSECRETVALUE"), "the redacted field's value survived the save through the structure tree");
let saved = Document::open(Arc::new(bytes)).unwrap();
let widget_left = saved
.object_numbers()
.into_iter()
.any(|n| saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")));
assert!(!widget_left, "neither of the field's widgets survived the save");
// The kept comment is still tagged.
let kept = saved.object_numbers().into_iter().any(|n| {
saved.get(ObjRef::new(n, saved.generation(n))).as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) {
Object::Array(a) => a.iter().any(|x| {
saved.resolve(x).as_dict().and_then(|d| d.get(b"Obj")).and_then(Object::as_ref).is_some_and(|r| {
saved.get(r).as_dict().and_then(|d| d.get(b"Contents")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "Kept note")
})
}),
_ => false,
})
});
assert!(kept, "the comment outside the mark keeps its structure reference");
}
/// #2874: a structure element written in place that tags a comment under a mark can't be edited
/// without rewriting what holds it, so applying is refused rather than leaving the comment
/// reachable (fail-closed, as in Sanitize).
#[test]
fn applying_redactions_refuses_an_in_place_element_tagging_a_removed_comment() {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 0 /Contents (COMMENTSECRETNOTE) >>".to_vec(),
b"<< >>".to_vec(),
// 6 the root's only kid, an annotation element written in place
b"<< /Type /StructTreeRoot /K [<< /Type /StructElem /S /Annot /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[190.0, 190.0, 230.0, 230.0]], "");
assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags)));
}
/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a
/// reference the walk never reached could keep a removed annotation. Then it is an error, and
/// nothing is changed, rather than success with references left behind (removing only the tree
+36 -9
View File
@@ -34,9 +34,13 @@ const UPLOAD_BYTES_PER_FRAME: usize = 8 << 20;
/// view are kept even over budget (at most a screenful of thumbnails of at most [`MAX_SIDE`]).
const TEXTURE_BUDGET: usize = 32 << 20;
/// The longest side of a thumbnail, in pixels.
/// (Room for a Letter page at 200% on a screen of 1.5 pixels per point; sharper screens upscale a
/// little at the largest sizes.)
const MAX_SIDE: f32 = 768.0;
/// (Room for a Letter page at 500% on a screen of 1.5 pixels per point; sharper screens upscale a
/// little at the largest sizes.) This only binds near the top of the zoom range: a card below it
/// asks for less than this and costs no more than it did. Near the top one thumbnail is a few
/// megabytes, so [`TEXTURE_BUDGET`] and [`UPLOAD_BYTES_PER_FRAME`] keep the cache at a screenful
/// plus one frame's uploads — and at the top of the range a screenful is a card or two, because a
/// card is wider than most windows.
const MAX_SIDE: f32 = 1536.0;
/// Thumbnail sizes round up to this many pixels, so a slightly different layout reuses them.
const SIZE_STEP: u32 = 16;
/// The box a page is fitted in at 100%, and the room the card adds around it (for the hover bar
@@ -50,8 +54,8 @@ const ABOVE: f32 = 32.0;
const BAR_H: f32 = 34.0;
/// The grid's zoom range, and the sizes its buttons and keys step through (the slider, the wheel
/// and a pinch go anywhere between).
pub(crate) const ZOOM_RANGE: std::ops::RangeInclusive<f32> = 0.6..=2.0;
const ZOOM_STEPS: [f32; 7] = [0.6, 0.8, 1.0, 1.25, 1.5, 1.75, 2.0];
pub(crate) const ZOOM_RANGE: std::ops::RangeInclusive<f32> = 0.6..=5.0;
const ZOOM_STEPS: [f32; 11] = [0.6, 0.8, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0];
/// The next size up (or down) from `zoom`; the end of the range stays put.
pub(crate) fn zoom_step(zoom: f32, larger: bool) -> f32 {
@@ -1371,10 +1375,10 @@ mod tests {
// The box follows the zoom, within its range; nonsense is 100%.
assert_eq!(paper_box(1.0), PAPER);
assert_eq!(paper_box(2.0), PAPER * 2.0);
assert_eq!(paper_box(99.0), PAPER * 2.0);
assert_eq!(paper_box(99.0), PAPER * 5.0);
assert_eq!(paper_box(-1.0), PAPER * 0.6);
assert_eq!(paper_box(f32::NAN), PAPER);
for zoom in [0.6, 1.0, 2.0] {
for zoom in [0.6, 1.0, 2.0, 5.0] {
let fit = paper_box(zoom);
// Letter is a little wider than the box: it fills the width. A tall page fills the height.
let letter = paper_size(612.0, 792.0, fit).unwrap();
@@ -1394,12 +1398,12 @@ mod tests {
fn zoom_buttons_step_through_round_sizes() {
let mut z = 0.6;
let mut up = vec![z];
while z < 2.0 {
while z < 5.0 {
z = zoom_step(z, true);
up.push(z);
}
assert_eq!(up, ZOOM_STEPS);
assert_eq!(zoom_step(2.0, true), 2.0, "the largest stays put");
assert_eq!(zoom_step(5.0, true), 5.0, "the largest stays put");
assert_eq!(zoom_step(0.6, false), 0.6, "the smallest stays put");
// From anywhere the slider left it, to the next round size.
assert_eq!(zoom_step(1.1, true), 1.25);
@@ -1408,6 +1412,29 @@ mod tests {
assert_eq!(zoom_step(1.0000001, true), 1.25, "a hair over a size counts as that size");
}
#[test]
fn the_cards_zoom_far_enough_to_read_a_value() {
// #2531: the grid stopped at 200%, which was not enough to read invoice values while
// reordering the pages of a statement. It has to reach 500%.
assert_eq!(*ZOOM_RANGE.end(), 5.0, "the grid stops at {}%, not 500%", ZOOM_RANGE.end() * 100.0);
assert_eq!(paper_box(5.0), PAPER * 5.0, "500% is kept, not clamped to the old ceiling");
// Every round size above the old 200% ceiling is a step the toolbar can reach.
let mut z = *ZOOM_RANGE.start();
let mut up = vec![z];
while z < *ZOOM_RANGE.end() {
z = zoom_step(z, true);
up.push(z);
}
for want in [2.5, 3.0, 4.0, 5.0] {
assert!(up.contains(&want), "no step reaches {want}%: {up:?}");
}
// A card stays sharp at the ceiling instead of upscaled from the old 768px cap. A Letter
// page fills the height of the box, so at 500% it needs PAPER.y * 5.0 points of it, which
// on a 1.5 pixels-per-point screen is this many pixels.
let wanted = PAPER.y * 5.0 * 1.5;
assert!(MAX_SIDE >= wanted, "MAX_SIDE {MAX_SIDE} is under the {wanted}px a card needs at 500%");
}
#[test]
fn arrow_keys_step_by_file_and_by_row() {
// The list: one at a time, staying put at both ends.
+66 -33
View File
@@ -24,6 +24,10 @@ pub struct CompareState {
pub leader: usize,
/// Each half's scroll offset last frame, to tell which one moved.
pub prev_scroll: [egui::Vec2; 2],
/// Show: whether the text changes are in the list and the shading, and whether the visual
/// differences (counted, never listed) are shaded (Acrobat's Filter).
pub show_text: bool,
pub show_visual: bool,
}
/// Visual differences are shaded orange.
@@ -36,6 +40,7 @@ pub enum PanelAction {
Report,
Clear,
SideBySide(bool),
Show { text: bool, visual: bool },
}
pub fn colour(kind: Kind) -> Color32 {
@@ -106,6 +111,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option<CompareState>,
ui.painter().rect_filled(dot, egui::CornerRadius::same(2), VISUAL);
ui.label(crate::i18n::fmt(tl!("{n} {kind}"), &[("n", &s.visual.len().to_string()), ("kind", tl!("Visual"))]));
});
ui.horizontal(|ui| {
ui.label(tl!("Show:"));
let (mut text, mut visual) = (s.show_text, s.show_visual);
let text_changed = ui.add(widgets::checkbox(&mut text, tl!("Text"))).changed();
if ui.add(widgets::checkbox(&mut visual, tl!("Visual"))).changed() || text_changed {
action = Some(PanelAction::Show { text, visual });
}
});
ui.add_space(6.0);
// Side by side, or the new file alone.
ui.horizontal(|ui| {
@@ -129,7 +142,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option<CompareState>,
}
});
ui.separator();
for (i, ch) in c.changes.iter().enumerate().take(2000) {
for (i, ch) in c.changes.iter().enumerate().filter(|_| s.show_text).take(2000) {
let text = match ch.kind {
Kind::Replaced => format!("\"{}\" → \"{}\"", ch.old.text, ch.new.text),
Kind::Inserted => format!("+ \"{}\"", ch.new.text),
@@ -170,38 +183,6 @@ impl PdfCraftApp {
match self.session.compare(old, new) {
Ok(result) => {
let visual = self.session.compare_visual(old, new, 72.0).unwrap_or_default();
self.views[i].compare_marks = result
.changes
.iter()
.flat_map(|ch| ch.new.rects.iter().map(move |r| (ch.new.page, r.map(|v| v as f32), colour(ch.kind))))
.collect();
// Visual differences not already covered by a text change.
let text_marks = self.views[i].compare_marks.clone();
for (p, r) in &visual {
let r = r.map(|v| v as f32);
let covered = text_marks.iter().any(|(mp, m, _)| *mp == *p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3]);
if !covered {
self.views[i].compare_marks.push((*p, r, VISUAL));
}
}
// The old file's pages too: what was deleted or replaced, and the visual
// differences, so both versions read with their own shading.
if let Some(oi) = self.views.iter().position(|v| v.id == old) {
let mut marks: Vec<(usize, [f32; 4], Color32)> = result
.changes
.iter()
.filter(|ch| ch.kind != Kind::Inserted)
.flat_map(|ch| ch.old.rects.iter().map(move |r| (ch.old.page, r.map(|v| v as f32), colour(ch.kind))))
.collect();
for (p, r) in &visual {
let r = r.map(|v| v as f32);
let covered = marks.iter().any(|(mp, m, _)| *mp == *p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3]);
if !covered {
marks.push((*p, r, VISUAL));
}
}
self.views[oi].compare_marks = marks;
}
let n = result.changes.len();
// Side by side when the old version is open in a tab and the window has the room.
let side_by_side = self.views.iter().any(|v| v.id == old) && self.ctx.as_ref().is_none_or(|c| c.content_rect().width() >= 1000.0);
@@ -214,7 +195,10 @@ impl PdfCraftApp {
side_by_side,
leader: 1,
prev_scroll: [egui::Vec2::ZERO; 2],
show_text: true,
show_visual: true,
});
self.shade_compare(i);
self.right = Some(RightPanel::Compare);
self.notify(if n == 0 {
tl!("The text is the same").to_string()
@@ -252,6 +236,11 @@ impl PdfCraftApp {
s.side_by_side = on;
s.leader = 1;
}
PanelAction::Show { text, visual } => {
s.show_text = text;
s.show_visual = visual;
self.shade_compare(index);
}
PanelAction::Mark => match self.session.mark_differences(old, new) {
Ok(n) => {
if let Some(info) = self.session.get(new).map(|d| d.info.clone()) {
@@ -285,6 +274,50 @@ impl PdfCraftApp {
}
}
impl PdfCraftApp {
/// Shade the comparison on the new file's pages (view `index`) and, when it is open, the old
/// file's, with the kinds of change the filter shows.
fn shade_compare(&mut self, index: usize) {
let Some(s) = self.compare.as_ref() else { return };
let old = s.old;
let (old_marks, new_marks) = (shading(s, true), shading(s, false));
if let Some(v) = self.views.get_mut(index) {
v.compare_marks = new_marks;
}
if let Some(v) = self.views.iter_mut().find(|v| v.id == old) {
v.compare_marks = old_marks;
}
}
}
/// One half's shading: its side of the text changes (the old file has no side of an insertion)
/// and the visual differences no text change there covers, so both versions read with their own
/// shading. Hidden kinds are left out after the overlap test, so hiding the text does not uncover
/// the visual differences a text change explains.
fn shading(s: &CompareState, old: bool) -> Vec<(usize, [f32; 4], Color32)> {
let overlaps = |p: usize, r: [f32; 4], marks: &[(usize, [f32; 4], Color32)]| {
marks.iter().any(|(mp, m, _)| *mp == p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3])
};
let mut text = Vec::new();
for ch in s.result.changes.iter().filter(|ch| !(old && ch.kind == Kind::Inserted)) {
let side = if old { &ch.old } else { &ch.new };
text.extend(side.rects.iter().map(|r| (side.page, r.map(|v| v as f32), colour(ch.kind))));
}
let mut visual = Vec::new();
for (p, r) in &s.visual {
let r = r.map(|v| v as f32);
// The old file's half also skips a region overlapping one it already shades.
if !overlaps(*p, r, &text) && !(old && overlaps(*p, r, &visual)) {
visual.push((*p, r, VISUAL));
}
}
let mut out = if s.show_text { text } else { Vec::new() };
if s.show_visual {
out.extend(visual);
}
out
}
/// The two halves of a side-by-side comparison, as view indexes (old, new), when the active
/// document is the new file of a comparison shown side by side and the old file is open too.
pub(crate) fn side_by_side(app: &PdfCraftApp, index: usize) -> Option<(usize, usize)> {
+1
View File
@@ -841,6 +841,7 @@
Insert Page Number إدراج رقم الصفحة
Insert Date إدراج التاريخ
Start page number رقم صفحة البداية
Shrink the page content to make room for the header and footer تصغير محتوى الصفحة لإفساح المجال للرأس والتذييل
Source المصدر
Fit to page ملاءمة الصفحة
Rotation التدوير
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -514,6 +514,7 @@
Insert Page Number Вмъкни номер на страница
Insert Date Вмъкни дата
Start page number Начален номер на страница
Shrink the page content to make room for the header and footer Смаляване на съдържанието на страницата, за да има място за горния и долния колонтитул
Source Източник
Fit to page Побери в страницата
Rotation Завъртане
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -917,6 +917,7 @@ key Space Mezerník
Insert Page Number Vložit číslo stránky
Insert Date Vložit datum
Start page number Počáteční číslo stránky
Shrink the page content to make room for the header and footer Zmenšit obsah stránky, aby se vešlo záhlaví a zápatí
Source Zdroj
Fit to page Přizpůsobit stránce
Rotation Otočení
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -819,6 +819,7 @@
Insert Page Number Seitenzahl einfügen
Insert Date Datum einfügen
Start page number Erste Seitenzahl
Shrink the page content to make room for the header and footer Seiteninhalt verkleinern, damit Kopf- und Fußzeile Platz haben
Source Quelle
Fit to page An Seite anpassen
Rotation Drehung
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -426,6 +426,7 @@
Insert Page Number Εισαγωγή αριθμού σελίδας
Insert Date Εισαγωγή ημερομηνίας
Start page number Αρχικός αριθμός σελίδας
Shrink the page content to make room for the header and footer Σμίκρυνση του περιεχομένου της σελίδας ώστε να χωρούν η κεφαλίδα και το υποσέλιδο
Source Πηγή
Appear behind page Εμφάνιση πίσω από τη σελίδα
Appear on top of page Εμφάνιση πάνω από τη σελίδα
1 # Greek (el) interface catalog. Columns: context <TAB> English source <TAB> Greek translation.
426
427
428
429
430
431
432
+1
View File
@@ -835,6 +835,7 @@
Insert Page Number Insertar número de página
Insert Date Insertar fecha
Start page number Número de página inicial
Shrink the page content to make room for the header and footer Reducir el contenido de la página para dejar espacio al encabezado y al pie de página
Source Origen
Fit to page Ajustar a la página
Rotation Rotación
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -815,6 +815,7 @@
Insert Page Number Insérer le numéro de page
Insert Date Insérer la date
Start page number Numéro de page de départ
Shrink the page content to make room for the header and footer Réduire le contenu de la page pour laisser de la place à l'en-tête et au pied de page
Source Source
Fit to page Ajuster à la page
Rotation Rotation
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -812,6 +812,7 @@
Insert Page Number Oldalszám beszúrása
Insert Date Dátum beszúrása
Start page number Kezdő oldalszám
Shrink the page content to make room for the header and footer Az oldal tartalmának kicsinyítése, hogy elférjen az élőfej és az élőláb
Source Forrás
Fit to page Oldalhoz igazítás
Rotation Elforgatás
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -838,6 +838,7 @@
Insert Page Number Inserisci numero di pagina
Insert Date Inserisci data
Start page number Numero di pagina iniziale
Shrink the page content to make room for the header and footer Riduci il contenuto della pagina per fare spazio a intestazione e piè di pagina
Source Origine
Fit to page Adatta alla pagina
Rotation Rotazione
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -842,6 +842,7 @@
Insert Page Number ページ番号を挿入
Insert Date 日付を挿入
Start page number 開始ページ番号
Shrink the page content to make room for the header and footer ヘッダーとフッターの領域を空けるためにページの内容を縮小
Source ソース
Fit to page ページに合わせる
Rotation 回転
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -798,6 +798,7 @@
Insert Page Number Бет нөмірін енгізу
Insert Date Күнді енгізу
Start page number Бастапқы бет нөмірі
Shrink the page content to make room for the header and footer Жоғарғы және төменгі колонтитулға орын босату үшін бет мазмұнын кішірейту
Source Дереккөз
Fit to page Бетке сыйдыру
Rotation Бұрылу
1 Menu Мәзір
798 Insert Page Number Бет нөмірін енгізу
799 Insert Date Күнді енгізу
800 Start page number Бастапқы бет нөмірі
801 Shrink the page content to make room for the header and footer Жоғарғы және төменгі колонтитулға орын босату үшін бет мазмұнын кішірейту
802 Source Дереккөз
803 Fit to page Бетке сыйдыру
804 Rotation Бұрылу
+1
View File
@@ -1150,6 +1150,7 @@
Start Pradėti
Start Checking Pradėti tikrinimą
Start page number Pradinio puslapio numeris
Shrink the page content to make room for the header and footer Sumažinti puslapio turinį, kad tilptų antraštė ir poraštė
Status Būsena
Steps Veiksmai
Strength: {s} Stiprumas: {s}
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -834,6 +834,7 @@
Insert Page Number Wstaw numer strony
Insert Date Wstaw datę
Start page number Początkowy numer strony
Shrink the page content to make room for the header and footer Zmniejsz zawartość strony, aby zrobić miejsce na nagłówek i stopkę
Source Źródło
Fit to page Dopasuj do strony
Rotation Obrót
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -469,6 +469,7 @@
Insert Page Number Inserir número de página
Insert Date Inserir data
Start page number Número da página inicial
Shrink the page content to make room for the header and footer Reduzir o conteúdo da página para dar espaço ao cabeçalho e ao rodapé
Source Origem
Fit to page Ajustar à página
Rotation Rotação
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -835,6 +835,7 @@
Insert Page Number Вставить номер страницы
Insert Date Вставить дату
Start page number Начальный номер страницы
Shrink the page content to make room for the header and footer Уменьшить содержимое страницы, чтобы освободить место для колонтитулов
Source Источник
Fit to page По странице
Rotation Поворот
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1767,6 +1767,7 @@
Start Začetek
Start Checking Začni preverjanje
Start page number Številka začetne strani
Shrink the page content to make room for the header and footer Pomanjšaj vsebino strani, da bo prostor za glavo in nogo
Status Stanje
Steps Koraki
Sticky note Opomba
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -816,6 +816,7 @@ comment menu Edit సవరించు
Insert Page Number పేజీ సంఖ్యను చొప్పించు
Insert Date తేదీని చొప్పించు
Start page number ప్రారంభ పేజీ సంఖ్య
Shrink the page content to make room for the header and footer హెడర్ మరియు ఫుటర్‌కు చోటు కల్పించేందుకు పేజీ కంటెంట్‌ను కుదించు
Source మూలం
Fit to page పేజీకి సరిపోయేలా
Rotation భ్రమణం
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -797,6 +797,7 @@
Insert Page Number Sayfa Numarası Ekle
Insert Date Tarih Ekle
Start page number Başlangıç sayfa numarası
Shrink the page content to make room for the header and footer Üst bilgi ve alt bilgiye yer açmak için sayfa içeriğini küçült
Source Kaynak
Fit to page Sayfaya sığdır
Rotation Döndürme
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
797
798
799
800
801
802
803
+1
View File
@@ -839,6 +839,7 @@
Insert Page Number Вставити номер сторінки
Insert Date Вставити дату
Start page number Початковий номер сторінки
Shrink the page content to make room for the header and footer Зменшити вміст сторінки, щоб звільнити місце для колонтитулів
Source Джерело
Fit to page За розміром сторінки
Rotation Поворот
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1140,6 +1140,7 @@ theme Appearance Giao diện
Start Bắt đầu
Start Checking Bắt đầu kiểm tra
Start page number Số trang bắt đầu
Shrink the page content to make room for the header and footer Thu nhỏ nội dung trang để chừa chỗ cho đầu trang và chân trang
Status Trạng thái
Steps Các bước
Strength: {s} Độ mạnh: {s}
Can't render this file because it contains an unexpected character in line 491 and column 12.
+1
View File
@@ -521,6 +521,7 @@
Insert Page Number 插入页码
Insert Date 插入日期
Start page number 起始页码
Shrink the page content to make room for the header and footer 缩小页面内容,为页眉和页脚留出空间
Source 来源
Fit to page 适合页面
Rotation 旋转
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -474,6 +474,7 @@
Insert Page Number 插入頁碼
Insert Date 插入日期
Start page number 起始頁碼
Shrink the page content to make room for the header and footer 縮小頁面內容,為頁首和頁尾留出空間
Source 來源
Fit to page 適合頁面
Rotation 旋轉
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2 -1
View File
@@ -2904,7 +2904,8 @@ impl PdfCraftApp {
("combine-zoom", _) => {
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
if !percent.is_finite() || !combine_grid::ZOOM_RANGE.contains(&(percent / 100.0)) {
return Err("combine-zoom must be between 60 and 200".into());
let (lo, hi) = (combine_grid::ZOOM_RANGE.start(), combine_grid::ZOOM_RANGE.end());
return Err(format!("combine-zoom must be between {} and {}", lo * 100.0, hi * 100.0));
}
self.set_combine_zoom(percent / 100.0);
}
+10
View File
@@ -229,6 +229,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
ui.label(tl!("Start page number"));
ui.add(egui::DragValue::new(&mut d.hf.start_number).range(1..=999_999));
});
ui.add(widgets::checkbox(&mut d.hf.shrink, tl!("Shrink the page content to make room for the header and footer")));
}
MarkKind::Watermark => {
ui.horizontal(|ui| {
@@ -312,11 +313,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
ui.add_space(8.0);
// Preview of the current page.
let hf = d.hf.clone();
let size_h = page.1.max(1.0);
let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit);
let file_name = d.use_file.then(|| d.file.as_ref().map(|f| f.0.clone())).flatten();
ui.horizontal(|ui| {
preview(ui, t, page, |p, r, s| match kind {
MarkKind::HeaderFooter => {
if hf.shrink {
// The band the page content shrinks into, centred across.
let (top, bottom) = hf.bands();
let k = ((size_h - top - bottom) / size_h).clamp(0.0, 1.0) as f32;
let band = Rect::from_min_max(pos2(r.left(), r.top() + top as f32 * s), pos2(r.right(), r.bottom() - bottom as f32 * s));
let content = Rect::from_center_size(band.center(), vec2(r.width() * k, band.height().max(0.0)));
p.rect_filled(content, CornerRadius::ZERO, Color32::from_gray(232));
}
for (k, text) in hf.text.iter().enumerate() {
if text.trim().is_empty() {
continue;
+31 -10
View File
@@ -3,7 +3,7 @@ use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, w
use egui::{Color32, Pos2, Stroke};
use pdfcraft_engine::{
Document, Edit, Style,
measure::{self, Kind, NewMeasurement, Point, Reading, Scale},
measure::{self, Kind, NewMeasurement, PageUnit, Point, Reading, Scale},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -49,9 +49,12 @@ pub struct MeasureView {
pub snaps: measure::snap::SnapOptions,
pub snap_enabled: bool,
pub sensitivity: f64,
/// The page side of the drawing scale, in its page unit ([`page_unit_of`]).
pub drawing_points: f64,
pub real_distance: f64,
pub unit: String,
/// The unit picked for the page side of the scale; `None` follows `unit`.
pub page_unit: Option<PageUnit>,
pub precision: u8,
pub name: String,
pub whole_page: bool,
@@ -76,9 +79,10 @@ impl Default for MeasureView {
snaps: Default::default(),
snap_enabled: true,
sensitivity: 6.0,
drawing_points: 72.0,
drawing_points: 1.0,
real_distance: 1.0,
unit: "in".into(),
page_unit: None,
precision: 2,
name: "Drawing scale".into(),
whole_page: true,
@@ -90,6 +94,11 @@ impl Default for MeasureView {
}
}
}
/// The unit of the page side of a drawing scale: the one picked, else the real-world unit when it
/// is pt, in, mm or cm (a plan at 1:50 in millimetres is 1 mm = 50 mm), else the display unit.
pub(crate) fn page_unit_of(picked: Option<PageUnit>, unit: &str, display: crate::units::Unit) -> PageUnit {
picked.or_else(|| PageUnit::from_label(unit)).or_else(|| PageUnit::from_label(display.label())).unwrap_or(PageUnit::Pt)
}
impl MeasureView {
pub fn cancel(&mut self) {
self.points.clear();
@@ -194,13 +203,13 @@ pub(crate) fn page_input(
}
let finishes = closes || matches!(tool, Tool::Distance | Tool::Calibrate) && state.points.len() == 2;
if finishes {
finish(state, &mut view.pending_edit, doc, page, tool, author);
finish(state, &mut view.pending_edit, doc, page, tool, author, units);
}
}
}
if state.page == Some(page) && !state.points.is_empty() {
if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) {
finish(state, &mut view.pending_edit, doc, page, tool, author);
finish(state, &mut view.pending_edit, doc, page, tool, author, units);
}
if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) {
state.points.pop();
@@ -252,13 +261,13 @@ pub(crate) fn page_input(
}
}
}
fn finish(state: &mut MeasureView, pending: &mut Option<Edit>, doc: &Document, page: usize, tool: Tool, author: &str) {
fn finish(state: &mut MeasureView, pending: &mut Option<Edit>, doc: &Document, page: usize, tool: Tool, author: &str, units: crate::units::Unit) {
if tool == Tool::Calibrate {
if let [a, b] = state.points.as_slice() {
let a = doc.measurement_to_view(page, *a);
let b = doc.measurement_to_view(page, *b);
if let (Ok(a), Ok(b)) = (a, b) {
state.drawing_points = measure::distance(a, b);
state.drawing_points = measure::distance(a, b) / page_unit_of(state.page_unit, &state.unit, units).points();
}
}
state.cancel();
@@ -309,13 +318,16 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
}
if state.seeded_page != Some(page) {
state.seeded_page = Some(page);
if let (Ok(a), Ok(b)) = (doc.measurement_to_user(page, [0.0, 0.0]), doc.measurement_to_user(page, [72.0, 0.0]))
// One page unit (the one the scale's unit gives) and what it measures.
if let Ok(a) = doc.measurement_to_user(page, [0.0, 0.0])
&& let Ok(scale) = doc.measurement_scale_or(page, a, units.per_point(), units.label())
&& let Ok(b) = doc.measurement_to_user(page, [page_unit_of(None, &scale.unit, units).points(), 0.0])
&& let Ok(reading) = measure::reading(Kind::Distance, &[a, b], &scale)
{
state.drawing_points = 72.0;
state.drawing_points = 1.0;
state.real_distance = reading.value;
state.unit = scale.unit;
state.page_unit = None;
state.precision = scale.precision;
}
}
@@ -353,7 +365,15 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
ui.separator();
ui.label(tl!("Drawing scale"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9).prefix("pt "));
ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9));
let page_unit = page_unit_of(state.page_unit, &state.unit, units);
egui::ComboBox::from_id_salt("measure-page-unit").selected_text(page_unit.label()).width(44.0).show_ui(ui, |ui| {
for u in PageUnit::ALL {
if ui.selectable_label(u == page_unit, u.label()).clicked() {
state.page_unit = Some(u);
}
}
});
ui.label("=");
ui.add(egui::DragValue::new(&mut state.real_distance).range(0.000001..=1e12));
});
@@ -410,7 +430,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
if apply && let Some(info) = info {
let result =
doc.measurement_to_user(page, [0.0, 0.0]).and_then(|a| doc.measurement_to_user(page, [1.0, 0.0]).map(|b| (a, b))).and_then(|(a, b)| {
Scale::calibrate(a, b, state.real_distance / state.drawing_points, &state.unit, state.precision).map_err(|e| e.to_string())
let page_unit = page_unit_of(state.page_unit, &state.unit, units);
Scale::ratio(a, b, state.drawing_points, page_unit, state.real_distance, &state.unit, state.precision).map_err(|e| e.to_string())
});
match result {
Ok(scale) => {
+49 -11
View File
@@ -403,7 +403,8 @@ fn thumbnails_show_the_first_page_each_file_takes() {
assert!(matches!(thumbs[0], CombineThumb::Ready { page: 2, .. }), "\"3, 1\" starts with page 3: {:?}", thumbs[0]);
assert!(matches!(thumbs[1], CombineThumb::Ready { page: 0, .. }));
let CombineThumb::Ready { width, height, .. } = thumbs[1] else { unreachable!() };
assert!(width > 0 && height > 0 && width <= 768 && height <= 768, "{width}×{height}");
// Within MAX_SIDE (combine_grid), which is not visible from here.
assert!(width > 0 && height > 0 && width <= 1536 && height <= 1536, "{width}×{height}");
// A new range is a new thumbnail, never the old page.
h.state_mut().combine_draft[0].range = "2".into();
assert!(settle(&mut h, |app| matches!(app.combine_thumbnails()[0], CombineThumb::Ready { page: 1, .. })));
@@ -541,7 +542,8 @@ fn long_thin_pages_get_thumbnails_within_the_size_limits() {
assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(|t| *t != CombineThumb::None && *t != CombineThumb::Pending)));
for t in h.state().combine_thumbnails() {
match t {
CombineThumb::Ready { width, height, .. } => assert!(width <= 768 && height <= 768, "{width}×{height}"),
// Within MAX_SIDE (combine_grid), which is not visible from here.
CombineThumb::Ready { width, height, .. } => assert!(width <= 1536 && height <= 1536, "{width}×{height}"),
other => panic!("each strip gets a thumbnail: {other:?}"),
}
}
@@ -728,15 +730,15 @@ fn the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_setting
assert_eq!(h.state().combine_zoom, 1.25);
let after = card(&h, "a.pdf");
assert!(after.width() > before.width() && after.height() > before.height(), "{before:?} → {after:?}");
// Larger until it can't go further: 200%, and the button says so.
for _ in 0..10 {
// Larger until it can't go further: 500% (#2531), and the button says so.
for _ in 0..12 {
if disabled(&h, "Larger pages") {
break;
}
h.get_by_label("Larger pages").click();
h.run_steps(2);
}
assert_eq!(h.state().combine_zoom, 2.0);
assert_eq!(h.state().combine_zoom, 5.0);
assert!(disabled(&h, "Larger pages"));
// The percentage resets it.
h.get_by_label("Reset page size").click();
@@ -758,17 +760,22 @@ fn the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_setting
fresh.restore(&saved);
assert_eq!(fresh.combine_zoom, 0.6);
fresh.restore(r#"{"combine_zoom": 99}"#);
assert_eq!(fresh.combine_zoom, 2.0);
assert_eq!(fresh.combine_zoom, 5.0);
fresh.restore(r#"{"combine_zoom": "big"}"#);
assert_eq!(fresh.combine_zoom, 2.0);
assert_eq!(fresh.combine_zoom, 5.0);
// Scriptable in percent, with a clear refusal outside the range.
let app = h.state_mut();
app.set_option("combine-zoom", "150%").unwrap();
assert_eq!(app.combine_zoom, 1.5);
for bad in ["59", "201", "big", "NaN", "inf", ""] {
// #2531: 200% was not enough to read a value off a card; 500% has to be reachable.
for pct in ["200", "300", "400", "500"] {
app.set_option("combine-zoom", pct).unwrap();
assert_eq!(app.combine_zoom, pct.parse::<f32>().unwrap() / 100.0);
}
for bad in ["59", "501", "big", "NaN", "inf", ""] {
assert!(app.set_option("combine-zoom", bad).is_err(), "{bad:?}");
}
assert_eq!(app.combine_zoom, 1.5);
assert_eq!(app.combine_zoom, 5.0, "a refused value leaves the size alone");
// The list has no card size.
h.get_by_label("List view").click();
h.run_steps(2);
@@ -780,7 +787,7 @@ fn the_slider_sizes_the_cards_from_smallest_to_largest() {
let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1)]);
let slider = h.get_by_label("Page size").rect();
drag(&mut h, slider.center(), slider.right_center() + vec2(40.0, 0.0));
assert_eq!(h.state().combine_zoom, 2.0);
assert_eq!(h.state().combine_zoom, 5.0);
let slider = h.get_by_label("Page size").rect();
drag(&mut h, slider.center(), slider.left_center() - vec2(40.0, 0.0));
assert_eq!(h.state().combine_zoom, 0.6);
@@ -883,7 +890,38 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla
}
#[test]
fn dragging_and_arrow_keys_work_at_the_smallest_and_largest_sizes() {
fn cards_at_the_top_zoom_render_a_sharper_thumbnail_within_the_texture_limit() {
// #2531: raising the ceiling is only worth something if the thumbnail gets sharper with it.
// The texture cap was 768px, which upscaled any card past 200%.
let mut h = grid_of_pages(&[("a.pdf", 1)]);
h.state_mut().set_option("combine-zoom", "100").unwrap();
assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[0])));
let CombineThumb::Ready { width: w0, height: h0, .. } = h.state().combine_thumbnails()[0] else { unreachable!() };
let at_100 = w0.max(h0);
h.state_mut().set_option("combine-zoom", "500").unwrap();
// The old thumbnail stands in until the sharper one arrives, so "ready" is not enough: wait
// for the size to actually change.
let mut redrawn = false;
for _ in 0..400 {
h.run_steps(1);
if matches!(h.state().combine_thumbnails()[0], CombineThumb::Ready { width, height, .. } if width.max(height) != at_100) {
redrawn = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let CombineThumb::Ready { width, height, .. } = h.state().combine_thumbnails()[0] else { unreachable!() };
let at_500 = width.max(height);
assert!(redrawn, "500% never re-rendered the thumbnail: {width}×{height}");
assert!(at_500 <= 1536, "past the texture limit: {width}×{height}");
assert!(at_500 >= at_100 * 4, "500% is not sharper than 100%: {at_100}px → {at_500}px");
}
// 60% against a large size, rather than the largest: at 500% a card is wider than this viewport,
// so only one fits across and "down moves a row" would have nothing to check.
#[test]
fn dragging_and_arrow_keys_work_at_the_smallest_and_a_large_size() {
let mut columns = Vec::new();
for zoom in ["60", "200"] {
let files: Vec<(String, Vec<u8>)> = (0..24).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect();
+63
View File
@@ -97,6 +97,69 @@ fn closing_either_half_of_a_side_by_side_comparison_keeps_the_app_running() {
}
}
/// Two versions whose differences are of both kinds: a replaced word (text) and a rectangle
/// drawn far below the text (visual only, no text change explains it).
fn text_and_visual_versions() -> (Vec<u8>, Vec<u8>) {
let mut s = Session::new();
let v1 = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
let text = s.create_from_text("t", "Delivery within three days. Returns accepted.").unwrap();
let id = s.open("v2.pdf", None, text, None).unwrap();
let square = pdfcraft_engine::Shape::Rectangle { rect: [100.0, 80.0, 300.0, 200.0] };
let style = pdfcraft_engine::Style::default_for(&square);
let new = pdfcraft_engine::NewAnnotation { page: 0, shape: square, style, contents: String::new(), author: "Ada".into() };
s.apply(id, pdfcraft_engine::Edit::AddAnnotation(new)).unwrap();
(v1, s.save_bytes(id).unwrap().to_vec())
}
/// The Compare panel's Show: Text / Visual filter, on both halves: Text takes the text changes
/// out of the list and the shading, Visual takes the visual differences (counted, never listed)
/// out of the shading, and each brings them back.
#[test]
fn compare_filters_hide_text_or_visual_changes() {
let (v1, v2) = text_and_visual_versions();
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("v1.pdf", None, v1.clone()).unwrap();
app.open_bytes("v2.pdf", None, v2.clone()).unwrap();
app
});
h.run_steps(4);
h.state_mut().compare_old = h.state().views.first().map(|v| v.id);
h.state_mut().run_compare();
h.run_steps(3);
let new_i = h.state().active_ids().unwrap().0;
let old_i = h.state().views.iter().position(|v| v.id == h.state().compare.as_ref().unwrap().old).unwrap();
// Visual differences are shaded orange (compare_ui::VISUAL), text changes in their kind's colour.
let visual = egui::Color32::from_rgb(0xF0, 0x8C, 0x1A);
let kinds = |h: &Harness<'_, PdfCraftApp>, i: usize| {
let marks = &h.state().views[i].compare_marks;
(marks.iter().filter(|m| m.2 != visual).count(), marks.iter().filter(|m| m.2 == visual).count())
};
let (text_new, visual_new) = kinds(&h, new_i);
let (text_old, visual_old) = kinds(&h, old_i);
assert!(text_new > 0 && visual_new > 0, "the fixture has both kinds on the new file: {text_new} text, {visual_new} visual");
assert!(text_old > 0 && visual_old > 0, "and on the old file: {text_old} text, {visual_old} visual");
h.get_by_label("\"five\" → \"three\"");
h.get_by_label("Text").click();
h.run_steps(2);
assert!(h.query_by_label("\"five\" → \"three\"").is_none(), "text changes leave the list");
assert_eq!(kinds(&h, new_i), (0, visual_new), "and the new file's shading");
assert_eq!(kinds(&h, old_i), (0, visual_old), "and the old file's");
h.get_by_label("Visual").click();
h.run_steps(2);
assert_eq!(kinds(&h, new_i), (0, 0));
assert_eq!(kinds(&h, old_i), (0, 0));
h.get_by_label("Text").click();
h.run_steps(2);
h.get_by_label("\"five\" → \"three\"");
assert_eq!(kinds(&h, new_i), (text_new, 0), "text back, visual still hidden");
assert_eq!(kinds(&h, old_i), (text_old, 0));
}
/// Writes a screenshot of the Compare panel when PDFCRAFT_SHOT is set (for review).
#[test]
fn compare_panel_screenshot() {
+44
View File
@@ -1270,6 +1270,7 @@ fn measurement_tools_draw_live_calibrate_save_and_export() {
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
h.state_mut().views[0].measure.drawing_points = 10.0;
h.state_mut().views[0].measure.page_unit = Some(pdfcraft_engine::measure::PageUnit::Pt);
h.state_mut().views[0].measure.real_distance = 1.0;
h.state_mut().views[0].measure.unit = "m".into();
h.get_by_label("Apply scale").click();
@@ -1318,6 +1319,49 @@ fn measurement_tools_draw_live_calibrate_save_and_export() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_1_to_50_scale_in_millimetres_measures_a_10_mm_line_as_500_mm() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm". The page side of the scale was fixed in
// points, so this stored 1 pt = 50 mm, 72 / 25.4 = 2.835 times too big.
use pdfcraft_engine::measure::{self, Kind};
let (mut h, c) = harness_pages(1);
let doc = h.state().views[0].id;
h.state_mut().set_option("zoom", "100").unwrap();
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
let m = &mut h.state_mut().views[0].measure;
(m.drawing_points, m.real_distance, m.unit) = (1.0, 50.0, "mm".into());
h.get_by_label("Apply scale").click();
h.run_steps(3);
let mm = 72.0 / 25.4;
let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]];
let stored = |h: &Harness<'static, PdfCraftApp>| {
let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap();
(measure::reading(Kind::Distance, &line, &scale).unwrap().value, scale.ratio)
};
let (value, ratio) = stored(&h);
assert!((value - 500.0).abs() < 1e-9, "a 10 mm line at 1:50 reads {value} ({ratio})");
assert_eq!(ratio, "1 mm = 50 mm");
// The scale is one undoable edit.
ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"}));
h.run_steps(2);
assert!((stored(&h).0 - 500.0).abs() > 1.0);
ok(&mut h, &c, "ui.command", json!({"id":"edit.redo"}));
h.run_steps(2);
assert_eq!(stored(&h), (value, ratio));
// Measuring the line on the page with the Distance tool reads the same.
ok(&mut h, &c, "ui.command", json!({"id":"measure.distance"}));
for [x, y] in line {
let r = h.state().views[0].page_screen_rect(0).unwrap();
ok(&mut h, &c, "ui.click", json!({"x":r.left()+x as f32*r.width()/200.0,"y":r.top()+y as f32*r.height()/300.0}));
h.run_steps(2);
}
let measurements = h.state().session.get(doc).unwrap().measurements().unwrap().measurements;
assert_eq!(measurements.len(), 1);
assert!((measurements[0].reading.value - 500.0).abs() < 0.05, "{:?}", measurements[0].reading);
assert_eq!(measurements[0].reading.unit, "mm");
}
#[test]
fn docking_pointer_group_float_close_restore_and_document_commands() {
use pdfcraft_ui_egui::docking::Panel;
+15
View File
@@ -85,6 +85,21 @@ fn header_and_footer_dialog_draws_in_the_chosen_face() {
assert_eq!(face.name(b"BaseFont"), Some(&b"Times-Roman"[..]));
}
#[test]
fn header_and_footer_dialog_can_shrink_the_page_content() {
let mut h = harness();
assert!(h.state_mut().execute("edit.bates"));
h.run_steps(3);
assert!(!h.state().marks_draft.hf.shrink, "off by default, as in Acrobat");
h.get_by_label("Shrink the page content to make room for the header and footer").click();
h.run_steps(2);
assert!(h.state().marks_draft.hf.shrink);
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(h.state().dialog, None);
assert_eq!(marks(&h), [MarkKind::HeaderFooter]);
}
#[test]
fn watermark_and_background_dialogs() {
let mut h = harness();
+10 -5
View File
@@ -50,7 +50,7 @@ update the numbers.
| 18 | Print production tools absent | feature | 25–45 | [target-app-parity.md](target-app-parity.md) |
| 19 | Preferences mostly missing | UI/UX | 15–25 | [ui-parity.md](ui-parity.md) |
| 20 | Scanners, GPU page rendering, pen pressure, Read Out Loud | hardware | 25–50 | [hardware-parity.md](hardware-parity.md) |
| 21 | Compare: no side-by-side view or filters | feature | 10–20 | [target-app-parity.md](target-app-parity.md) |
| 21 | Compare: filters by Acrobat's finer kinds of change, page ranges, scanned documents | feature | 8–16 | [target-app-parity.md](target-app-parity.md) |
| 22 | PDF 2.0 extras, linearization, Arlington validation | spec | 20–35 | [pdf-spec-parity.md](pdf-spec-parity.md) |
| 23 | Local AI provider (summarize, ask, alt text, redaction suggestions) | AI | 20–40 | [target-app-parity.md](target-app-parity.md) |
| 24 | Checklist not diffed against Acrobat's own menus | measurement | 4–8 | [target-app-parity.md](target-app-parity.md) |
@@ -351,11 +351,15 @@ update the numbers.
the CPU and uploaded); pressure-sensitive ink; platform text-to-speech.
- **Estimate:** 25–50 h; scanners need hardware. **Doc:** [hardware-parity.md](hardware-parity.md).
### 21. Compare: no side-by-side view or filters
### 21. Compare: filters by Acrobat's finer kinds of change, page ranges, scanned documents
- **Missing:** synchronised side-by-side view, filters by change type, page ranges, scanned
documents.
- **Estimate:** 10–20 h. **Doc:** [target-app-parity.md](target-app-parity.md).
- **Done:** the synchronised side-by-side view; the Compare panel's Show: Text / Visual filter
(Text hides the text changes from the list and the shading, Visual hides the visual differences
from the shading; they are counted, not listed).
- **Missing:** telling images, annotations, formatting and background/header-footer changes apart
(all of them are Visual today), scrolling the two halves by matching content rather than by
offset, page ranges, scanned documents.
- **Estimate:** 8–16 h. **Doc:** [target-app-parity.md](target-app-parity.md).
### 22. PDF 2.0 extras, linearization, Arlington validation
@@ -384,6 +388,7 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. |
| 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h |
| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) |
+1 -1
View File
@@ -209,7 +209,7 @@ full parity in that area.
| K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT |
| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) |
| M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit |
| N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report). Missing: most Preferences pages, side-by-side compare, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app |
| N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report, side by side, Text/Visual filter). Missing: most Preferences pages, Acrobat's finer compare filters, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app |
| **All** | **808** | **423** | **74** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) |
Shipped features rest on thin evidence: 141 of 423 cite exactly one test, and only a handful cite
+15 -15
View File
@@ -2445,11 +2445,11 @@ title = "Shrink document to avoid overwriting content"
area = "C"
tier = "P2"
milestone = "M7"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/edit/src/tests.rs::shrink_scales_the_original_content_into_the_band_between_header_and_footer", "crates/edit/src/tests.rs::shrink_follows_the_display_space_of_a_rotated_page", "crates/edit/src/tests.rs::shrink_is_undone_by_remove_and_not_doubled_by_update", "crates/automation/tests/automation.rs::a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_can_shrink_the_page_content"]
notes = "#2140: the page's original content is scaled evenly into the band the header and footer leave free (margin, tallest box, half the font size of clearance), centred across, in display space; Remove and Update undo it. Not moved yet: annotations (links, comments, form fields) and PdfCraft's own added text and images; not compared side by side with Acrobat's exact scale"
[[feature]]
id = "edit.watermark-add-text"
@@ -3028,8 +3028,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.combine"]
tools = ["doc_combine"]
evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"]
notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file."
evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/src/combine_grid.rs::the_cards_zoom_far_enough_to_read_a_value", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"]
notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 500% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; the buttons step 60/80/100/125/150/175/200/250/300/400/500%, kept in the settings, --combine-zoom 60..500), keeping the card under the pointer in place. 200% used to be the ceiling, which was too small to read values off an invoice while reordering the pages of a statement (#2531). Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 1536 px (room for a Letter page at 500% on a 1.5 pixels-per-point screen); at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file."
[[feature]]
id = "organize.combine-options"
@@ -5632,8 +5632,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.search"]
tools = ["redact_mark"]
evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"]
notes = "phone (North American, E.164 with + country code, national with trunk 0), 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"
evidence = ["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/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; 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-pattern-locales"
@@ -5764,8 +5764,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed"]
notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; untouched tags stay"
evidence = ["crates/redact/src/tests.rs::tags_lose_what_redaction_removed", "crates/redact/src/tests.rs::applied_redactions_drop_structure_tree_references_to_what_they_remove"]
notes = "marked content changed by redaction is found by comparing each MCID before and after; its elements lose Alt, ActualText and E, and references to now-empty marked content are dropped; object references (OBJR) to the comments and form fields redaction removes are dropped too (#2874); untouched tags stay"
[[feature]]
id = "protect.redaction-verification"
@@ -9213,11 +9213,11 @@ title = "Filter changes by type (text, images, formatting)"
area = "N"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
status = "partial"
commands = ["doc.compare"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/compare.rs::compare_filters_hide_text_or_visual_changes"]
notes = "The Compare panel's Show: Text / Visual checkboxes (both on after Compare files) hide that kind of change on both halves of a side-by-side view and bring it back: Text takes the text changes out of the list and the shading, Visual takes the visual differences out of the shading (the list holds only text changes; visual differences are counted, not listed); hiding the text does not uncover the visual differences a text change explains. The counts, Mark as comments and Report… still cover every change. Still missing against Acrobat's Filter menu: telling images, annotations, formatting and background/header-footer changes apart (the comparison finds text changes and pixel differences only, so those all fall under Visual), and Acrobat's defaults with annotations, formatting and background off. Headless: doc_compare returns every change with its kind, so an agent filters the result itself."
[[feature]]
id = "misc.compare-page-ranges"