Keep undrawn comments and accurate search hits (#1317, #1327, #1302, #1315, #1340) (#1442)

Flattening deleted a cloudy comment it could not draw, and a refused text-box restyle or resize left the new properties in the file. Arrow defaults were stored on plain lines. A custom match ending on a separator included the next glyph, and the vertical-text redaction error claimed an overlap it had not checked.

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Márk BartalosandBrandon Thomas authored and GitHub committed 2026-10-13 01:09:36 -04:00
1 parent 890b83c4c4
commit 9ed3abe490
13 files changed
+225 -33

No files matched your search

+2
View File
@@ -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.
+46 -15
View File
@@ -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::<Vec<f64>>()).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<Rgb>
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<Rgb>
}
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.
+6 -2
View File
@@ -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('欢'));
+11 -2
View File
@@ -168,8 +168,9 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
// A subtype this build cannot draw fails here and is kept below, so the comment is
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
// was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {
@@ -185,6 +186,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
continue;
}
let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
let mut drew = false;
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
@@ -192,8 +194,15 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
xobjects.set(name.into_bytes(), Object::Ref(ap));
drawn += 1;
drew = true;
}
}
// A comment whose ink was not written into the page stays. Removing it would drop
// the annotation (a cloudy square this builder cannot draw, for example) with nothing
// left on the page. Widgets are form fields: flattening still removes them.
if !widget && !drew {
continue;
}
match entry.as_ref() {
Some(r) => {
gone.insert(r);
+31
View File
@@ -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]
+7 -4
View File
@@ -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<Report, Reda
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
};
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
// place such glyphs under a mark or confirm they are gone. Rather than report success and
// ship the text under the box, refuse the whole operation (the caller keeps the previous
// document). Full vertical-text removal is a future enhancement.
// tell whether those glyphs lie under a mark. Rather than report success and possibly
// leave the text in place, refuse the whole operation (the caller keeps the previous
// document). The error does not claim the text was under the mark. Full vertical-text
// removal is a future enhancement.
if out.vertical {
return Err(RedactError::VerticalText(pi + 1));
}
+5 -1
View File
@@ -236,7 +236,11 @@ fn vertical_writing_text_under_a_mark_fails_closed() {
// Identity-V: apply must refuse rather than report success with the text still in the file.
let mut vdoc = make("Identity-V");
mark(&mut vdoc, 0, &[[85.0, 55.5, 105.0, 61.0]], "");
assert_eq!(apply(&mut vdoc, None), Err(RedactError::VerticalText(1)), "vertical text under a mark must not silently pass");
let err = apply(&mut vdoc, None).unwrap_err();
assert_eq!(err, RedactError::VerticalText(1), "vertical text with a pending mark must not silently pass");
let msg = err.to_string();
assert!(msg.contains("cannot tell whether"), "{msg}");
assert!(!msg.contains("text under a redaction mark"), "{msg}");
// Identity-H control: the same glyphs and mark are redacted normally.
let mut hdoc = make("Identity-H");
+49 -5
View File
@@ -149,18 +149,32 @@ impl PageText {
/// separated by one space, original case). Used for pattern search (Search & Redact).
pub fn find_with(&self, matcher: impl Fn(&[char]) -> Vec<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
let mut chars: Vec<char> = Vec::new();
let mut owner: Vec<usize> = 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<Option<usize>> = 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<TextGlyph> = "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<usize>| 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,
+1 -1
View File
@@ -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;
+15
View File
@@ -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<CommentTool> {
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<CommentTool> {
Some(match (a.subtype.as_str(), a.intent.as_deref()) {
+42
View File
@@ -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::<Vec<_>>();
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());
+6 -2
View File
@@ -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.
+4 -1
View File
@@ -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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- 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.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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<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). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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<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). 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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.<br>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.<br>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.<br>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.<br>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.<br>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<br>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.<br>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<br>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.<br>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.<br>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.<br>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).<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 |