mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
A stamp placed on a page with /Rotate was drawn in user space, so the label or picture turned with the page. Counterrotate the appearance so it reads upright as the page is displayed. Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
dfa43beb9f
commit
fff985f30d
9 files changed
+245
-20
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 (M5 #1398: stamps read upright on rotated pages; 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 (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)
|
||||
@@ -143,6 +144,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged.
|
||||
- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged.
|
||||
- **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.
|
||||
|
||||
@@ -47,10 +47,10 @@ deterministic; the engine supplies them.
|
||||
never paint a second icon.
|
||||
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
|
||||
viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
|
||||
- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
|
||||
pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
|
||||
and PDF-page stamps still use their existing placement behavior; page-rotation support for
|
||||
those paths is not complete.
|
||||
- Picture stamps (a natural-size or explicit-rectangle image, a PDF page, or an image
|
||||
signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so
|
||||
the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps
|
||||
that appearance matrix. The rotation is the page's `/Rotate` at placement.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
@@ -264,6 +264,11 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// The page `/Rotate` a rubber stamp's label is turned back by (`/PCStampRotate`).
|
||||
fn stamp_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
|
||||
@@ -632,7 +637,26 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
}
|
||||
let kind = crate::StampKind::from_name(other)?;
|
||||
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
return Some(stamp(kind, rect, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res));
|
||||
let turn = stamp_turn(d);
|
||||
let draw = if turn == 0 {
|
||||
rect
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
[0.0, 0.0, dw, dh]
|
||||
};
|
||||
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
|
||||
if turn != 0 {
|
||||
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
|
||||
// the page maps display space into user space. Viewers fit that box to
|
||||
// `/Rect`; the page rotation then shows the label the right way up.
|
||||
let m = pdfcraft_model::view_matrix_for(turn, rect);
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -1091,6 +1091,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
if let Some(b) = by {
|
||||
d.set(b"PCByLine".to_vec(), PdfString::text(b));
|
||||
}
|
||||
// The label is drawn for the page as displayed. A later page rotation is a new edit.
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
Shape::TypedSignature { contours, .. } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
@@ -1237,8 +1242,9 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
|
||||
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
|
||||
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
|
||||
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
|
||||
/// box is fitted to `/Rect` by PDF viewers.
|
||||
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
|
||||
let matrix = match rotation {
|
||||
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
|
||||
@@ -1248,8 +1254,8 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
|
||||
};
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||||
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
|
||||
if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() {
|
||||
return Err(AnnotError::Invalid("only a picture stamp can be oriented".into()));
|
||||
}
|
||||
let normal = d
|
||||
.get(b"AP")
|
||||
@@ -1316,8 +1322,8 @@ fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
|
||||
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
|
||||
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
|
||||
let mut stream = appearance::build(d)?;
|
||||
// Image stamp restyling must retain the placement's page-axis correction.
|
||||
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
|
||||
// Picture-stamp restyling must retain the placement's page-axis correction.
|
||||
if d.get(b"PCPicture").is_some() {
|
||||
let matrix = d.get(b"AP").and_then(|ap| {
|
||||
let ap = doc.resolve(ap);
|
||||
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
|
||||
|
||||
@@ -2165,7 +2165,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
let src = mark_source(doc, file)?;
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
let natural = rect[2] == rect[0] && rect[3] == rect[1];
|
||||
let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 };
|
||||
let rotation = pdfcraft_organize::page_rotation(doc, *page)?;
|
||||
let rect = if natural {
|
||||
let k = (200.0 / sw.max(sh)).min(1.0);
|
||||
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
|
||||
|
||||
@@ -114,6 +114,191 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A blank page turned `degrees`, with one annotation placed from displayed coordinates.
|
||||
fn rotated_blank(degrees: i64) -> (Session, DocId) {
|
||||
let mut session = Session::new().with_clock(|| 1_700_000_000);
|
||||
let blank = session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
let id = session.open_new("drawing.pdf", blank).unwrap();
|
||||
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
|
||||
(session, id)
|
||||
}
|
||||
|
||||
/// The user-space box of a `w` by `h` point rectangle centred at a displayed point.
|
||||
fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] {
|
||||
let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)];
|
||||
let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect();
|
||||
[
|
||||
f64::from(mapped[0][0].min(mapped[1][0])),
|
||||
f64::from(mapped[0][1].min(mapped[1][1])),
|
||||
f64::from(mapped[0][0].max(mapped[1][0])),
|
||||
f64::from(mapped[0][1].max(mapped[1][1])),
|
||||
]
|
||||
}
|
||||
|
||||
fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
|
||||
let doc = session.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
rendered
|
||||
}
|
||||
|
||||
fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] {
|
||||
let offset = ((y * rendered.width + x) * 4) as usize;
|
||||
rendered.rgba[offset..offset + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save.
|
||||
#[test]
|
||||
fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
|
||||
let (w, h) = StampKind::SignHere.size();
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let (mut session, id) = rotated_blank(degrees);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
let rect = displayed_rect(&info, 100.0, 40.0, w, h);
|
||||
let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None };
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
style: Style::default_for(&shape),
|
||||
shape,
|
||||
contents: String::new(),
|
||||
author: "Test".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let check = |session: &Session, id: DocId| {
|
||||
let rendered = render_page(session, id);
|
||||
let doc = session.get(id).unwrap();
|
||||
let info = doc.info.pages[0].clone();
|
||||
let stamp = doc.info.annotations[0].rect;
|
||||
let a = info.user_to_view(stamp[0], stamp[1]);
|
||||
let b = info.user_to_view(stamp[2], stamp[3]);
|
||||
let (left, top) = (a[0].min(b[0]), a[1].min(b[1]));
|
||||
let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1]));
|
||||
let scale = 2.0;
|
||||
let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32);
|
||||
// The tag's point is the left edge. The flat end fills the right edge.
|
||||
let tip = at(left + 4.0, (top + bottom) / 2.0);
|
||||
let outside = at(left + 4.0, top + 4.0);
|
||||
let end = at(right - 4.0, top + 4.0);
|
||||
assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point");
|
||||
assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper");
|
||||
assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange");
|
||||
};
|
||||
check(&session, id);
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
|
||||
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
|
||||
check(&reopened, again);
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-page PDF whose left half is red and whose right half is white, as displayed.
|
||||
fn half_red_page() -> Vec<u8> {
|
||||
let content = "1 0 0 rg 0 0 40 40 re f";
|
||||
let objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, obj) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(obj);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn quadrant_png() -> Arc<Vec<u8>> {
|
||||
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
|
||||
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
Arc::new(png.into_inner())
|
||||
}
|
||||
|
||||
fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) {
|
||||
let doc = session.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
|
||||
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) {
|
||||
assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright.
|
||||
#[test]
|
||||
fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() {
|
||||
let bytes = quadrant_png();
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
let (mut session, id) = rotated_blank(degrees);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0);
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddCustomStamp {
|
||||
page: 0,
|
||||
rect,
|
||||
name: "Original quadrants".into(),
|
||||
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
|
||||
author: "Test".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_upright_quadrants(&session, id, degrees);
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
|
||||
assert_upright_quadrants(&session, id, degrees);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box.
|
||||
#[test]
|
||||
fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
|
||||
let bytes = Arc::new(half_red_page());
|
||||
for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) {
|
||||
let (mut session, id) = rotated_blank(degrees);
|
||||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||||
let at = info.view_to_user(100.0, 100.0).map(f64::from);
|
||||
let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) };
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddCustomStamp {
|
||||
page: 0,
|
||||
rect,
|
||||
name: "Half red".into(),
|
||||
file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 },
|
||||
author: "Test".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
let left = stamp_pixel(&rendered, 80, 100);
|
||||
let right = stamp_pixel(&rendered, 120, 100);
|
||||
assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red");
|
||||
assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper");
|
||||
}
|
||||
}
|
||||
|
||||
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
|
||||
let doc = s.get(id).unwrap();
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
|
||||
@@ -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: #1398 stamps read upright on rotated pages; 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 (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)
|
||||
@@ -127,6 +128,9 @@ 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). Stamps placed
|
||||
on a rotated page read upright: rubber stamps, natural-size and explicit-rectangle images, and
|
||||
PDF-page stamps (#1398). 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
|
||||
@@ -320,6 +324,7 @@ update the numbers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; 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 (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)
|
||||
@@ -89,6 +90,7 @@ total matches the parity estimate.
|
||||
| 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: stamps placed on a rotated page read upright (#1398); 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 |
|
||||
@@ -149,6 +151,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M5 #1398: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M12: text-only auto-tag Apply writes real structure and parent-tree links, with undo and Tags-panel review; mixed content remains unsupported. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. |
|
||||
|
||||
@@ -4052,8 +4052,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.stamp"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools"]
|
||||
notes = "12 standard business stamps in PdfCraft's own design; ISO 32000 stamp names where defined"
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
|
||||
notes = "12 standard business stamps in PdfCraft's own design; ISO 32000 stamp names where defined. A stamp placed on a rotated page reads upright as the page is displayed (#1398)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-sign-here"
|
||||
@@ -4064,8 +4064,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.stamp"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools"]
|
||||
notes = "Accepted, Initial Here, Rejected, Sign Here, Witness as pointed tags"
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
|
||||
notes = "Accepted, Initial Here, Rejected, Sign Here, Witness as pointed tags. The point stays on the left as the page is displayed, including /Rotate 90, 180 and 270 (#1398)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-dynamic"
|
||||
@@ -4076,8 +4076,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["comment.stamp"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools"]
|
||||
notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time)"
|
||||
evidence = ["crates/annot/src/tests.rs::stamps_draw_their_label_and_by_line", "crates/ui-egui/tests/stamps.rs::choosing_and_placing_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::rubber_stamps_point_left_as_displayed_on_rotated_pages"]
|
||||
notes = "Approved, Confidential, Received, Reviewed, Revised with a By author at time, date line (local time). The label is turned with the page's /Rotate so it reads upright as displayed (#1398)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-custom"
|
||||
@@ -4088,8 +4088,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.stamp"]
|
||||
tools = ["stamp_custom"]
|
||||
evidence = ["crates/ui-egui/tests/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/automation.rs::stamps_through_tools"]
|
||||
notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette"
|
||||
evidence = ["crates/ui-egui/tests/stamps.rs::creating_placing_and_keeping_custom_stamps", "crates/automation/tests/automation.rs::stamps_through_tools", "crates/engine/src/tests.rs::explicit_image_stamps_keep_displayed_orientation_on_rotated_pages", "crates/engine/src/tests.rs::pdf_page_stamps_keep_displayed_orientation_on_rotated_pages"]
|
||||
notes = "Create custom stamp from a PDF page or an image (category and name), kept in the app's settings; placed at natural size (at most 200 pt) keeping its shape; resizable (appearance redrawn from /PCPicture); delete from the palette. Natural-size and explicit-rectangle images, and PDF-page stamps, read upright on a rotated page (#1398)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.stamps-manage"
|
||||
|
||||
Reference in new issue
Block a user