M12: retain product notes while integrating current main

This commit is contained in:
PerchAnton
2026-10-18 16:05:46 +03:00
50 changed files with 1829 additions and 178 deletions
+6 -5
View File
@@ -1,12 +1,12 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (PR #2624: integrated upstream d8b27058 while retaining this fix and upstream progress; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -165,12 +165,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-18 (PR #2624, upstream refresh):** Integrated main `d8b27058` while retaining this PR's fix, regression coverage and upstream progress. Estimates unchanged.
- **2026-10-15 (M12, #2569):** Editing growable multiline XFA text now lays out pages and widgets from the final datasets, also on forms without scripts. Undo/redo restores the layout; synthetic engine/tool tests cover 10,500 characters, save/reopen and print-ready PDF text. Text longer than a page uses the existing read-only page text fallback, with a warning. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M12, #2569):** Editing growable multiline XFA text now lays out pages and widgets from the final datasets, also on forms without scripts. Undo/redo restores the layout; synthetic engine/tool tests cover 10,500 characters, save/reopen and print-ready PDF text. Text longer than a page uses the existing read-only page text fallback, with a warning. 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-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
@@ -512,10 +512,11 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; 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 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
+9 -4
View File
@@ -918,11 +918,16 @@ impl Automation {
let list: Vec<Value> = found
.iter()
.map(|(p, c)| {
let kind = match c.kind {
pdfcraft_engine::detect::Kind::Text => "text",
pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
let (kind, comb) = match c.kind {
pdfcraft_engine::detect::Kind::Text => ("text", None),
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
};
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
if let Some(cells) = comb {
v["comb"] = json!(cells);
}
v
})
.collect();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
+1 -1
View File
@@ -138,7 +138,7 @@ impl Automation {
.added()
.iter()
.filter(|x| x.page == page)
.nth((index - 1).max(0) as usize)
.nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
.filter(|_| index >= 1)
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
Ok((page, index as usize - 1, it.content.clone(), ph))
+26
View File
@@ -283,6 +283,13 @@ impl Automation {
let to = self.position(&a, "to")?;
self.apply(&a, Edit::MovePages { pages, to })?
}
"page_reverse" => {
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(&a, "pages")?,
None => (0..self.doc(&a)?.info.pages.len()).collect(),
};
self.apply(&a, Edit::ReversePages { pages })?
}
"page_insert_blank" => self.insert_blank(&a)?,
"page_insert_file" => self.insert_file(&a)?,
"page_extract" => self.page_extract(&a)?,
@@ -3101,4 +3108,23 @@ mod tests {
std::fs::set_permissions(&target, perms).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A recovered document, or a new one, rotated and then undone still has never been saved:
/// `doc_close` refuses it unless told to discard it (#2346 review).
#[test]
fn closing_a_recovered_or_new_document_after_undo_is_refused() {
let mut a = Automation::new();
let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap();
a.session.mark_recovered(recovered, Some("Report.pdf".into()));
let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap();
for id in [recovered, new] {
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
json(&mut a, "edit_undo", json!({"doc": id.0}));
let refused = a.call("doc_close", &json!({"doc": id.0}));
assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}");
assert!(a.session.get(id).is_some(), "still open");
json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true}));
assert!(a.session.get(id).is_none());
}
}
}
+4 -1
View File
@@ -224,6 +224,9 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
&["doc", "pages", "to"],
)),
t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
.cmd("page.reverse")
.with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
.cmd("page.insert_blank")
.with(schema(
@@ -1098,7 +1101,7 @@ pub fn tools() -> Vec<ToolDef> {
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
.cmd("form.detect")
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
+68
View File
@@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() {
assert_ne!(v, "Filled by an agent");
}
#[test]
fn reversing_pages_through_tools() {
let dir = workdir("reverse");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_reverse", json!({ "doc": doc }));
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document");
ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] }));
assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]);
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
}
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
@@ -3582,6 +3597,24 @@ fn adding_content_through_tools() {
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
}
/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable
/// failure for `content_update` and `content_delete`, not a panic.
#[test]
fn content_index_out_of_range_is_an_error_not_a_panic() {
let dir = workdir("content-index-range");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] }));
for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] {
for tool in ["content_delete", "content_update"] {
let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" });
let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args };
assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}");
}
}
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
}
/// Text styling on an added image is refused (#1331), as image transforms are on text, and the
/// refusal leaves the content, the dirty flag and the undo and redo steps as they were.
#[test]
@@ -4959,6 +4992,41 @@ fn detecting_form_fields_through_tools() {
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
}
#[test]
fn detecting_comb_boxes_through_tools() {
let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let dir = workdir("detect-comb");
std::fs::write(dir.join("comb.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } }));
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001");
}
#[test]
fn comparing_documents_through_tools() {
let dir = workdir("compare");
+162 -1
View File
@@ -337,6 +337,40 @@ impl PartialEq for SourceIdentity {
impl Eq for SourceIdentity {}
/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed
/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every
/// change makes a different one. Changed objects are compared as allocations, which the state
/// keeps alive, so a later object can't take an address one of them had.
#[derive(Clone)]
pub struct ReadState {
source: SourceIdentity,
trailer: Dict,
overlay: BTreeMap<u32, Slot>,
}
impl ReadState {
/// Whether `doc` reads as this state.
pub fn matches(&self, doc: &Document) -> bool {
self.source == doc.source_identity()
&& self.trailer == doc.trailer
&& self.overlay.len() == doc.overlay.len()
&& self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| {
a == b
&& match (x, y) {
(Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
(Slot::Freed(g), Slot::Freed(h)) => g == h,
_ => false,
}
})
}
}
impl std::fmt::Debug for ReadState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct Document {
data: Arc<Vec<u8>>,
@@ -801,6 +835,12 @@ impl Document {
SourceIdentity(Arc::downgrade(&self.source_identity))
}
/// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it
/// changes with every edit, so a value derived from the document can be cached against it.
pub fn read_state(&self) -> ReadState {
ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() }
}
pub fn version(&self) -> &str {
&self.version
}
@@ -1113,7 +1153,19 @@ impl Document {
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(s.clone());
}
let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
// A file entry that cannot place the stream (one that says it is itself compressed
// inside another stream, as in #2367, or points nowhere) is tried by the stream's own
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
// overlay or in a saved revision's table, is what that says it is: the file's copy
// must not come back.
let loaded = match self.try_get(num) {
Ok(o) if matches!(*o, Object::Stream(_)) => o,
_ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
return Err(CosError::MissingObject(num));
}
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
};
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
let data = s.decoded_within(self.stream_limit)?;
@@ -1935,6 +1987,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607;
mod tests {
use super::*;
#[test]
fn a_read_state_matches_a_clone_and_no_changed_document() {
let mut doc = Document::new_empty();
let a = doc.add(Object::Int(1));
let state = doc.read_state();
let snapshot = doc.clone();
assert!(state.matches(&doc) && state.matches(&snapshot));
// The same value set again is still a change: another allocation.
doc.set(a, Object::Int(1));
assert!(!state.matches(&doc) && state.matches(&snapshot));
let edited = doc.read_state();
doc.free(a);
assert!(!edited.matches(&doc));
let mut trailer_changed = snapshot.clone();
trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null);
assert!(!state.matches(&trailer_changed));
// Another document's state never matches, even with the same changes.
let mut other = Document::new_empty();
other.add(Object::Int(1));
assert!(!other.read_state().matches(&snapshot));
}
/// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
@@ -2535,6 +2609,93 @@ mod tests {
assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
/// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream
/// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99.
fn compressed_in_compressed_fixture() -> Document {
let mut out = b"%PDF-1.5\n".to_vec();
let at = |out: &mut Vec<u8>, num: u32, body: &str| {
let off = out.len();
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
off as u16
};
let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>");
let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>");
let inner = "<< /Type /Outlines /Count 0 >>";
at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len()));
// /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3.
let rows: Vec<[u8; 4]> = vec![
[0, 0, 0, 255],
[1, 0, o1 as u8, 0],
[1, 0, o2 as u8, 0],
[2, 0, 99, 0],
[2, 0, 3, 0],
[1, (out.len() >> 8) as u8, out.len() as u8, 0],
];
let rows: Vec<u8> = rows.concat();
let xref = out.len();
out.extend_from_slice(
format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// An object stream whose own cross-reference entry says it is compressed inside another
/// stream (which the format forbids, and which does not exist here: qpdf's
/// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it
/// read as null and a full save wrote `/Outlines null`. The stream is now found by its own
/// header, as a plain object with a wrong offset is.
#[test]
fn an_object_stream_marked_as_compressed_is_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log());
let outlines = doc.get(ObjRef::new(4, 0));
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}");
let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap());
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}");
}
/// The header fallback is for file entries only: once the stream is freed (or replaced) in
/// the overlay, its objects must not come back from the file's copy of it.
#[test]
fn a_freed_object_stream_is_not_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some());
let mut edited = doc.clone();
edited.free(ObjRef::new(3, 0));
let fresh = edited.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
let mut replaced = doc.clone();
replaced.set(ObjRef::new(3, 0), Object::Int(0));
let fresh = replaced.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
}
/// The same once the free is saved: the revision's table says the stream is free, and
/// neither reading the file back nor rewriting it in full brings its objects back.
#[test]
fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() {
let mut edited = compressed_in_compressed_fixture();
edited.free(ObjRef::new(3, 0));
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(incremental)).unwrap();
assert_eq!(reopened.revisions().len(), 2);
assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. })));
assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0)));
for object_streams in [true, false] {
let opts = crate::SaveOptions { object_streams, ..Default::default() };
let full = crate::write_full(&reopened, &opts).unwrap();
let back = Document::open(Arc::new(full)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o));
assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}");
}
}
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+1 -1
View File
@@ -22,7 +22,7 @@ mod security;
mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
+2 -1
View File
@@ -5,6 +5,7 @@
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
mod extract;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
use pdfcraft_fonts::{literal, win_ansi, wrap};
@@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, bytes)?])
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
+33
View File
@@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
}
}
}
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees
/// clockwise).
pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap());
// Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt.
let m0 = media(&pages(&doc)[0]);
assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}");
let img = image_of(&doc, 0);
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60)));
// "As stored" stays as it is.
let m1 = media(&pages(&doc)[1]);
assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}");
let img = image_of(&doc, 1);
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
}
@@ -1,6 +1,6 @@
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
//! (stamps, added images, replacements) go through here first.
//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this
//! crate (pages made from images, stamps, added images, replacements) goes through here first.
use std::borrow::Cow;
use std::io::Cursor;
@@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edit, MarkFile};
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
/// degrees clockwise).
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
use crate::tests::phone_jpeg;
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
@@ -111,23 +94,4 @@ mod tests {
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
}
+12 -3
View File
@@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
clips.insert((stream, at), c);
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert((stream, i), (m, false)),
None => clips.insert((stream, at), (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
@@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if let Some(c) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
if let Some((c, keep)) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
+25
View File
@@ -1921,6 +1921,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() {
}
}
#[test]
fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() {
// The crop is smaller than the image, so a clip left behind would shrink `visible_rect`.
let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET");
let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len();
let start = images::page_images(&doc, 0).unwrap()[0].clone();
let mut first = 0;
for i in 0..20 {
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
if i == 0 {
first = operators(&doc);
}
}
let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0];
let now = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect);
assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect);
assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one");
assert_eq!(q_depth(&doc), 1);
let doc = reopen(&doc);
assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap())));
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(caption, ["Caption"]);
}
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
+1
View File
@@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Crop pages", "crop", "page.crop", Ready),
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
item("Page labels", "tag", "page.number", Ready),
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
],
+1
View File
@@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
+18 -13
View File
@@ -6,7 +6,7 @@
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -343,19 +343,24 @@ impl Session {
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
let mut edits = Vec::with_capacity(found.len());
for (page, c, turn) in found {
// A turned field keeps its place: the rectangle is already the one on the page.
let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() });
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
edits.push(Edit::AddField {
page,
rect: c.rect,
kind: match c.kind {
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 },
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
},
name: Some(c.name.clone()),
});
if let Some(props) = props {
let (kind, comb) = match c.kind {
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
};
let mut props = FieldProps::default();
if turn != 0 {
// A turned field keeps its place: the rectangle is already the one on the page.
props.rotation = Some((0, turn));
props.rect = Some((0, c.rect));
}
if let Some(cells) = comb {
props.max_len = Some(Some(cells));
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
}
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
if turn != 0 || comb.is_some() {
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
}
+61 -19
View File
@@ -36,7 +36,6 @@ pub mod signature_image;
pub mod signature_scan;
pub mod text_fonts;
mod text_preview;
mod upright_image;
pub mod word_count;
pub mod xfa;
@@ -306,6 +305,10 @@ struct Editor {
saved: Arc<Vec<u8>>,
/// Passwords that open [`Self::saved`].
saved_keys: Keys,
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
/// undo brings back, or closing it would lose the whole document without asking.
unsaved_file: bool,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
keys: Keys,
@@ -317,15 +320,15 @@ struct Editor {
text_blocks: TextBlocksCache,
}
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
/// undo and save (each reopens the working file), so a stale entry never matches.
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
/// with undo, so a stale entry never matches, also after the document has been edited.
#[derive(Default)]
struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
/// The document state, the page (0-based) and its text blocks.
type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
impl Clone for TextBlocksCache {
fn clone(&self) -> Self {
@@ -337,20 +340,16 @@ impl Clone for TextBlocksCache {
impl Editor {
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
if let Some(source) = &source
&& let Ok(cache) = self.text_blocks.0.lock()
&& let Some((s, p, blocks)) = cache.as_ref()
&& s == source
if let Ok(cache) = self.text_blocks.0.lock()
&& let Some((state, p, blocks)) = cache.as_ref()
&& *p == page
&& state.matches(&self.cos)
{
return Ok(blocks.clone());
}
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
if let Some(source) = source
&& let Ok(mut cache) = self.text_blocks.0.lock()
{
*cache = Some((source, page, blocks.clone()));
if let Ok(mut cache) = self.text_blocks.0.lock() {
*cache = Some((self.cos.read_state(), page, blocks.clone()));
}
Ok(blocks)
}
@@ -984,6 +983,27 @@ fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
/// is left alone.
fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
use pdfcraft_cos::{Dict, ObjRef, Object};
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
let refs =
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
config.set(b"BaseState".to_vec(), Object::name("ON"));
config.set(b"ON".to_vec(), refs(true));
config.set(b"OFF".to_vec(), refs(false));
properties.set(b"D".to_vec(), Object::Dict(config));
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
cos.set(root_ref, Object::Dict(root));
}
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
@@ -1247,6 +1267,10 @@ pub enum Edit {
pages: Vec<usize>,
to: usize,
},
/// `pages` reversed among the positions they occupy.
ReversePages {
pages: Vec<usize>,
},
InsertBlankPage {
at: usize,
width: f64,
@@ -1777,6 +1801,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete pages",
"Move page",
"Move pages",
"Reverse pages",
"Insert blank page",
"Duplicate page",
"Duplicate pages",
@@ -1916,6 +1941,7 @@ impl Edit {
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
Edit::ReversePages { .. } => Label::of("Reverse pages"),
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
@@ -2088,6 +2114,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
Edit::RotatePages { .. }
| Edit::DeletePages { .. }
| Edit::MovePages { .. }
| Edit::ReversePages { .. }
| Edit::InsertBlankPage { .. }
| Edit::DuplicatePages { .. }
| Edit::ReplacePages { .. }
@@ -2298,6 +2325,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
Edit::InsertBlankPage { at, width, height } => {
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
@@ -3526,6 +3554,7 @@ impl Session {
cos,
saved,
saved_keys: keys.clone(),
unsaved_file: false,
undo: Vec::new(),
redo: Vec::new(),
keys,
@@ -3835,7 +3864,7 @@ impl Session {
/// file is clean; undo past a later save is not, because the restored snapshot's own base
/// is an earlier revision.
fn sync_dirty(doc: &mut Document) {
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
}
/// Use the current state's password for viewing (protection edits change it).
@@ -4120,6 +4149,7 @@ impl Session {
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
editor.saved = bytes;
editor.saved_keys = keys;
editor.unsaved_file = false;
}
if let Some(p) = path {
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
@@ -4183,7 +4213,7 @@ impl Session {
editor.undo.clear();
editor.redo.clear();
Self::adopt_keys(doc);
doc.dirty = false;
Self::sync_dirty(doc);
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
@@ -4344,12 +4374,18 @@ impl Session {
};
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
}
let cos = match doc.editor.as_ref() {
let mut cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
// action) has changed one, the print copy's default configuration says which are on, so a
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
if !doc.config.layers.is_empty() {
set_default_layer_state(&mut cos, &doc.info.layers);
}
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
@@ -4657,6 +4693,9 @@ impl Session {
let id = self.open(name, None, bytes, None)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.dirty = true;
if let Some(e) = d.editor.as_mut() {
e.unsaved_file = true;
}
// Unsaved work with no edit yet still needs its first recovery snapshot.
d.generation += 1;
}
@@ -4709,6 +4748,9 @@ impl Session {
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.path = path;
d.dirty = true;
if let Some(e) = d.editor.as_mut() {
e.unsaved_file = true;
}
d.generation += 1;
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
}
@@ -4918,7 +4960,7 @@ fn embed_image(
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
pdfcraft_create::image_xobject(doc, name, bytes)
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
+166
View File
@@ -1987,6 +1987,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() {
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the
/// layers untouched, the document's own configuration prints.
#[test]
fn printing_keeps_hidden_layers_hidden() {
let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 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 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
// Red and green pixels on the first printed sheet.
let printed = |s: &Session, id: DocId| {
let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
let mut out = Session::new();
let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
let page = render_page(&out, printed);
let px = page.rgba.as_chunks::<4>().0;
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
(red, green)
};
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
let (red, green) = printed(&s, id);
assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
assert!(s.set_layer_visible(id, a, false));
let (red, green) = printed(&s, id);
assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
assert!(green > 100, "Layer B still prints: {green} green");
assert!(s.set_layer_visible(id, a, true));
let (red, _) = printed(&s, id);
assert!(red > 100, "shown again, Layer A prints again: {red} red");
}
#[test]
fn printing_lays_out_sheets_that_render() {
let (s, id) = session_with(5);
@@ -3638,6 +3691,34 @@ fn detecting_fields_on_a_scanned_form() {
}
}
#[test]
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
}
for i in 0..10 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
assert_eq!(
found,
[
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
]
);
s.auto_detect_fields(id, &[]).unwrap();
let d = s.get(id).unwrap();
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
assert_eq!(d.can_undo(), Some("Detect form fields"));
}
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -5866,3 +5947,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() {
// No area, or a box off the image, is refused.
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
}
/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
fn phone_jpeg() -> Vec<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
/// still read once per document state, and undo and redo never get another state's blocks.
#[test]
fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
let (mut session, id) = session_with(1);
let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
let before = blocks(&session);
assert_eq!(before[0].editor_text, "Page 1");
let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
session.apply(id, edit).unwrap();
let edited = blocks(&session);
assert_eq!(edited[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
session.undo(id).unwrap();
assert_eq!(blocks(&session)[0].editor_text, "Page 1");
session.redo(id).unwrap();
let redone = blocks(&session);
assert_eq!(redone[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&redone, &blocks(&session)));
}
/// A new document, or one restored from recovery, has never been written where it belongs: undo
/// back to the bytes it was opened from must not make it clean, or closing it would discard the
/// whole document without asking. Saving makes it clean; Revert of a saved one too.
#[test]
fn undo_never_makes_an_unsaved_document_clean() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
s.revert(new).unwrap();
assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
s.mark_recovered(recovered, Some("Report.pdf".into()));
s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(recovered).unwrap();
assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
let saved = s.save_bytes(new).unwrap();
s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
assert!(!s.get(new).unwrap().dirty);
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
+74 -2
View File
@@ -1,8 +1,8 @@
//! What content editing and redaction need from a PDF font: how to split a string into character
//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means
//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact
//! published metrics ([`crate::Std14`]).
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded
//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]).
//!
//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph
//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own
@@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
}
}
/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded
/// program's own advances, so measure with them too; a standard-14 guess from the name can be
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
use skrifa::instance::{LocationRef, Size};
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem == 0.0 {
continue;
}
let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default());
let (charmap, cmap) = (face.charmap(), face.cmap().ok());
let found = |g: Option<GlyphId>| g.filter(|g| *g != GlyphId::NOTDEF);
m.widths = (0..=255u32)
.map(|code| {
let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c)))
.or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o))));
// A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest.
match glyph {
None if m.subset => 0.0,
_ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem),
}
})
.collect();
m.first = 0;
return;
}
}
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
@@ -607,6 +645,9 @@ impl Metrics {
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
};
m.unicode = unicode_map(doc, font, m.composite);
if m.std14.is_some() {
embedded_widths(doc, font, &mut m);
}
let mut reverse = HashMap::new();
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
if let Some(ch) = only_char(text)
@@ -1218,6 +1259,37 @@ mod tests {
assert!(embedded_sfnt(&doc, &Dict::new()).is_none());
}
/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name
/// ("Mono" would mean Courier, 600 units for every glyph).
#[test]
fn a_font_without_widths_measures_with_its_embedded_program() {
use pdfcraft_cos::Stream;
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let upem = f64::from(face.metrics(size, location).units_per_em);
let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem;
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c));
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
}
#[test]
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
+82 -7
View File
@@ -1,6 +1,6 @@
//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new
//! field from the label next to it (Acrobat's auto-naming).
//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
//! and name each new field from the label next to it (Acrobat's auto-naming).
//!
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
@@ -30,6 +30,11 @@ pub struct Shapes {
pub enum Kind {
Text,
CheckBox,
/// A row of touching cells, one character each: a text field with the comb flag and
/// `cells` as its character limit.
Comb {
cells: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -40,6 +45,10 @@ pub struct Candidate {
pub name: String,
}
/// The most points a subpath may have and still be read as a rectangle: its four corners plus
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
const MAX_RECT_POINTS: usize = 16;
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
@@ -65,12 +74,33 @@ impl DetectionPath {
return None;
}
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut points = self.points.as_slice();
let mut points = self.points.clone();
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
if repeated_end {
points = &points[..points.len() - 1];
points.pop();
}
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
if !(self.closed || implicitly_closed || repeated_end) {
return None;
}
// A point on a straight side, between its neighbours, doesn't change the shape: drop it,
// so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
// point that turns back along the side is not between its neighbours and stays, so a
// path that doubles back is still not a rectangle.
let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
let mut i = 0;
while points.len() > 4 && i < points.len() {
let n = points.len();
let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
if on_row || on_column {
points.remove(i);
i = 0;
} else {
i += 1;
}
}
if points.len() != 4 {
return None;
}
let mut horizontal = [false; 4];
@@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let (bx, by) = ctm.apply(x, y);
let b = [bx, by];
if !path.complex {
if path.points.len() >= 5 {
if path.points.len() > MAX_RECT_POINTS {
path.complex = true;
} else {
if path.points.is_empty() {
@@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
}
/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
/// enough: dates split into day and month groups of two cells. Cells are square or taller
/// than wide; an empty table of wider cells is not a comb.
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
let cells: Vec<[f64; 4]> = boxes
.iter()
.copied()
.filter(|r| {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
})
.collect();
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
let follows = |a: &[f64; 4], b: &[f64; 4]| {
(b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
};
let mut out = Vec::new();
for first in &cells {
if cells.iter().any(|a| follows(a, first)) {
continue;
}
let (mut last, mut n) = (first, 1usize);
// Each step moves at least 4 pt right, so this ends; the bound is for safety.
while let Some(b) = cells.iter().find(|b| follows(last, b))
&& n < cells.len()
{
last = b;
n += 1;
}
if n >= 2 {
out.push(([first[0], first[1], last[2], first[3]], n));
}
}
out
}
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
/// overlaps them); names are unique against `taken`.
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec<Candidate> {
@@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
// Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
// check boxes so the cells don't each become one.
for (rect, cells) in combs(&shapes.boxes) {
if labels.iter().any(|x| overlaps(x.rect, rect)) {
continue;
}
let l = label_for(&labels, rect, false);
push(Kind::Comb { cells }, rect, l, &mut out);
}
// Small squares: check boxes, labelled by the text to their right.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
@@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
for c in &mut out {
let base = if c.name.is_empty() {
match c.kind {
Kind::Text => "Text".to_string(),
Kind::Text | Kind::Comb { .. } => "Text".to_string(),
Kind::CheckBox => "Check Box".to_string(),
}
} else {
+71
View File
@@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() {
assert!(again.is_empty(), "{again:?}");
}
#[test]
fn detection_makes_a_row_of_touching_cells_one_comb_field() {
use crate::detect::*;
let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
let words = vec![
w("Postcode", 40.0, 205.0, 85.0),
w("A", 88.0, 205.0, 95.0),
w("Postcode", 40.0, 105.0, 85.0),
w("B", 88.0, 105.0, 95.0),
w("Yes", 70.0, 41.0, 90.0),
];
let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]);
// 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn
// right to left, and a lone square.
let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect();
boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev());
boxes.push([50.0, 40.0, 62.0, 52.0]);
let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]);
let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect();
assert_eq!(
got,
[
("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]),
("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]),
("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]),
],
"{found:?}"
);
// Squares with a gap between them stay check boxes, and so do touching squares of
// different sizes.
let apart = Shapes {
boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]],
rules: Vec::new(),
};
let kinds: Vec<Kind> = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect();
assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]);
// An empty table of cells wider than tall (an attendance sheet) is not a comb.
let grid = |cw: f64, ch: f64| {
(0..3).flat_map(move |r| {
(0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch])
})
};
for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] {
let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]);
assert!(table.is_empty(), "{cw} x {ch}: {table:?}");
}
}
#[test]
fn page_shapes_reads_boxes_and_rules() {
let mut doc = fixture();
@@ -2023,6 +2071,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
}
}
/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's
/// letter cells), or on every side, is the rectangle it looks like. A point that turns back
/// along a side is not on the way between its neighbours, so such a path is still not a box.
#[test]
fn page_shapes_reads_rectangles_with_points_along_their_sides() {
for content in [
"100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f",
"100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S",
"100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f",
] {
let shapes = detection_test_shapes(content.as_bytes());
assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}");
}
for content in [
// Out past the corner and back: a spike, not a side.
"100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S",
// A sixth point off the sides.
"100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S",
] {
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
}
}
#[test]
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
for content in [
+25 -2
View File
@@ -215,7 +215,10 @@ fn num(v: f64) -> String {
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they
// would stay set for every later content stream on the page, so text added after OCR (a
// header or footer) drew invisibly (#3029).
let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
for w in words {
let width = helvetica_width(&w.text, 1.0);
let height = w.up[0].hypot(w.up[1]);
@@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
out.extend_from_slice(b"ET\nEMC\n");
out.extend_from_slice(b"ET\nQ\nEMC\n");
out
}
@@ -272,6 +275,26 @@ mod tests {
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
}
/// #3029: the layer's invisible rendering mode and font end with the layer. Every operator
/// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn
/// after it on the page starts from the graphics state it had before.
#[test]
fn the_layer_restores_the_graphics_state_it_changes() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
let mut depth = 0_i32;
for token in s.split_ascii_whitespace() {
match token {
"q" => depth += 1,
"Q" => depth -= 1,
"Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"),
_ => {}
}
assert!(depth >= 0, "unbalanced Q in {s:?}");
}
assert_eq!(depth, 0, "the layer leaves a q open: {s:?}");
}
/// A word box is tight around the ink, so the text's size and baseline come from the letters:
/// capitals, lowercase without ascenders, descenders.
#[test]
+20
View File
@@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<()
rebuild(doc, &rest)
}
/// Reverse the order of the pages at `indices` among the positions they occupy; every other
/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change.
pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
let mut all = walk(doc)?;
check(indices, all.len())?;
let mut slots = indices.to_vec();
slots.sort_unstable();
slots.dedup();
if slots.len() < 2 {
return Err(OrganizeError::Invalid("reversing needs at least two pages".into()));
}
let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect();
for (slot, page) in slots.iter().zip(pages.into_iter().rev()) {
if let Some(entry) = all.get_mut(*slot) {
*entry = page;
}
}
rebuild(doc, &all)
}
/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
/// fonts and images with the originals.
pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+19
View File
@@ -244,6 +244,25 @@ fn move_pages_reorders() {
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
}
#[test]
fn reverse_pages_reorders() {
let mut doc = open(fixture());
reverse_pages(&mut doc, &[0, 1, 2]).unwrap();
assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);
// Unsorted, repeated indices name the same slots; page 1's slot stays put.
reverse_pages(&mut doc, &[2, 1, 2]).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
}
#[test]
fn cannot_reverse_missing_or_fewer_than_two_pages() {
let mut doc = open(fixture());
assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3)));
assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_))));
assert!(!doc.is_modified(), "failed operations leave the document untouched");
}
#[test]
fn inserts_blank_page() {
let mut doc = open(fixture());
+1 -1
View File
@@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a
- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a
widget under a mark;
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
page as a `/PCMark /Redaction` stream.
+26
View File
@@ -455,6 +455,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
}
}
}
// Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to
// those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints
// of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at
// least one more annotation of this page or stops, so it ends.
let replies: Vec<(ObjRef, ObjRef)> = annots
.iter()
.filter_map(Object::as_ref)
.filter_map(|r| {
let obj = doc.get(r);
let d = obj.as_dict()?;
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
})
.collect();
loop {
let before = removed.len();
for (r, parent) in &replies {
if !removed.contains(r) && removed.contains(parent) {
removed.push(*r);
report.annotations += 1;
}
}
if removed.len() == before {
break;
}
}
let kept: Vec<Object> = annots
.into_iter()
.filter(|a| {
+77
View File
@@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
assert!(!content(&doc, 0).contains("1234"));
}
/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
/// lie past a mark that covers all of it on screen, and was kept.
#[test]
fn a_whole_line_under_a_mark_goes_even_without_widths() {
let text = b"illicit little lilies";
let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>";
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()),
font.as_bytes().to_vec(),
b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(),
stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR),
]);
// Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126.
let line = [5.0, 95.0, 90.0, 112.0];
mark(&mut doc, 0, &[line], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.glyphs, text.len(), "every glyph of the line goes");
assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0));
}
#[test]
fn added_text_parameters_do_not_keep_redacted_text() {
// An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded.
@@ -490,6 +514,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared");
}
#[test]
fn replies_to_a_redacted_comment_go_with_it() {
// #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of
// its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file.
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
let p = pdfcraft_model::pages(&doc).swap_remove(0);
assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays");
// A classic table, so the objects' strings are plain in the file.
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] {
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone));
}
assert!(bytes.windows(9).any(|w| w == b"UNRELATED"));
}
/// The replies listed before the annotations they answer, deepest first, and a pop-up on a
/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of
/// #2697).
#[test]
fn replies_listed_before_their_parent_and_their_pop_ups_go_too() {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
let p = pdfcraft_model::pages(&doc).swap_remove(0);
let left = annots_of(&doc, &p.dict);
assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too");
}
#[test]
fn comments_links_and_fields_under_a_mark_go() {
let mut doc = one_page(b"", "", vec![]);
+142 -14
View File
@@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu
dense
}
/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs
/// belongs to the span.
/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs
/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062).
/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Bidi {
L,
R,
Num,
N,
}
fn bidi_class(text: &str) -> Bidi {
if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) {
Bidi::R
} else if text.chars().any(char::is_numeric) {
Bidi::Num
} else if text.chars().any(char::is_alphabetic) {
Bidi::L
} else {
Bidi::N
}
}
/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the
/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has
/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals
/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones.
fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) {
let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl);
// Glyphs and word gaps (None) in visual order.
let mut items: Vec<(Option<usize>, Bidi)> = Vec::with_capacity(idx.len() * 2);
for (w, &i) in idx.iter().enumerate() {
if w > 0 && spaces.get(w).copied().unwrap_or(false) {
items.push((None, Bidi::N));
}
items.push((Some(i), bidi_class(&glyphs[i].text)));
}
let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect();
let text = |k: usize| texts.get(k).copied().unwrap_or("");
let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count();
let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L };
let n = items.len();
// W4: a single separator between two digits belongs to the number.
for k in 1..n.saturating_sub(1) {
let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}");
if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num {
items[k].1 = Bidi::Num;
}
}
// W5: terminators (percent, currency) next to a number belong to it.
let term =
|s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}'));
for k in 1..n {
if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) {
items[k].1 = Bidi::Num;
}
}
for k in (0..n.saturating_sub(1)).rev() {
if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) {
items[k].1 = Bidi::Num;
}
}
// W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right.
let mut strong = para;
for it in &mut items {
match it.1 {
Bidi::L | Bidi::R => strong = it.1,
Bidi::Num if strong == Bidi::L => it.1 = Bidi::L,
_ => {}
}
}
// N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's.
let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R };
let mut k = 0;
while k < idx.len() {
if rtl(idx[k]) {
while k < n {
if items[k].1 != Bidi::N {
k += 1;
continue;
}
let mut e = k;
while e < n && items[e].1 == Bidi::N {
e += 1;
}
let before = if k == 0 { para } else { dir(items[k - 1].1) };
let after = if e == n { para } else { dir(items[e].1) };
let resolved = if before == after { before } else { para };
for it in &mut items[k..e] {
it.1 = resolved;
}
k = e;
}
// Levels (I1/I2), then L2: reverse every run at or above each level, highest first.
let rtl_para = para == Bidi::R;
let levels: Vec<u8> = items
.iter()
.map(|it| match it.1 {
Bidi::R => 1,
Bidi::Num => 2,
_ if rtl_para => 2,
_ => 0,
})
.collect();
let mut order: Vec<usize> = (0..n).collect();
for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() {
let mut k = 0;
while k < n {
if levels[order[k]] < level {
k += 1;
continue;
}
let mut e = k;
while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) {
while e < n && levels[order[e]] >= level {
e += 1;
}
idx[k..=e].reverse();
if let Some(gaps) = spaces.get_mut(k + 1..=e) {
gaps.reverse();
order[k..e].reverse();
k = e;
}
}
let mut j = 0;
let mut gap = false;
for &o in &order {
match items[o].0 {
None => gap = true,
Some(i) => {
if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) {
*slot = i;
*space = gap && j > 0;
}
gap = false;
j += 1;
}
k = e + 1;
} else {
k += 1;
}
}
}
@@ -2092,6 +2200,26 @@ mod tests {
assert_eq!(layout(v).plain_text(), "שלום עולם");
}
/// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words.
#[test]
fn rtl_line_with_number_and_punctuation_reads_in_logical_order() {
let line = |words: &[&str]| {
let mut v = Vec::new();
let mut x = 10.0;
for w in words {
word(&mut v, w, x, 10.0, 6.0);
x += w.chars().count() as f32 * 6.0 + 6.0;
}
layout(v).plain_text()
};
// Each line as drawn, left to right.
assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים");
assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה.");
assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب");
assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם");
assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price");
}
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
#[test]
fn words_and_lines_around_a_glyph() {
+1 -1
View File
@@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
+123 -2
View File
@@ -423,6 +423,8 @@ pub struct DocView {
/// This frame's consumers, with visible cells ahead of prefetch.
thumb_demand: HashMap<usize, bool>,
frame_queue: Vec<RenderRequest>,
/// A page-list edit after layout invalidates this frame's scales and page indexes.
frame_geometry_changed: bool,
/// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport
/// bounds them, and the byte and count limits apply to prefetch only.
frame_visible: HashSet<usize>,
@@ -580,6 +582,8 @@ pub struct DocView {
pub(crate) last_scroll: Vec2,
/// Side-by-side compare: scroll to here this frame, following the other half.
pub(crate) sync_scroll: Option<Vec2>,
/// The page widget's id, by which the Hand tool finds its drag before the pages are laid out.
page_widget: Option<egui::Id>,
pan_hold_until: f64,
/// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan
/// settle holds. Fast pages render straight through, with no added latency.
@@ -716,6 +720,7 @@ impl DocView {
thumbs: HashMap::new(),
thumb_demand: HashMap::new(),
frame_queue: Vec::new(),
frame_geometry_changed: false,
frame_visible: HashSet::new(),
stale_thumbs: HashSet::new(),
stale_tiles: HashSet::new(),
@@ -774,6 +779,7 @@ impl DocView {
sharp_hold_until: 0.0,
last_scroll: Vec2::ZERO,
sync_scroll: None,
page_widget: None,
pan_hold_until: 0.0,
slow_pages: HashSet::new(),
comments: Default::default(),
@@ -802,6 +808,7 @@ impl DocView {
/// adopt the new page geometry, keeping the reader's place where possible.
pub fn document_changed(&mut self, info: &DocInfo) {
self.invalidate_content();
self.frame_geometry_changed = true;
self.page_count = info.pages.len();
self.page_heights = info.pages.iter().map(|p| p.height).collect();
let last = self.page_count.saturating_sub(1);
@@ -1517,6 +1524,7 @@ impl DocView {
/// Start collecting demand from every consumer before scheduling or uploading results.
pub(crate) fn begin_render_frame(&mut self) {
self.frame_geometry_changed = false;
self.pages_panel = None;
self.panel_gap = None;
self.thumb_demand.clear();
@@ -1732,6 +1740,15 @@ impl DocView {
}
pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) {
if self.frame_geometry_changed {
// Dialogs and edits can replace pages after the canvas collected demand. Its
// scales and indexes belong to the old pages, not to the new renderer (#3149).
self.begin_render_frame();
self.last_queue.clear();
pool.set_queue(Vec::new());
ctx.request_repaint();
return;
}
let queue = self.prepare_render_queue(info, ctx.pixels_per_point());
if queue != self.last_queue {
pool.set_queue(queue.clone());
@@ -1798,6 +1815,12 @@ impl DocView {
self.errors.insert(r.request.page, e);
return 0;
}
// Even current demand may have been sized with obsolete geometry or a different
// device limit. Check before cache eviction, conversion and every upload path.
let max_side = ctx.input(|i| i.max_texture_side);
if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side {
return 0;
}
// Remember heavy pages: only these get the zoom/pan settle holds, so fast pages
// never pay added latency. Stale entries die with the tiles on edits.
if r.millis >= SLOW_MILLIS {
@@ -2558,6 +2581,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if !std::mem::replace(&mut view.shown, true) {
scroll = scroll.scroll_offset(Vec2::ZERO);
}
// The Hand tool pans here, before the pages are laid out, so the page moves with the pointer
// in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the
// page only after that frame was drawn, a frame behind. The frame egui tells the drag from a
// click, the page catches up with all the pointer has moved since the press. A jump asked for
// below still wins.
if app.quick_tool == QuickTool::Hand
&& !middle_gesture
&& let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id))
{
let moved = if ui.ctx().drag_started_id() == Some(id) {
ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO)
} else {
ui.input(|i| i.pointer.delta())
};
// Within the content, as egui keeps it: past an end the page would show there for a
// frame and spring back. `max` is never NaN or below zero, so the clamp can't panic.
let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max));
}
if let Some(v) = view.sync_scroll.take() {
scroll = scroll.scroll_offset(v);
}
@@ -2703,14 +2745,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
view.page_widget = Some(resp.id);
// A drag lasts only while egui reports it, so the mark can't outlive one.
if !resp.dragged_by(egui::PointerButton::Primary) {
view.selection_drag = false;
}
// The Hand tool pans: the content widget takes every drag, so scroll by its delta.
// The Hand tool pans by this widget's drag, before the pages are laid out (above).
if hand {
if resp.dragged() {
ui.scroll_with_delta(resp.drag_delta());
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
@@ -3828,6 +3870,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((p, i)) = app.views[index].comments.default_request.take() {
app.make_comment_default(p, i);
}
if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() {
let doc = app.views[index].id;
app.comment_attachment_action(doc, p, i, open);
}
if let Some((name, w)) = field_props {
app.open_field_props(&name, w);
}
@@ -5525,6 +5571,81 @@ trailer << /Root 1 0 R >>
}
}
/// #3149: the replacement dialog commits after the canvas collected this frame's demand.
#[test]
fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() {
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 2048);
let mut app = PdfCraftApp::new();
let original = app.session.create_blank(200.0, 300.0, 1).unwrap();
let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap();
app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap();
let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() };
app.views[0].frame_queue = vec![request];
app.views[0].frame_visible.insert(0);
assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] }));
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0));
let edited_bytes = doc.bytes.clone();
let pool = RenderPool::new_inline(doc.display.clone(), Default::default());
app.views[0].finish_render_frame(&ctx, &doc.info, &pool);
assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer");
// The next layout can render a bounded backdrop and sharp tiles at the same zoom.
let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request };
let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request };
for _ in 0..2 {
let view = &mut app.views[0];
view.begin_render_frame();
view.frame_visible.insert(0);
view.frame_queue = vec![backdrop, tile];
view.finish_render_frame(&ctx, &doc.info, &pool);
}
let view = &app.views[0];
assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]);
assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]);
assert!(view.errors.is_empty());
assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit");
assert!(doc.dirty);
assert_eq!(doc.can_undo(), Some("Replace page"));
}
#[test]
fn oversized_raster_results_do_not_replace_cached_textures() {
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 64);
for request in [
RenderRequest::default(),
RenderRequest { tag: THUMB_TAG, ..Default::default() },
RenderRequest { tag: GRID_TAG, ..Default::default() },
RenderRequest { tag: PRINT_TAG, ..Default::default() },
RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() },
] {
let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default());
view.last_queue = vec![request];
let texture = |v: &DocView| {
v.pages
.values()
.map(|p| &p.tex)
.chain(v.thumbs.values().map(|p| &p.tex))
.chain(v.grid_pages.values().map(|(_, t)| t))
.chain(v.print_pages.values().map(|(_, t)| t))
.chain(v.tiles.values())
.map(|t| (t.id(), t.size()))
.collect::<Vec<_>>()
};
assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4);
let cached = texture(&view);
assert_eq!(cached.len(), 1);
for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] {
assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}");
assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster");
}
assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4);
assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress");
}
}
#[test]
fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() {
let ctx = egui::Context::default();
+6
View File
@@ -629,6 +629,12 @@ impl PdfCraftApp {
"page.duplicate" => {
self.apply_edit(Edit::DuplicatePages { pages: targets });
}
// Two or more selected pages reverse among themselves; otherwise the whole document.
"page.reverse" => {
let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count());
let pages = if targets.len() >= 2 { targets } else { (0..all).collect() };
self.apply_edit(Edit::ReversePages { pages });
}
"ocr.enhance_camera" => {
// The corners start on the document's edges when they can be found, else
// inset on the current page (camera_ui::page_input).
+21
View File
@@ -504,6 +504,9 @@ pub struct CommentView {
pub props_request: Option<(usize, usize)>,
/// Make Current Properties Default was asked for: (page, index).
pub default_request: Option<(usize, usize)>,
/// A file attachment comment's file was asked for: (page, index, open it in a new tab
/// rather than save it).
pub attachment_request: Option<(usize, usize, bool)>,
/// Replace Text: the struck-out text's quads while the replacement is typed.
pub replace: Option<Vec<[f64; 8]>>,
/// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file.
@@ -1595,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
}
}
/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's
/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading
/// the file changes nothing, so a read-only document offers them too.
pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) {
let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index };
let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return };
if ui.button(tl!("Save attachment…")).clicked() {
view.comments.attachment_request = Some((a.page, a.index, false));
ui.close();
}
if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() {
view.comments.attachment_request = Some((a.page, a.index, true));
ui.close();
}
ui.separator();
}
/// Items of the canvas context menu for the selected comment (or the page).
pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option<CanvasAction> {
let mut action = None;
@@ -1602,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
match selected {
Some(a) => {
let (page, index) = (a.page, a.index);
attachment_items(ui, view, info, a);
if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() {
view.comments.composer = Some(Composer {
page,
+3 -1
View File
@@ -255,7 +255,7 @@ pub(crate) fn show(
continue;
}
let thread = replies_of(a);
if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) {
edit = Some(e);
}
}
@@ -268,6 +268,7 @@ fn card(
t: &Tokens,
a: &Annotation,
thread: &[&Annotation],
info: &DocInfo,
view: &mut DocView,
prefs: &CommentPrefs,
allowed: bool,
@@ -447,6 +448,7 @@ fn card(
.layout(Layout::top_down_justified(Align::Min))
.show(|ui| {
ui.set_min_width(160.0);
crate::comments::attachment_items(ui, view, info, a);
if ui.button(tl_ctx!("comment menu", "Edit")).clicked() {
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
ui.close();
+155 -7
View File
@@ -793,14 +793,27 @@ struct Limits {
max_unauthenticated: usize,
/// How long a write may block on a client that never reads its replies.
write_timeout: std::time::Duration,
/// How long a connection refused after a reply is kept half-open, discarding input, so the
/// reply is not lost to a reset (see [`close_after_reply`]).
linger: std::time::Duration,
/// How many refused connections may linger at once. A lingering connection has given back
/// its place among [`Self::max_unauthenticated`], so this is what bounds their threads;
/// beyond it a refused connection is closed at once.
max_lingering: usize,
}
#[cfg(not(target_arch = "wasm32"))]
const LIMITS: Limits =
Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) };
const LIMITS: Limits = Limits {
auth_deadline: std::time::Duration::from_secs(10),
max_unauthenticated: 8,
write_timeout: std::time::Duration::from_secs(30),
linger: std::time::Duration::from_secs(1),
max_lingering: 8,
};
/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the
/// connection authenticates, and on every way its thread can end.
/// One of a limited number of places, given back when dropped: a place among
/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused,
/// and on every way its thread can end), or among [`Limits::max_lingering`].
#[cfg(not(target_arch = "wasm32"))]
struct Slot(std::sync::Arc<std::sync::atomic::AtomicUsize>);
@@ -850,6 +863,38 @@ impl std::io::Read for Reading {
}
}
/// Close a connection whose last reply has just been written without losing that reply.
///
/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and
/// the reset can discard the reply before the client reads it (Linux does): a client still
/// sending when it is refused would never learn why. So send FIN first, then read and discard
/// whatever arrives until the client closes its side or `linger` passes, and only then close.
///
/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places,
/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one
/// the connection is closed at once after the FIN, accepting the risk of a reset rather than
/// holding another thread.
#[cfg(not(target_arch = "wasm32"))]
fn close_after_reply(reading: &mut Reading, place: Option<Slot>, linger: std::time::Duration) {
use std::io::Read;
let _ = reading.stream.shutdown(std::net::Shutdown::Write);
let Some(_place) = place else { return };
reading.until = Some(std::time::Instant::now() + linger);
let mut sink = [0u8; 1024];
// Each read is bounded by the time left, and none is made once `linger` has passed.
while matches!(reading.read(&mut sink), Ok(n) if n > 0) {}
}
/// A connection is being refused before `auth`: give back its place among those waiting to
/// authenticate, so it does not keep a real client out while it lingers, and take a place to
/// linger in if one is free. Done before the reply is written, so a client that has read the
/// reply sees the places as they will be.
#[cfg(not(target_arch = "wasm32"))]
fn refuse(slot: &mut Option<Slot>, lingering: &std::sync::Arc<std::sync::atomic::AtomicUsize>, limits: Limits) -> Option<Slot> {
drop(slot.take());
Slot::take(lingering, limits.max_lingering)
}
/// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`.
///
/// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be
@@ -868,6 +913,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
let token = random_token()?;
let expected = token.clone();
let waiting = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let lingering = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
std::thread::Builder::new().name("pdfcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
@@ -881,6 +927,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
};
let client = client.clone();
let expected = expected.clone();
let lingering = lingering.clone();
let _ = std::thread::Builder::new().name("pdfcraft-control-conn".into()).spawn(move || {
// Held until `auth` succeeds; given back however this thread ends.
let mut slot = Some(slot);
@@ -899,14 +946,20 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
Err(e) if !authed && matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
let secs = limits.auth_deadline.as_secs_f64();
let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s"));
let _ = writeln!(write, "{e}").and_then(|_| write.flush());
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
// End of stream or a failed read ends the connection, as before.
Ok(LineRead::End) | Err(_) => break,
Ok(LineRead::TooLong) => {
let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth"));
let _ = writeln!(write, "{e}").and_then(|_| write.flush());
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
}
@@ -945,7 +998,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
}
}
};
if writeln!(write, "{reply}").and_then(|_| write.flush()).is_err() || close {
// `close` is only ever set before `auth`.
let place = if close { refuse(&mut slot, &lingering, limits) } else { None };
if writeln!(write, "{reply}").and_then(|_| write.flush()).is_err() {
break;
}
if close {
close_after_reply(reader.get_mut(), place, limits.linger);
break;
}
}
@@ -1089,6 +1148,8 @@ mod transport_tests {
auth_deadline: std::time::Duration::from_millis(300),
max_unauthenticated: 2,
write_timeout: std::time::Duration::from_secs(5),
linger: std::time::Duration::from_secs(1),
max_lingering: 2,
};
/// The next line the server sends, as JSON, or `None` when it closed the connection instead.
@@ -1146,6 +1207,93 @@ mod transport_tests {
drip.join().unwrap();
}
/// A client refused while the server still has its input unread gets the reason, not a
/// reset: closing on top of unread input sends a reset, which can discard the reply before
/// the client reads it (Linux does). Here a line far over the limit leaves most of itself
/// unread when the server refuses it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_client_refused_with_input_unread_gets_the_reason() {
use std::io::{BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, TEST_LIMITS).unwrap();
let s = connect(ep.port);
let mut w = s.try_clone().unwrap();
w.write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
// Let the server refuse it (and, before the fix, close) before reading the reply.
std::thread::sleep(std::time::Duration::from_millis(200));
let mut r = BufReader::new(s);
let reply = next_line(&mut r).expect("the server says why before closing");
assert_eq!(reply["error"]["code"], -32600, "{reply}");
}
/// A refused connection gives back its place among those waiting to authenticate before it
/// lingers, so lingering does not keep real clients out.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_lingering_connection_holds_no_place_to_authenticate() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } });
// Refused with most of its line unread, and left open: it lingers for `linger`.
let refused = connect(ep.port);
refused.try_clone().unwrap().write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
let mut r = BufReader::new(refused.try_clone().unwrap());
assert_eq!(next_line(&mut r).expect("refused with a reason")["error"]["code"], -32600);
// Well within the linger, both places are free for new connections.
let started = std::time::Instant::now();
let others: Vec<_> = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect();
for s in &others {
// A refused one has already been closed, so its write may fail; its reply says why.
let _ = writeln!(s.try_clone().unwrap(), "{auth}");
let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply");
assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}");
}
assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed());
drop(refused);
}
/// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still
/// gets its reply, and is then closed at once instead of lingering.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn only_so_many_refused_connections_linger() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#;
// The first refused connection takes the only lingering place and keeps it.
let first = connect(ep.port);
writeln!(first.try_clone().unwrap(), "{bad}").unwrap();
let mut r1 = BufReader::new(first.try_clone().unwrap());
assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001);
// The second still gets its reason (it has nothing unread, so no reset)...
let second = connect(ep.port);
let mut w2 = second.try_clone().unwrap();
writeln!(w2, "{bad}").unwrap();
let mut r2 = BufReader::new(second.try_clone().unwrap());
assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001);
// ...and is closed outright, not lingering: writing to it soon fails, where a lingering
// connection would read and discard for the whole of `linger`.
let started = std::time::Instant::now();
let closed = loop {
if w2.write_all(b"x").is_err() {
break true;
}
if started.elapsed() > limits.linger {
break false;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed());
drop(first);
}
/// #1806: only so many connections may wait to authenticate at once. One more is refused
/// on the spot; once a waiting one authenticates or times out, its place is free again.
#[cfg(not(target_arch = "wasm32"))]
+15
View File
@@ -1475,6 +1475,21 @@ mod tests {
(app, ed)
}
#[test]
fn a_loaded_font_is_applied_to_the_paragraph_being_edited() {
// Loading a font used to only register it on the page: the paragraph kept its source
// face until the font was found again in the document-font menu (#2591).
let (mut app, ed) = preview_app();
app.views[0].line_editor = Some(ed);
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]);
let doc = app.session.get(app.views[0].id).unwrap();
let lines = doc.text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>(), "Preview text");
assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::<Vec<_>>());
assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing");
}
#[test]
fn text_background_is_cached_without_editing_and_released_on_cancel() {
let (app, ed) = preview_app();
+20 -9
View File
@@ -24,6 +24,7 @@ pub enum CloseRequest {
pub struct PendingOverwrite {
pub id: pdfcraft_engine::DocId,
pub dest: String,
target: SaveTarget,
/// What was to run once the document is written (close its tab, quit).
after: Box<dyn FnOnce(&mut PdfCraftApp) + Send>,
}
@@ -621,12 +622,12 @@ impl PdfCraftApp {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after));
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
});
return false;
}
};
self.save_doc_to_then(id, &destination, Box::new(after))
self.save_doc_to_then(id, &destination, target, Box::new(after))
}
#[cfg(target_arch = "wasm32")]
{
@@ -681,14 +682,14 @@ impl PdfCraftApp {
/// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged).
/// Returns `true` if written now.
#[cfg(not(target_arch = "wasm32"))]
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
if own && self.watch.disk_differs(id, dest) {
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after });
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
self.dialog = Some(crate::Dialog::OverwriteChanged);
return false;
}
let saved = self.save_doc_to(id, dest);
let saved = self.save_doc_to(id, dest, target);
if saved {
after(self);
}
@@ -699,7 +700,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn overwrite_changed_file(&mut self) {
let Some(p) = self.pending_overwrite.take() else { return };
if self.save_doc_to(p.id, &p.dest) {
if self.save_doc_to(p.id, &p.dest, p.target) {
(p.after)(self);
}
}
@@ -715,17 +716,27 @@ impl PdfCraftApp {
/// Write document `id` to `dest` and make it the document's file. Returns `true` if written.
#[cfg(not(target_arch = "wasm32"))]
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool {
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool {
// Save As answers on a later frame: commit what was typed meanwhile, too (#166), even
// if another tab is active by then.
if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) {
return false;
}
let Some(name) = self.session.get(id).map(|d| d.name.clone()) else {
let Some((name, full)) = self.session.get(id).map(|d| {
// Save As to another file is a clean copy, without the earlier revisions that still
// hold deleted pages or replaced text (#2165). A signed document stays incremental so
// its signatures keep covering the file.
let full = target == SaveTarget::As
&& !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest))
&& d.read_only_reason.is_none()
&& !d.is_signed();
(d.name.clone(), full)
}) else {
self.notify_tr("The document was closed before it could be saved.");
return false;
};
let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
Ok(b) => b,
Err(e) => {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
+19 -6
View File
@@ -476,13 +476,26 @@ impl PdfCraftApp {
match purpose {
FilePurpose::TextFont { page } => {
let Some((_, bytes)) = files.into_iter().next() else { return };
if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })
&& let Some((index, id)) = self.active_ids()
&& let Some(doc) = self.session.get(id)
&& let Some(ed) = self.views[index].line_editor.as_mut()
&& let Some(ctx) = self.ctx.as_ref()
let bytes = Arc::new(bytes);
if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) {
return;
}
let Some((index, id)) = self.active_ids() else { return };
let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return };
// The font just loaded is the one the user wants: apply it to the paragraph, as
// choosing it from the document-font menu does, instead of only registering it (#2591).
let resource = self.session.get(id).and_then(|doc| {
doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource)
});
let ctx = self.ctx.clone();
if resource.is_some() {
ed.font_resource = resource;
self.apply_line_format(index, ed, ctx.as_ref());
} else if let Some(doc) = self.session.get(id)
&& let Some(ctx) = ctx
{
ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page);
ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page);
self.views[index].line_editor = Some(ed);
}
}
FilePurpose::Combine => self.stage_combine(files),
+76 -2
View File
@@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option<Lang> {
list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag)
}
/// The first supported language among BCP 47 tags in preference order, as the browser lists them
/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in
/// practice, and a page can't make Auto walk an unbounded one.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn first_supported_tag<S: AsRef<str>>(tags: impl IntoIterator<Item = S>) -> Option<Lang> {
tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim()))
}
/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages`
/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads).
/// English when the browser offers none we have a catalog for.
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN };
let mut tags: Vec<String> = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect();
tags.extend(navigator.language());
first_supported_tag(&tags).unwrap_or(Lang::EN)
}
thread_local! {
@@ -411,7 +425,38 @@ pub fn set_current(lang: Lang) {
/// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages
/// that capitalise nouns (German), where lowercasing would misspell it.
pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> {
if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) }
match current().code() {
"de" => std::borrow::Cow::Borrowed(label),
"tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()),
_ => std::borrow::Cow::Owned(label.to_lowercase()),
}
}
/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no
/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must
/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps
/// the English rule.
pub fn upper_label(english: &str) -> String {
let lang = current();
let shown = tr(lang, english);
if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() }
}
fn upper_turkish(c: char) -> std::char::ToUppercase {
match c {
'i' => 'İ'.to_uppercase(),
'ı' => 'I'.to_uppercase(),
c => c.to_uppercase(),
}
}
fn lower_turkish(c: char) -> std::char::ToLowercase {
match c {
// `İ`.to_lowercase() is "i" plus a combining dot above.
'İ' => 'i'.to_lowercase(),
'I' => 'ı'.to_lowercase(),
c => c.to_lowercase(),
}
}
pub fn current() -> Lang {
@@ -619,6 +664,21 @@ mod tests {
assert_eq!(first_supported("("), None);
}
/// #2954: the web build's Auto language follows the browser's preferred languages in order,
/// skipping ones without a catalog, and is English only when none is supported.
#[test]
fn browser_language_list_is_followed_in_order() {
assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it"));
assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA()));
assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant"));
assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN));
assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br"));
assert_eq!(first_supported_tag(["", "xx"]), None);
assert_eq!(first_supported_tag(Vec::<String>::new()), None);
let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]);
assert_eq!(first_supported_tag(long), None, "only the first tags are read");
}
#[test]
fn windows_display_language_resolves_chinese_scripts() {
for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] {
@@ -1961,6 +2021,20 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() {
set_current(Lang::from_code("tr").expect("tr registered"));
assert_eq!(upper_label("Advanced"), "GELİŞMİŞ");
assert_eq!(upper_label("Signatures"), "İMZALAR");
// A label with no Turkish row is still English and keeps the English rule.
assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE");
assert_eq!(in_sentence("İmza Alanı"), "imza alanı");
assert_eq!(in_sentence("Işık"), "ışık");
set_current(Lang::EN);
assert_eq!(upper_label("Advanced"), "ADVANCED");
assert_eq!(in_sentence("Image"), "image");
}
#[test]
fn german_certificate_subject_has_a_distinct_context() {
let de = Lang::from_code("de").expect("de registered");
+9
View File
@@ -1541,6 +1541,15 @@ impl PdfCraftApp {
}
}
/// [`Self::attachment_action`] for the file of the attachment comment at (page, index).
pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) {
let source = pdfcraft_render::AttachmentSource::Annotation { page, index };
// The menus offer the items only for a listed file, so a miss is a comment gone since.
if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) {
self.attachment_action(doc, i, open);
}
}
/// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves
/// the frame to the app and Use system title bar is off.
pub fn draws_window_frame(&self) -> bool {
+23 -11
View File
@@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras);
if changed {
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
if app.apply_edit(edit) {
ed.applied();
if let Some(doc) = app.session.get(app.views[i].id)
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
{
ed.refresh_source(block, doc.text_lines(ed.page));
ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page);
}
}
app.views[i].line_editor = Some(ed);
app.apply_line_format(i, ed, Some(ui.ctx()));
}
ui.add_space(6.0);
ui.separator();
@@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.separator();
}
impl PdfCraftApp {
/// Apply the open paragraph's formatting as one change; the paragraph stays open.
pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) {
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
if self.apply_edit(edit) {
ed.applied();
if let Some(doc) = self.session.get(self.views[index].id)
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
{
ed.refresh_source(block, doc.text_lines(ed.page));
if let Some(ctx) = ctx {
ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page);
}
}
}
self.views[index].line_editor = Some(ed);
}
}
// ───────────────────────────────────────────────────────────────────────────── right panels
pub(crate) enum Nav {
@@ -791,6 +800,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) {
app.make_comment_default(p, i);
}
if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) {
app.comment_attachment_action(id, p, i, open);
}
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) {
app.open_comment_props(p, i);
}
+1 -1
View File
@@ -1103,7 +1103,7 @@ pub(crate) fn body(
);
// Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on.
ui.add_space(6.0);
egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint))
egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint))
.id_salt("print-advanced")
.default_open(d.as_image)
.show(ui, |ui| {
+1 -1
View File
@@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(10.0);
// Section titles across every panel go through here, so one translation point covers them.
ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
ui.add_space(2.0);
}
+85
View File
@@ -1213,6 +1213,91 @@ fn attaching_a_file_as_a_comment() {
assert_eq!(s.quick_tool, QuickTool::Select);
}
/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying
/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference
/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones.
fn attachment_comment_fixture() -> Vec<u8> {
let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(),
"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(),
format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
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("annexed.pdf", None, attachment_comment_fixture()).expect("opens");
app.set_option("left", "closed").unwrap();
setup(&mut app);
app
});
h.run_steps(4);
settle(&mut h);
h
}
fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
let s = h.state();
s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect()
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_page_menu() {
let mut h = attachment_harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
assert_eq!(comments(&h)[0].subtype, "FileAttachment");
click_pt(&mut h, (110.0, 112.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected");
let p = at(&h, 110.0, 112.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Copy text");
// The Attachments panel is closed, so these items can only come from the comment's menu.
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_card_menu() {
let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap());
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Copy text");
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
/// Drag with the middle button (the mouse wheel) in small steps.
fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE };
+36
View File
@@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() {
assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]);
}
#[test]
fn reverse_pages_reverses_the_selection_or_else_the_whole_document() {
let mut h = organize(4);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]);
}
#[test]
fn command_click_toggles_and_rotation_applies_to_selection() {
let mut h = organize(3);
@@ -520,6 +535,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() {
let _ = std::fs::remove_file(out);
}
#[test]
fn save_as_to_a_new_file_drops_earlier_revisions() {
// #2165: Save As appended a revision, so a deleted page stayed in the new file's first one.
let out = temp_path("save-as-clean.pdf");
let mut h = organize(3);
h.get_by_label("Page 2").hover();
h.run_steps(2);
h.get_by_label("Delete page 2").click();
h.run_steps(3);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As));
let saved = std::fs::read(&out).expect("file written");
let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count();
assert_eq!(count(b"%%EOF"), 1, "one revision only");
assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone");
assert!(!dirty(&h));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice());
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
let _ = std::fs::remove_file(out);
}
#[test]
fn closing_a_dirty_tab_asks_and_cancel_keeps_it() {
let mut h = organize(2);
+32
View File
@@ -285,6 +285,38 @@ fn certifying_without_a_visible_signature() {
h.get_by_label_contains("Certified by Test Signer EC");
}
#[test]
fn save_as_keeps_a_signed_document_incremental() {
// #2165: Save As to a new file rewrites unsigned documents in full; a signed one is appended
// to, or its signature would no longer cover the file.
let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12");
let digital_id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap();
let mut s = pdfcraft_engine::Session::new();
let id = s.open("contract.pdf", None, std::sync::Arc::new(FIXTURE.to_vec()), None).unwrap();
let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..Default::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
let out = dir().join("copy.pdf");
let (bytes, cert) = (signed.to_vec(), digital_id.certificate.clone());
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("contract.pdf", None, bytes).expect("opens");
app.session.set_trusted_certificates(vec![cert]);
app
});
h.run_steps(4);
let status = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).map(|x| x.status)
};
assert_eq!(status(&h), Some(SignatureStatus::Valid));
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "a signed document can be saved as a copy");
let saved = std::fs::read(&out).expect("file written");
assert!(saved.starts_with(&signed), "the signed revision is kept as it was");
assert_eq!(status(&h), Some(SignatureStatus::Valid));
}
/// The fixture with an empty signature field across the bottom of the page.
const WITH_FIELD: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj
+41
View File
@@ -1046,6 +1046,47 @@ fn the_hand_tool_pans_by_dragging() {
assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand");
}
#[test]
fn the_hand_tool_moves_the_page_with_the_pointer() {
// With the Hand tool the page stays under the pointer, point for point, in the frame the
// pointer moves (#924, #1992). The drag used to reach the scroll offset only after the frame
// was drawn (and, before the page view stopped animating its scrolls, eased in over several
// frames), so the page trailed behind the pointer; the points moved before egui tells a drag
// from a click were lost on top.
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "600"), ("quick", "hand")]);
let start = h.state().views[0].viewport_rect().center();
h.hover_at(start);
h.run_steps(2);
let page = |h: &Harness<'static, PdfCraftApp>| rect(h, 0).expect("page 1 on screen").min;
let grabbed = page(&h);
let click_dist = h.ctx.options(|o| o.input_options.max_click_dist);
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.event(button(start, true));
h.step();
// A slow start, two points a frame, then quick strokes both ways. Once the pointer is further
// than a click may wander, the page has moved exactly as far as it, in every frame.
let mut pointer = start;
let strokes = [egui::vec2(-2.0, -2.0); 5].into_iter().chain([egui::vec2(-40.0, -150.0), egui::vec2(25.0, 60.0)]);
for stroke in strokes {
pointer += stroke;
h.event(egui::Event::PointerMoved(pointer));
h.step();
if (pointer - start).length() > click_dist {
assert_eq!(page(&h) - grabbed, pointer - start, "the page keeps up with the pointer in the same frame");
}
}
// Held still, it stays: nothing is left to ease in.
h.run_steps(30);
assert_eq!(page(&h) - grabbed, pointer - start, "nothing moves the page once the pointer stops");
// Let go mid-stroke, it stops with the pointer: the Hand tool doesn't fling the page on.
pointer += egui::vec2(0.0, -30.0);
h.event(egui::Event::PointerMoved(pointer));
h.step();
h.event(button(pointer, false));
h.run_steps(30);
assert_eq!(page(&h) - grabbed, pointer - start, "the page stays where it was let go");
}
#[test]
fn required_fields_get_a_red_border_when_highlighting() {
let _gpu = gpu();
+2 -1
View File
@@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod
3. After every edit an incremental write produces the working file (original bytes plus one
revision); the view is refreshed from those bytes, so what you see is what Save writes.
4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A
conversion, print imposition, a changed password, Save As with garbage collection.
conversion, print imposition, a changed password, Save As to another file (garbage-collected,
unless the document is signed).
5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected
from rewrites.
+2 -1
View File
@@ -8,7 +8,8 @@ together with its pixels, including nested graphics states, intervening state op
and paths split across content streams. A clip shared with other content stays where it is.
When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an
earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't
nest one more graphics state each time.
nest one more graphics state each time. Moving a privately cropped figure again likewise
changes the `cm` before its clip instead of adding another.
PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**,
which packages text, vector paths, and nested images into one object. PdfCraft selects
+7 -6
View File
@@ -1,12 +1,12 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (PR #2624: integrated upstream d8b27058 while retaining this fix and upstream progress; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -108,7 +108,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.<br>Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. |
@@ -136,9 +136,9 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text.<br>Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text.<br> Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text.<br>Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text.<br> Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
@@ -187,10 +187,11 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; 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 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
+15 -15
View File
@@ -1070,8 +1070,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"]
notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily"
evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"]
notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop"
[[feature]]
id = "view.rotate-view"
@@ -2812,8 +2812,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.replace"]
tools = ["page_replace"]
evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"]
notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept"
evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"]
notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget."
[[feature]]
id = "organize.duplicate-pages"
@@ -2833,11 +2833,11 @@ title = "Reverse page order"
area = "D"
tier = "P2"
milestone = "M4"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["page.reverse"]
tools = ["page_reverse"]
evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"]
notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu"
[[feature]]
id = "organize.cut-copy-paste-pages"
@@ -3812,8 +3812,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.attach"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel"
evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)"
[[feature]]
id = "comment.record-audio"
@@ -5088,8 +5088,8 @@ milestone = "M6"
status = "partial"
commands = ["form.detect", "form.prepare"]
tools = ["form_detect_fields"]
evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"]
notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups."
evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"]
notes = "Underscore runs, rules, empty boxes, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: dotted leader lines, tables of cells as such, deskewing, radio groups."
[[feature]]
id = "form.auto-name-fields"
@@ -5728,8 +5728,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
notes = "with pop-ups; fields lose all widgets"
evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"]
notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets"
[[feature]]
id = "protect.redact-struct-tree"