mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
890b83c4c4
commit
9ed3abe490
13 files changed
+225
-33
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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('欢'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user