mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
Merge main and retain grayscale spooler conversion
This commit is contained in:
commit
1c1a5d746e
12 files changed
+449
-24
No files matched your search
Generated
+1
-1
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c#1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
|
||||
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
|
||||
@@ -252,6 +252,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear.
|
||||
Print eligibility, source dictionaries and shared appearance streams remain unchanged.
|
||||
|
||||
- **2026-10-27 (M10, #5029):** Vector printing retains the orientation and upper-left anchor of existing NoRotate annotation appearances on rotated source pages.
|
||||
Crop origins, UserUnit and later sheet transforms remain effective; estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
|
||||
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
|
||||
|
||||
- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged.
|
||||
|
||||
@@ -139,6 +139,8 @@ fn spool_job(
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
|
||||
// Set when the copy sent is made grey (`spooler_copy`, #5806).
|
||||
grey_pages: false,
|
||||
title,
|
||||
options: {
|
||||
let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect();
|
||||
@@ -369,8 +371,8 @@ impl Automation {
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
|
||||
let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
|
||||
+123
-10
@@ -6,20 +6,65 @@ use pdfcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use crate::guard;
|
||||
|
||||
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler.
|
||||
/// On Windows, whose PDF renderer prints an image of more than 2²² pixels from a shrunk copy,
|
||||
/// large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131), so that a
|
||||
/// scan prints at the printer's resolution. Elsewhere, with no large image, and when the copy
|
||||
/// can't be read or written, the bytes go as they are: tiling never stops a print.
|
||||
pub fn spooler_copy(bytes: Vec<u8>) -> Vec<u8> {
|
||||
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler
|
||||
/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are.
|
||||
///
|
||||
/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so.
|
||||
/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft
|
||||
/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright
|
||||
/// (#5806). The driver is still asked for grey where it has it.
|
||||
/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows'
|
||||
/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out
|
||||
/// at the printer's resolution.
|
||||
///
|
||||
/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a
|
||||
/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey,
|
||||
/// strictly, as before.
|
||||
pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job) -> Vec<u8> {
|
||||
if !cfg!(windows) {
|
||||
return bytes;
|
||||
}
|
||||
let bytes = Arc::new(bytes);
|
||||
let mut bytes = Arc::new(bytes);
|
||||
if job.grayscale {
|
||||
match greyed(bytes.clone()) {
|
||||
Some(Some(grey)) => {
|
||||
bytes = Arc::new(grey);
|
||||
job.grey_pages = true;
|
||||
}
|
||||
Some(None) => job.grey_pages = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy.
|
||||
tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone()))
|
||||
}
|
||||
|
||||
/// `bytes` with every colour on every page, its comments and form fields included, turned grey by
|
||||
/// Convert colours (black kept black, pictures re-encoded where they can be): `Some(Some(_))`.
|
||||
/// `Some(None)` when nothing needed converting, and `None` when the copy can't be read or written.
|
||||
///
|
||||
/// Convert colours leaves shadings, patterns, inline images and pictures it can't re-encode
|
||||
/// (indexed, CMYK, JPEG 2000 …) as they are; the driver is still asked for grey, so a driver with a
|
||||
/// grey option makes those grey too, and one without prints them as they are.
|
||||
pub fn greyed(bytes: Arc<Vec<u8>>) -> Option<Option<Vec<u8>>> {
|
||||
guard(|| {
|
||||
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
|
||||
let pages: Vec<usize> = (0..pdfcraft_model::pages(&doc).len()).collect();
|
||||
let settings =
|
||||
pdfcraft_edit::ConvertColours { target: pdfcraft_edit::ColourTarget::Gray, preserve_black: true, images: true, annotations: true };
|
||||
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, _: pdfcraft_edit::ColourTarget| {
|
||||
pdfcraft_optimize::recolour_image(doc, s, pdfcraft_optimize::ImageTarget::Gray)
|
||||
};
|
||||
let done = pdfcraft_edit::convert_colours(&mut doc, &pages, &settings, &mut recolour).ok()?;
|
||||
if done.colours == 0 && done.images == 0 {
|
||||
return Some(None);
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).ok().map(Some)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy
|
||||
/// can't be read or written.
|
||||
pub fn tiled(bytes: Arc<Vec<u8>>) -> Option<Vec<u8>> {
|
||||
@@ -37,6 +82,7 @@ mod tests {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
|
||||
use super::*;
|
||||
use crate::print::spool::Job;
|
||||
|
||||
/// A one-page PDF drawing a `w` × `h` grey image over the page.
|
||||
fn page_with_image(w: i64, h: i64) -> Vec<u8> {
|
||||
@@ -90,16 +136,83 @@ mod tests {
|
||||
fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() {
|
||||
assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None);
|
||||
assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None);
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec()), b"not a PDF");
|
||||
let mut job = Job::default();
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF");
|
||||
let small = page_with_image(100, 100);
|
||||
assert_eq!(spooler_copy(small.clone()), small);
|
||||
assert_eq!(spooler_copy(small.clone(), &mut job), small);
|
||||
assert!(!job.grey_pages);
|
||||
// A grayscale job whose copy can't be read asks the driver for grey as before.
|
||||
let mut grey = Job { grayscale: true, ..Job::default() };
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF");
|
||||
assert!(!grey.grey_pages);
|
||||
}
|
||||
|
||||
/// A one-page PDF filling the page in red.
|
||||
fn red_page() -> Vec<u8> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||||
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1 0 0 rg 0 0 612 792 re f".to_vec())));
|
||||
let mut page = Dict::new();
|
||||
page.set("Type", Object::name("Page"));
|
||||
page.set("Parent", Object::Ref(pages));
|
||||
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
|
||||
page.set("Contents", Object::Ref(content));
|
||||
let page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set("Count", Object::Int(1));
|
||||
d.set("Kids", Object::Array(vec![Object::Ref(page)]));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The operators of the first page's content.
|
||||
fn first_page_content(bytes: Vec<u8>) -> String {
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let contents = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||||
match &*doc.get(contents) {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #5806: a page in colour is made grey for a grayscale job, which says so.
|
||||
#[test]
|
||||
fn a_colour_page_is_made_grey_for_a_grayscale_job() {
|
||||
let grey = greyed(Arc::new(red_page())).expect("readable").expect("converted");
|
||||
let content = first_page_content(grey.clone());
|
||||
assert!(!content.contains(" rg"), "{content}");
|
||||
assert!(content.contains(" g"), "{content}");
|
||||
// Already grey: nothing to convert, and the pages are still grey.
|
||||
assert_eq!(greyed(Arc::new(grey)), Some(None));
|
||||
assert_eq!(greyed(Arc::new(b"not a PDF".to_vec())), None);
|
||||
}
|
||||
|
||||
/// On Windows a grayscale job's copy is grey and the job says its pages are; elsewhere the
|
||||
/// bytes go as they are and the driver is asked, as before. A colour job is never converted.
|
||||
#[test]
|
||||
fn a_grayscale_job_sends_grey_pages_on_windows_only() {
|
||||
let red = red_page();
|
||||
let mut job = Job { grayscale: true, ..Job::default() };
|
||||
let sent = spooler_copy(red.clone(), &mut job);
|
||||
if cfg!(windows) {
|
||||
assert!(job.grey_pages);
|
||||
assert!(!first_page_content(sent).contains(" rg"));
|
||||
} else {
|
||||
assert!(!job.grey_pages);
|
||||
assert_eq!(sent, red);
|
||||
}
|
||||
let mut colour = Job::default();
|
||||
assert_eq!(spooler_copy(red.clone(), &mut colour), red);
|
||||
assert!(!colour.grey_pages);
|
||||
}
|
||||
|
||||
/// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are.
|
||||
#[test]
|
||||
fn the_spooler_gets_tiles_on_windows_only() {
|
||||
let large = page_with_image(2100, 2100);
|
||||
let sent = spooler_copy(large.clone());
|
||||
let sent = spooler_copy(large.clone(), &mut Job::default());
|
||||
if cfg!(windows) {
|
||||
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
|
||||
assert_ne!(sent, large);
|
||||
|
||||
@@ -55,6 +55,9 @@ annotations); sheets place them with a clip. The result is a fresh, unencrypted,
|
||||
garbage-collected file (callers check the print permission).
|
||||
Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix.
|
||||
Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices.
|
||||
Existing printable NoRotate appearances keep their orientation and upper-left rectangle anchor when the source page is rotated.
|
||||
Text annotations use this upright placement even with the NoRotate flag clear (ISO 32000-1 section 12.5.6.4).
|
||||
Crop origins, UserUnit, sheet scaling, and later imposition rotations still apply; NoZoom does not cancel printing scale.
|
||||
Missing or unusable appearance streams still cannot be printed.
|
||||
|
||||
The GUI Print dialog remembers its last selected paper size across application sessions,
|
||||
@@ -108,7 +111,7 @@ In a browser there is no spooler: the app (`pdfcraft-ui-egui`'s `web_print`) dra
|
||||
PDF's sheets with PdfCraft's renderer and prints them through the browser's print dialog, which
|
||||
sets copies, two-sided printing and colour. This crate stays free of web APIs.
|
||||
|
||||
Not yet: print as image, poster labels, PostScript output, colour conversion for
|
||||
Not yet: poster labels, PostScript output, colour conversion for
|
||||
grayscale.
|
||||
|
||||
## Architectural sheets and scale
|
||||
|
||||
@@ -163,6 +163,20 @@ pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) ->
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// ISO 32000-1 section 12.5.3: counterrotate the fitted appearance about Rect's upper-left
|
||||
/// corner in default user space. Crop, UserUnit and sheet transforms remain outside it.
|
||||
fn no_rotate_placement(placement: Matrix, rect: [f64; 4], rotation: i64) -> Option<Matrix> {
|
||||
let [x, _, _, y] = rect;
|
||||
let counterrotation = match rotation {
|
||||
90 => Matrix([0.0, 1.0, -1.0, 0.0, x + y, y - x]),
|
||||
180 => Matrix([-1.0, 0.0, 0.0, -1.0, 2.0 * x, 2.0 * y]),
|
||||
270 => Matrix([0.0, -1.0, 1.0, 0.0, x - y, y + x]),
|
||||
_ => Matrix::default(),
|
||||
};
|
||||
let placement = placement.then(&counterrotation);
|
||||
placement.0.iter().all(|value| value.is_finite()).then_some(placement)
|
||||
}
|
||||
|
||||
/// A Form XObject holding page `index` as it prints (user space; BBox = crop box).
|
||||
/// `pages` are the document's pages, listed once by the caller: listing them for each page
|
||||
/// printed would make printing quadratic in the page count.
|
||||
@@ -209,6 +223,15 @@ pub(crate) fn page_form(
|
||||
let Some(ap) = appearance(doc, &d) else { continue };
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
let Some(m) = appearance_matrix(doc, &form, rect) else { continue };
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
// Text annotations have implicit NoRotate even when the flag is clear (ISO 32000-1 section 12.5.6.4).
|
||||
let no_rotate = flags & 16 != 0 || d.get(b"Subtype").is_some_and(|subtype| doc.resolve(subtype).as_name() == Some(b"Text"));
|
||||
let m = if no_rotate {
|
||||
no_rotate_placement(m, rect, page.rotation(doc))
|
||||
.ok_or_else(|| PrintError::Invalid(format!("page {} has a non-finite NoRotate annotation placement", index.saturating_add(1))))?
|
||||
} else {
|
||||
m
|
||||
};
|
||||
let ap = if !form.contains(b"Subtype")
|
||||
&& let Object::Stream(stream) = &*doc.get(ap)
|
||||
{
|
||||
|
||||
@@ -56,6 +56,11 @@ fn render(bytes: Vec<u8>) -> RenderedPage {
|
||||
}
|
||||
|
||||
fn render_page(bytes: Vec<u8>, index: usize) -> RenderedPage {
|
||||
render_page_at_size(bytes, index, (100, 100))
|
||||
}
|
||||
|
||||
/// Explicit raster dimensions expose `/UserUnit` and sheet scaling without a default-size render hiding them.
|
||||
fn render_page_at_size(bytes: Vec<u8>, index: usize, size: (u32, u32)) -> RenderedPage {
|
||||
let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
|
||||
page: index,
|
||||
kind: RequestKind::Pixels,
|
||||
@@ -66,7 +71,7 @@ fn render_page(bytes: Vec<u8>, index: usize) -> RenderedPage {
|
||||
});
|
||||
assert_eq!(page.error, None);
|
||||
assert!(page.warnings.is_empty(), "{:?}", page.warnings);
|
||||
assert_eq!((page.width, page.height), (100, 100));
|
||||
assert_eq!((page.width, page.height), size);
|
||||
page
|
||||
}
|
||||
|
||||
@@ -593,6 +598,7 @@ fn annotation_placement_preserves_small_transform_coefficients() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare bad-geometry output with a no-annotation control to ensure page artwork remains visible.
|
||||
#[test]
|
||||
fn unusable_appearance_geometry_keeps_page_artwork() {
|
||||
let (mut control, _, _) = widget_fixture(Some(Object::name("Form")), None);
|
||||
@@ -815,3 +821,261 @@ fn shared_promotions_span_pages_without_merging_distinct_sources() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 0, 255], [255, 255, 0, 255]];
|
||||
|
||||
// Contributor-original, asymmetric 20 x 10 artwork: red/blue above green/yellow.
|
||||
/// Shift `/CropBox` and `/Rect` together so the colored, asymmetric artwork exposes origin and rotation errors.
|
||||
fn no_rotate_fixture(rotation: Object, origin: [f64; 2], subtype: &str, flags: i64) -> (Document, ObjRef, ObjRef) {
|
||||
let (mut doc, ap, annotation) = widget_fixture(Some(Object::name("Form")), None);
|
||||
let form = doc.get(ap).as_dict().unwrap().clone();
|
||||
doc.set(
|
||||
ap,
|
||||
Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f")),
|
||||
);
|
||||
let [x, y] = origin;
|
||||
doc.update_dict(annotation, |d| {
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"F".to_vec(), Object::Int(flags));
|
||||
d.set(b"Rect".to_vec(), numbers(&[x + 40.0, y + 40.0, x + 60.0, y + 50.0]));
|
||||
})
|
||||
.unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| {
|
||||
d.set(b"MediaBox".to_vec(), numbers(&[x - 20.0, y - 20.0, x + 120.0, y + 120.0]));
|
||||
d.set(b"CropBox".to_vec(), numbers(&[x, y, x + 100.0, y + 100.0]));
|
||||
d.set(b"Rotate".to_vec(), rotation);
|
||||
d.remove(b"Contents");
|
||||
})
|
||||
.unwrap();
|
||||
(doc, ap, annotation)
|
||||
}
|
||||
|
||||
/// Colored quadrant samples distinguish every quarter-turn; white margins expose extent or anchor errors.
|
||||
fn assert_upright_corners(page: &RenderedPage, anchor: (usize, usize), scale: usize) {
|
||||
let (x, y) = anchor;
|
||||
for ((dx, dy), color) in [(5, 2), (15, 2), (5, 7), (15, 7)].into_iter().zip(CORNER_COLORS) {
|
||||
assert_eq!(pixel(page, x + dx * scale, y + dy * scale), color, "upright corner at {anchor:?}, scale={scale}");
|
||||
}
|
||||
for (px, py) in [(x - 2, y + 5 * scale), (x + 22 * scale, y + 5 * scale), (x + 10 * scale, y - 2), (x + 10 * scale, y + 12 * scale)] {
|
||||
assert_eq!(pixel(page, px, py), [255, 255, 255, 255], "outside the upright appearance at ({px},{py})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Literal anchors and colors test pivot, orientation, and size without copying production math.
|
||||
/// Direct and indirect `Text` subtypes with `/F 4` exercise implicit `NoRotate` without `/F 16`.
|
||||
/// Unflagged non-`Text` cases retain the rotating control.
|
||||
#[test]
|
||||
fn vector_print_preserves_no_rotate_appearance_anchors() {
|
||||
for (rotation, anchor, control) in [
|
||||
(0, (40, 50), [(45, 52), (55, 52), (45, 57), (55, 57)]),
|
||||
(90, (50, 40), [(47, 45), (47, 55), (42, 45), (42, 55)]),
|
||||
(180, (60, 50), [(55, 47), (45, 47), (55, 42), (45, 42)]),
|
||||
(270, (50, 60), [(52, 55), (52, 45), (57, 55), (57, 45)]),
|
||||
] {
|
||||
for (subtype, reversed, indirect_subtype) in [
|
||||
("Widget", false, false),
|
||||
("Square", false, false),
|
||||
("Square", true, false),
|
||||
("Stamp", false, false),
|
||||
("Text", false, false),
|
||||
("Text", false, true),
|
||||
] {
|
||||
for flags in [4, 20] {
|
||||
let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags);
|
||||
if indirect_subtype {
|
||||
let name = doc.add(Object::name(subtype));
|
||||
doc.update_dict(annotation, |d| d.set(b"Subtype".to_vec(), Object::Ref(name))).unwrap();
|
||||
}
|
||||
if reversed {
|
||||
doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&[60.0, 50.0, 40.0, 40.0]))).unwrap();
|
||||
}
|
||||
let before = saved(&doc);
|
||||
let original = doc.get(ap);
|
||||
let settings = print_settings(Content::DocumentAndMarkups);
|
||||
let first = impose(&doc, &settings).unwrap();
|
||||
let reopened = Document::open(Arc::new(first.clone())).unwrap();
|
||||
for bytes in [first, impose(&reopened, &settings).unwrap()] {
|
||||
let page = render(bytes);
|
||||
if flags == 20 || subtype == "Text" {
|
||||
assert_upright_corners(&page, anchor, 1);
|
||||
} else {
|
||||
for ((x, y), color) in control.into_iter().zip(CORNER_COLORS) {
|
||||
assert_eq!(pixel(&page, x, y), color, "control: rotation={rotation}, subtype={subtype}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(doc.get(ap), original, "shared appearance bytes/dictionary stay unchanged");
|
||||
assert_eq!(saved(&doc), before, "rotation={rotation}, subtype={subtype}, flags={flags}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare output pixel-for-pixel with the source shifted to its `/Rect` anchor.
|
||||
/// This also verifies that the exposed page area remains white.
|
||||
fn assert_translated_upright_source(actual: &RenderedPage, source: &RenderedPage, anchor: (i32, i32)) {
|
||||
let (dx, dy) = (anchor.0 - 40, anchor.1 - 50);
|
||||
for y in 0..100i32 {
|
||||
for x in 0..100i32 {
|
||||
let (sx, sy) = (x - dx, y - dy);
|
||||
let expected = if (0..100).contains(&sx) && (0..100).contains(&sy) { pixel(source, sx as usize, sy as usize) } else { &[255; 4] };
|
||||
let actual = pixel(actual, x as usize, y as usize);
|
||||
assert_eq!(actual, expected, "translated source at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare print output with a rotation-zero source under shifted `/BBox` and `/Matrix` transforms.
|
||||
/// This also covers inherited and normalized `/Rotate` values.
|
||||
#[test]
|
||||
fn no_rotate_keeps_appearance_matrix_and_shifted_inherited_page_geometry() {
|
||||
for (rotation, inherited, origin, anchor) in
|
||||
[(450, true, [-20.0, 30.0], (50, 40)), (-180, false, [15.0, -25.0], (60, 50)), (-90, true, [-20.0, 30.0], (50, 60))]
|
||||
{
|
||||
for (matrix, shifted_bbox) in
|
||||
[([2.0, 0.0, 0.0, 3.0, 7.0, -11.0], true), ([1.0, 0.0, 1.0, 1.0, 7.0, -11.0], false), ([-1.0, 0.0, 0.0, 1.0, 27.0, -11.0], false)]
|
||||
{
|
||||
let (mut doc, ap, _) = no_rotate_fixture(Object::Int(rotation), origin, "Widget", 20);
|
||||
doc.update_dict(ap, |d| {
|
||||
d.set(b"Matrix".to_vec(), numbers(&matrix));
|
||||
if shifted_bbox {
|
||||
d.set(b"BBox".to_vec(), numbers(&[5.0, -4.0, 25.0, 6.0]));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
if shifted_bbox {
|
||||
let form = doc.get(ap).as_dict().unwrap().clone();
|
||||
doc.set(
|
||||
ap,
|
||||
Object::Stream(Stream::flate(
|
||||
form,
|
||||
b"q 1 0 0 1 5 -4 cm 0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f Q",
|
||||
)),
|
||||
);
|
||||
}
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
if inherited {
|
||||
let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
let rotation = Object::Ref(doc.add(Object::Int(rotation)));
|
||||
doc.update_dict(parent, |d| d.set(b"Rotate".to_vec(), rotation)).unwrap();
|
||||
doc.update_dict(page, |d| {
|
||||
d.remove(b"Rotate");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let mut upright = doc.clone();
|
||||
upright.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(0))).unwrap();
|
||||
let expected = render(saved(&upright));
|
||||
// Establish that the reference contains real asymmetric artwork, rather than
|
||||
// accepting two blank or uniformly colored renderings as equivalent.
|
||||
for color in CORNER_COLORS {
|
||||
assert!(expected.rgba.as_chunks::<4>().0.contains(&color));
|
||||
}
|
||||
let before = saved(&doc);
|
||||
let original = doc.get(ap);
|
||||
let settings = print_settings(Content::Document);
|
||||
let printed = impose(&doc, &settings).unwrap();
|
||||
let reopened = Document::open(Arc::new(printed.clone())).unwrap();
|
||||
assert_translated_upright_source(&render(printed), &expected, anchor);
|
||||
assert_translated_upright_source(&render(impose(&reopened, &settings).unwrap()), &expected, anchor);
|
||||
assert_eq!(doc.get(ap), original);
|
||||
assert_eq!(saved(&doc), before, "rotation={rotation}, inherited={inherited}, origin={origin:?}, matrix={matrix:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid `/Rotate` values normalize to zero while the eligible appearance remains upright.
|
||||
#[test]
|
||||
fn malformed_page_rotation_keeps_no_rotate_appearance_upright() {
|
||||
for rotation in [Object::Int(135), Object::Int(-45), Object::Real(90.5), Object::name("QuarterTurn")] {
|
||||
let (doc, _, _) = no_rotate_fixture(rotation.clone(), [-20.0, 30.0], "Widget", 20);
|
||||
assert_eq!(pdfcraft_model::pages(&doc)[0].rotation(&doc), 0);
|
||||
let before = saved(&doc);
|
||||
let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap());
|
||||
assert_upright_corners(&printed, (40, 50), 1);
|
||||
assert_eq!(saved(&doc), before, "malformed Rotate={rotation:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `NoZoom` does not cancel print layout scaling across `UserUnit` and custom sheet sizes.
|
||||
#[test]
|
||||
fn no_rotate_keeps_user_units_and_sheet_scaling() {
|
||||
let (mut doc, _, annotation) = no_rotate_fixture(Object::Int(90), [-20.0, 30.0], "Widget", 28);
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap();
|
||||
// Resolve the same flags as the existing print path; NoZoom does not cancel print scale.
|
||||
let flags = doc.add(Object::Int(28));
|
||||
doc.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Ref(flags))).unwrap();
|
||||
let before = saved(&doc);
|
||||
for (paper, layout, size, anchor, scale) in [
|
||||
((200.0, 200.0), Layout::Size(SizeMode::Actual), (200, 200), (100, 80), 2),
|
||||
((100.0, 100.0), Layout::Size(SizeMode::Custom(50.0)), (100, 100), (50, 40), 1),
|
||||
] {
|
||||
let settings = Settings { paper, layout, ..print_settings(Content::Document) };
|
||||
let printed = render_page_at_size(impose(&doc, &settings).unwrap(), 0, size);
|
||||
assert_upright_corners(&printed, anchor, scale);
|
||||
}
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
|
||||
/// Counterrotating one use must not alter the shared stream or rotate its later unflagged use.
|
||||
#[test]
|
||||
fn no_rotate_is_per_invocation_when_appearances_are_shared() {
|
||||
let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20);
|
||||
let mut control = doc.get(annotation).as_dict().unwrap().clone();
|
||||
control.set(b"F".to_vec(), Object::Int(4));
|
||||
control.set(b"Rect".to_vec(), numbers(&[10.0, 10.0, 30.0, 20.0]));
|
||||
let control = doc.add(Object::Dict(control));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(control)]))).unwrap();
|
||||
let before = saved(&doc);
|
||||
let original = doc.get(ap);
|
||||
let output = impose(&doc, &print_settings(Content::Document)).unwrap();
|
||||
let reopened = Document::open(Arc::new(output.clone())).unwrap();
|
||||
let rendered = render(output);
|
||||
assert_upright_corners(&rendered, (50, 40), 1);
|
||||
for ((x, y), color) in [(17, 15), (17, 25), (12, 15), (12, 25)].into_iter().zip(CORNER_COLORS) {
|
||||
assert_eq!(pixel(&rendered, x, y), color, "unflagged use of the shared appearance");
|
||||
}
|
||||
let sheet = &pdfcraft_model::pages(&reopened)[0];
|
||||
let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0"));
|
||||
assert_eq!(xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0"), xobject(&reopened, form.as_dict().unwrap(), b"PCPrA1"));
|
||||
assert_eq!(doc.get(ap), original);
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
|
||||
/// Refuse non-finite `NoRotate` placement without changing the source.
|
||||
/// Excluded and degenerate appearances retain their omission behavior.
|
||||
#[test]
|
||||
fn no_rotate_refuses_nonfinite_placement_and_still_skips_degenerate_appearances() {
|
||||
for (rotation, rect) in [(90, [1e308, 0.0, 1.7e308, 1e308]), (180, [1e308, 0.0, 1.7e308, 1e308]), (270, [-1e308, 0.0, -0.3e308, 1e308])] {
|
||||
for (subtype, flags) in [("Widget", 20), ("Text", 4)] {
|
||||
let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags);
|
||||
doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&rect))).unwrap();
|
||||
let before = saved(&doc);
|
||||
let original = doc.get(ap);
|
||||
assert!(
|
||||
matches!(impose(&doc, &print_settings(Content::DocumentAndMarkups)), Err(PrintError::Invalid(_))),
|
||||
"rotation={rotation}, subtype={subtype}, flags={flags}: unrepresentable placement"
|
||||
);
|
||||
assert_eq!(doc.get(ap), original);
|
||||
assert_eq!(saved(&doc), before);
|
||||
let mut excluded = doc.clone();
|
||||
for flags in [16, 22] {
|
||||
excluded.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Int(flags))).unwrap();
|
||||
let printed = render(impose(&excluded, &print_settings(Content::DocumentAndMarkups)).unwrap());
|
||||
assert!(printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]), "excluded malformed appearance");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut doc, ap, _) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20);
|
||||
doc.update_dict(ap, |d| d.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 0.0, 10.0]))).unwrap();
|
||||
let before = saved(&doc);
|
||||
let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap());
|
||||
assert!(
|
||||
printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]),
|
||||
"degenerate appearance keeps its existing omission behavior"
|
||||
);
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
@@ -687,7 +687,7 @@ impl PdfCraftApp {
|
||||
/// the progress card counts the sheets as they reach the spooler, and the spooler's answer
|
||||
/// arrives as a notice.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn send_to_printer(&mut self, printer: String, bytes: Vec<u8>, job: spool::Job) -> bool {
|
||||
fn send_to_printer(&mut self, printer: String, bytes: Vec<u8>, mut job: spool::Job) -> bool {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ctx = self.ctx.clone();
|
||||
let spooler = self.spooler.clone();
|
||||
@@ -697,9 +697,9 @@ impl PdfCraftApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
// Large pictures become tiles on Windows (#5131): work on the whole file, kept off the
|
||||
// UI thread with the rest.
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
|
||||
// On Windows grayscale makes the pages grey (#5806) and large pictures become tiles
|
||||
// (#5131): work on the whole file, kept off the UI thread with the rest.
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
|
||||
// The receiver may be gone (the app quit): nothing to report to then.
|
||||
let result = match &spooler {
|
||||
Some(submit) => submit(&bytes, &job),
|
||||
|
||||
@@ -543,6 +543,8 @@ impl PrintDraft {
|
||||
},
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()),
|
||||
// Set when the copy sent is made grey (`spooler_copy`, #5806).
|
||||
grey_pages: false,
|
||||
title: title.to_string(),
|
||||
// Only for the printer the options were read from.
|
||||
options: match (&self.printer, &self.driver_options) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M10 #5029: Text appearances have implicit NoRotate during vector printing even when the flag is clear; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
|
||||
>
|
||||
@@ -261,6 +264,10 @@ total matches the parity estimate.
|
||||
| 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; XFDF values retain text across XML comments and processing instructions.<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; XFDF values retain text across XML comments and processing instructions.<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. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly).<br>Done since (#4088): automatic AcroForm field scripts ignore Print with a console diagnostic; explicit button and console printing remain available. Integrated main f137a289 (0.16.1) on 2026-10-31; ShowPanel/SaveAs filtering, all three engine regressions, the headless regression, field effects and document-error provenance are retained. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. |
|
||||
| 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. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. 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, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. 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). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Appearance placement retains small transform coefficients for large bounding boxes and matrices. Existing printable NoRotate appearances retain orientation and the upper-left rectangle anchor through source page rotation, including implicit NoRotate on Text annotations with the flag clear; later sheet transforms still apply (#5029). |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| 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. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. 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, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. 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). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. 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). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
|
||||
@@ -340,6 +347,8 @@ Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | M10 #5029: existing Text appearances keep their implicit NoRotate orientation in vector printing even when the flag is clear. Print eligibility and source/AP objects remain unchanged; native viewing without the flag remains a separate gap. Estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10 #5029: vector printing preserves existing NoRotate appearance orientation and anchors; estimates unchanged. Missing appearances and NoZoom print behavior remain unchanged. |
|
||||
| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. |
|
||||
| 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. |
|
||||
| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. |
|
||||
|
||||
@@ -3686,8 +3686,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.note"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"]
|
||||
notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page."
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors"]
|
||||
notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom). Vector printing preserves existing printable Text appearances upright even when NoRotate is implicit and the flag is clear; imported Text appearances with that flag clear still turn in the native view. Missing print appearances are not synthesized. Edit flattening is tracked separately."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.highlight"
|
||||
@@ -8345,8 +8345,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"]
|
||||
notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path."
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling"]
|
||||
notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. Missing or unusable appearance streams remain outside this path."
|
||||
|
||||
[[feature]]
|
||||
id = "print.print-with-comment-summary"
|
||||
|
||||
Reference in new issue
Block a user