diff --git a/ROADMAP.md b/ROADMAP.md index 6304d521b..5b2fbba92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -142,6 +143,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. - **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. - **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. - **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 282498f96..c4de43aea 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1264,6 +1264,21 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio Ok(()) } +/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would +/// replace those characters with `?`, so the edit is refused before the dictionary changes. +fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { + let drawn_elsewhere = + d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); + if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { + return Err(AnnotError::Undrawable(format!( + "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", + c.escape_debug(), + u32::from(c) + ))); + } + Ok(()) +} + /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). /// @@ -1272,19 +1287,19 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio /// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` /// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let drawn_elsewhere = - d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none(); - if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) { - return Err(AnnotError::Undrawable(format!( - "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", - c.escape_debug(), - u32::from(c) - ))); - } + refuse_foreign_text(doc, &annot_dict(doc, r))?; draw_appearance(doc, r) } +/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. +fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { + if let Err(e) = set_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) +} + /// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard /// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, /// cleared when all of it can be drawn) so the edit is reported and flattening won't make that @@ -1783,13 +1798,13 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() }) .collect(); + let before = annot_dict(doc, r); doc.update_dict(r, |d| { d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); d.set(b"Rect".to_vec(), num_array(&rect)); touch(d, meta); })?; - set_appearance(doc, r)?; - Ok(()) + redraw_or_restore(doc, r, before) } /// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's @@ -1801,6 +1816,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); } let d = annot_dict(doc, r); + if !new_text { + refuse_foreign_text(doc, &d)?; + } // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); let (outer, cl) = match callout { @@ -1826,7 +1844,16 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } touch(d, meta); })?; - if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) } + let before = d; + if new_text { + if let Err(e) = draw_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) + } else { + redraw_or_restore(doc, r, before) + } } fn border_width_of(d: &Dict) -> f64 { @@ -1863,7 +1890,9 @@ pub fn set_style( return Err(AnnotError::Invalid("this comment has no line endings to change".into())); } } + refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + let before = d; doc.update_dict(r, |d| { if let Some(c) = color { if free_text { @@ -1901,7 +1930,7 @@ pub fn set_style( ensure_ending_room(d); touch(d, meta); })?; - set_appearance(doc, r) + redraw_or_restore(doc, r, before) } /// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, @@ -1927,6 +1956,8 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option if fill.is_some_and(|c| !finite(&c)) { return Err(AnnotError::Invalid("invalid fill colour".into())); } + refuse_foreign_text(doc, &d)?; + let before = d; doc.update_dict(r, |d| { match fill { Some(c) => d.set(b"IC".to_vec(), rgb(c)), @@ -1936,7 +1967,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option } touch(d, meta); })?; - set_appearance(doc, r) + redraw_or_restore(doc, r, before) } /// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 59b69dc60..2ae46a246 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1220,9 +1220,13 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { on_page.push(Object::Ref(r)); set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err(); + let before = list(&doc, 0)[k].clone(); + let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); - assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept"); + assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); + let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); + assert!(matches!(&err, AnnotError::Undrawable(_)), "{err:?}"); + assert_eq!(list(&doc, 0)[k], before, "a refused resize keeps the rectangle"); // New text from the user is taken (and marked), as for any comment. set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap(); assert_eq!(mark(&doc, k), Some('欢')); diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 7b1865ca8..a3f6c886a 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -168,8 +168,9 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result { gone.insert(r); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 1ed11f036..06b920765 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -215,6 +215,37 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden +/// cloudy square and a cloudy square that already has an appearance keep their previous results. +#[test] +fn flattening_keeps_a_cloudy_square_it_cannot_draw() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; + let case = |extra: &str, drawn: usize, left: bool| { + let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) {extra} >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>", + &annot, + &page, + form, + ]); + let before = streams(&doc, 0); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(p.dict.contains(b"Annots"), left); + if drawn == 0 { + assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment"); + } + }; + case("/BE << /S /C /I 2 >>", 0, true); + case("/BE << /S /S >>", 1, false); + case("/BE << /S /C /I 2 >> /F 2", 0, true); + case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false); +} + /// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must /// generate that appearance and bake it into the page, not delete a comment that shows an icon. #[test] diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 7b09602cd..ddacfe234 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -42,7 +42,9 @@ pub enum RedactError { /// A mark's overlay text has a character Helvetica can't draw; nothing was redacted. #[error("{0}")] Undrawable(String), - #[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")] + #[error( + "page {0} has vertical-writing text while a redaction mark is pending, and PdfCraft cannot tell whether that text lies under the mark yet, so nothing was changed" + )] VerticalText(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), @@ -406,9 +408,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result Vec>) -> Vec> { let mut chars: Vec = Vec::new(); - let mut owner: Vec = Vec::new(); + // `None` is a word or line separator inserted between glyphs. It is not part of either + // glyph, so a match that starts or ends on it must not take the neighbouring glyph. + let mut owner: Vec> = Vec::new(); for (i, g) in self.glyphs.iter().enumerate() { - if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| *c != ' ') { + let gap = self.space_before.get(i).copied().unwrap_or(false) || self.line_of.get(i) != self.line_of.get(i.saturating_sub(1)); + if i > 0 && gap && chars.last().is_some_and(|c| *c != ' ') { chars.push(' '); - owner.push(i); + owner.push(None); } for c in g.text.chars() { chars.push(if c.is_whitespace() { ' ' } else { c }); - owner.push(i); + owner.push(Some(i)); } } - matcher(&chars).into_iter().filter(|r| r.start < r.end && r.end <= chars.len()).map(|r| owner[r.start]..owner[r.end - 1] + 1).collect() + matcher(&chars) + .into_iter() + .filter_map(|r| { + if r.start >= r.end || r.end > chars.len() { + return None; + } + let glyphs = owner.get(r.start..r.end)?; + let start = glyphs.iter().find_map(|g| *g)?; + let end = glyphs.iter().rev().find_map(|g| *g)?.checked_add(1)?; + (start < end).then_some(start..end) + }) + .collect() } /// Index of the glyph nearest to a view-space point (for selection anchors). @@ -1857,6 +1871,36 @@ mod tests { assert_eq!(t.nearest(52.0, 14.0), Some(5)); } + /// #1315: a custom match that ends on a reconstructed word or line separator does not take + /// the following glyph. + #[test] + fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { + let glyphs: Vec = "quickbrown" + .chars() + .enumerate() + .map(|(i, c)| { + let x = i as f32 * 6.0; + g(&c.to_string(), x, 0.0, x + 6.0) + }) + .collect(); + for line_break in [false, true] { + let mut space_before = vec![false; 10]; + space_before[5] = !line_break; + let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); + let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let plain = if line_break { "quick\nbrown" } else { "quick brown" }; + assert_eq!(page.plain_text(), plain); + assert_eq!(page.find("quick "), vec![0..5]); + let span = |r: std::ops::Range| Vec::from([r]); + assert_eq!(page.find_with(|_| span(0..5)), vec![0..5]); + assert_eq!(page.find_with(|_| span(5..11)), vec![5..10]); + assert_eq!(page.find_with(|_| span(6..11)), vec![5..10]); + let hits = page.find_with(|_| span(0..6)); + assert_eq!(page.text_of(hits[0].clone()), "quick"); + assert_eq!(hits, vec![0..5]); + } + } + #[test] fn phrase_search_ignores_repeated_spaces() { // "quick brown" drawn with two space glyphs in a row. Text extraction keeps both spaces, diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index e6ac0ba16..48d3571e1 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -90,8 +90,8 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return }; - let Some(tool) = crate::comments::tool_for(a) else { return }; let Some(p) = doc.comment_props(page, index) else { return }; + let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { style.color = c; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index c73408710..774e37b30 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -315,6 +315,21 @@ pub(crate) fn made_by(a: &Annotation, tool: CommentTool) -> bool { } } +/// The tool whose defaults "Make Current Properties Default" should update. +/// +/// Arrows are stored as Line annotations. An arrow ending belongs to the Arrow tool, so its +/// defaults stay separate from a plain line. +pub(crate) fn tool_for_default(a: &Annotation, endings: Option<&[LineEnding]>) -> Option { + match tool_for(a) { + Some(CommentTool::Line) if endings.is_some_and(|ends| ends.iter().copied().any(arrow_ending)) => Some(CommentTool::Arrow), + other => other, + } +} + +fn arrow_ending(ending: LineEnding) -> bool { + matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 7523a1882..98f2bf907 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -799,6 +799,48 @@ fn make_current_properties_default() { assert_eq!(last.color, Some([0.0, 0.47, 0.84])); } +/// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. +#[test] +fn an_arrows_default_does_not_restyle_lines() { + use pdfcraft_ui_egui::comments::CommentTool; + + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "arrow").unwrap(); + }); + drag_pt(&mut h, (20.0, 40.0), (140.0, 80.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation { + page: 0, + index: 0, + color: Some([0.0, 0.47, 0.84]), + opacity: Some(0.5), + width: Some(6.0), + endings: None, + }); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Make Current Properties Default").click(); + h.run_steps(3); + let arrow = h.state().comment_prefs.style(CommentTool::Arrow); + let line = h.state().comment_prefs.style(CommentTool::Line); + assert_eq!((arrow.color, arrow.opacity, arrow.width), ([0.0, 0.47, 0.84], 0.5, 6.0)); + assert_eq!((line.color, line.opacity, line.width), ([0.89, 0.13, 0.13], 1.0, 2.0), "plain lines keep their own default"); + + h.state_mut().set_option("quick", "arrow").unwrap(); + drag_pt(&mut h, (20.0, 100.0), (140.0, 140.0)); + h.state_mut().set_option("quick", "line").unwrap(); + drag_pt(&mut h, (160.0, 40.0), (260.0, 80.0)); + let c = comments(&h); + let arrows = c.iter().filter(|a| a.subtype == "Line").collect::>(); + assert!(arrows.len() >= 3, "{c:?}"); + let next_arrow = &arrows[1]; + let plain = arrows.last().unwrap(); + assert_eq!(next_arrow.color, Some([0.0, 0.47, 0.84])); + assert_eq!(plain.color, Some([0.89, 0.13, 0.13])); +} + #[test] fn highlighting_an_area_off_the_text() { let mut h = harness(|app| app.set_option("quick", "highlight").unwrap()); diff --git a/docs/gaps.md b/docs/gaps.md index 116f26ee2..679c75dcf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315 and #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -126,8 +127,11 @@ update the numbers. - **Missing:** fixes for the ≈ 13 of ≈ 25 filed reports of features marked `shipped` that give wrong results still open, plus triage of ≈ 220 other open issues. - **Evidence:** 245 open issues on 2026-10-10. The first batch of `fix(...)` reports was fixed by - 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821); the rest of - the backlog is untriaged. + 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). A later + batch keeps a cloudy comment flatten cannot draw (#1317), leaves a refused text-box restyle or + resize unchanged (#1327), keeps arrow defaults off plain lines (#1302), stops a custom text + match on the separator instead of the next glyph (#1315), and states the vertical-text + redaction refusal without claiming an overlap (#1340). The rest of the backlog is untriaged. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0221fb0f1..eed76c42f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -82,18 +83,20 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| 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. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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
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). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | 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
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). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | 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.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. 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).
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 |