diff --git a/Cargo.lock b/Cargo.lock index a521b5c92f..e456fb78bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4656,6 +4656,7 @@ name = "pdfcraft-print" version = "0.18.1" dependencies = [ "craft-print", + "log", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", diff --git a/ROADMAP.md b/ROADMAP.md index e90f0d0288..71573a6ead 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) @@ -238,6 +240,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## 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-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. - **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. @@ -829,6 +833,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. | | 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. | | 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. | diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 46a3368a13..186db56922 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -773,7 +773,7 @@ pub fn tools() -> Vec { "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, "collate": { "type": "boolean" }, - "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." }, "grayscale": { "type": "boolean" }, "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 46b71d6b2c..7a0506ca8b 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] craft-print = { workspace = true } +log = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md index 9499260624..6b0aea45b0 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -70,6 +70,20 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short- use the corresponding Windows binding edges. Random, exclusively created Windows job files are removed on success and failure. Other targets offer the print-ready PDF instead. +On Windows, **Printer default** reads the selected driver's current `DEVMODEW` duplex +setting immediately before submission and carries that binding explicitly into the job +(#5130). This also applies to `doc_print` with `duplex: "printer"`; its omitted-argument +default remains `off`. Explicit Off/Long Edge/Short Edge choices, booklet binding and +single-sided cut-and-stack bypass this lookup. Drivers without `DM_DUPLEX` retain the +unspecified setting. Two-sided defaults also stay unspecified when the driver does not +confirm automatic two-sided support, preserving manual duplex preferences (#5772). +A failed lookup, settings read or invalid declared duplex value is +logged and falls back to leaving duplex unspecified, so the driver can still choose its default. +If the queue was resolved before the settings failure, the job remains pinned to that queue. CUPS still leaves the choice to the queue. The Windows backend +selects `JobDuplexAllDocumentsContiguously`, falling back to `DocumentDuplex` when +the driver exposes only document-level duplex, and validates the retained binding before submission +([craft-libs #411](https://github.com/storytold/craft-libs/pull/411)). + Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is renamed `PdfCraftPrint` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 4b97d78658..ddeba8354a 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -6,6 +6,9 @@ pub use craft_print::{ parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +#[cfg(any(windows, test))] +mod windows_duplex; + /// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load /// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) /// so a Windows installation without them still starts (#2630); a call into a missing @@ -387,6 +390,10 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } @@ -397,6 +404,10 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } diff --git a/crates/print/src/spool/windows_duplex.rs b/crates/print/src/spool/windows_duplex.rs new file mode 100644 index 0000000000..eca09cf9a1 --- /dev/null +++ b/crates/print/src/spool/windows_duplex.rs @@ -0,0 +1,214 @@ +//! Resolve Printer default from the Windows driver's current DEVMODE. Omitting duplex +//! from a PrintTicket leaves its interpretation to the XPS driver (#5130). + +use std::borrow::Cow; + +use super::{Duplex, Job}; +use crate::PrintError; + +/// Preserve an explicit duplex choice by borrowing `job` without calling `read`. +/// Otherwise call `read` once with the selected printer (or `None` for the system default), +/// returning an owned copy with the resolved printer and duplex value. Log lookup failures +/// and leave duplex unspecified so the spooler can use its previous default behavior; +/// the original job remains unchanged even when the driver has no duplex setting. +fn resolve<'a>(job: &'a Job, read: impl FnOnce(Option<&str>) -> Result<(String, Option), PrintError>) -> Cow<'a, Job> { + if job.duplex.is_some() { + return Cow::Borrowed(job); + } + let (printer, duplex) = match read(job.printer.as_deref()) { + Ok(settings) => settings, + Err(error) => { + driver_default(Err(error)); + return Cow::Borrowed(job); + } + }; + let mut resolved = job.clone(); + resolved.printer = Some(printer); + resolved.duplex = duplex; + Cow::Owned(resolved) +} + +/// A driver default is best-effort: record the fallback and let the driver choose. +fn driver_default(result: Result, PrintError>) -> Option { + match result { + Ok(value) => value, + Err(error) => { + log::warn!("could not resolve the printer's two-sided default; leaving duplex unspecified: {error}"); + None + } + } +} + +/// Preserve manual two-sided defaults when PrintCapabilities say the driver cannot take +/// explicit two-sided settings (#5772). Unknown capabilities also keep the driver's default: +/// turning a best-effort preference into a strict request could otherwise refuse a working job. +fn supported_default(value: Option, support: impl FnOnce() -> Option) -> Option { + if matches!(value, Some(Duplex::LongEdge | Duplex::ShortEdge)) && support() != Some(true) { None } else { value } +} + +/// DEVMODEW's public dmFields (offset 72) marks dmDuplex (offset 94) as valid with +/// DM_DUPLEX. Values are DMDUP_SIMPLEX, DMDUP_VERTICAL and DMDUP_HORIZONTAL, respectively. +/// Return `None` when DM_DUPLEX is absent, or a spool error for a malformed field or an +/// unrecognized declared value. Layout: . +fn duplex(settings: &craft_print::rgb::DevMode) -> Result, PrintError> { + let bytes = settings.as_bytes(); + let invalid = || PrintError::Spool("the printer driver returned an invalid two-sided default; check its printing preferences".into()); + let fields = bytes.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).ok_or_else(invalid)?; + if fields & 0x1000 == 0 { + return Ok(None); + } + match bytes.get(94..96).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes) { + Some(1) => Ok(Some(Duplex::Off)), + Some(2) => Ok(Some(Duplex::LongEdge)), + Some(3) => Ok(Some(Duplex::ShortEdge)), + _ => Err(invalid()), + } +} + +#[cfg(windows)] +/// Resolve an unspecified duplex setting from the selected Windows printer immediately +/// before submission. An unnamed printer is resolved to the system default and pinned to +/// the returned job. Explicit choices bypass the driver lookup. Lookup failures are logged +/// and leave duplex unspecified; a settings failure retains the resolved queue. +pub(super) fn job(job: &Job) -> Cow<'_, Job> { + resolve(job, |selected| { + let failed = |e| PrintError::Spool(format!("could not read the printer's two-sided default: {e}")); + let printer = match selected { + Some(name) => name.to_owned(), + None => craft_print::rgb::default_printer() + .map_err(failed)? + .ok_or_else(|| PrintError::Spool("there is no default printer; choose a printer".into()))?, + }; + let value = craft_print::rgb::default_settings(&printer).map_err(failed).and_then(|settings| duplex(&settings)); + // Only this printer's driver is asked (craft-print `two_sided`), not every queue's, as + // `printers()` would; a networked printer in power save could hold the job (#2192). + let value = supported_default(driver_default(value), || craft_print::two_sided(&printer)); + Ok((printer, value)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(fields: u32, value: i16) -> craft_print::rgb::DevMode { + let mut bytes = vec![0; 220]; + bytes[68..70].copy_from_slice(&220u16.to_le_bytes()); + bytes[72..76].copy_from_slice(&fields.to_le_bytes()); + bytes[94..96].copy_from_slice(&value.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(bytes).unwrap() + } + + #[test] + fn printer_default_reaches_the_windows_job_for_each_binding() { + for (value, expected) in [(1, Duplex::Off), (2, Duplex::LongEdge), (3, Duplex::ShortEdge)] { + let original = Job { + printer: Some("Office".into()), + copies: 3, + collate: false, + grayscale: true, + title: "report".into(), + options: vec![("media".into(), "A4".into())], + ..Job::default() + }; + let resolved = resolve(&original, |selected| { + assert_eq!(selected, Some("Office")); + Ok(("Office".into(), duplex(&settings(0x1000, value))?)) + }); + assert_eq!(resolved.duplex, Some(expected)); + assert_eq!(resolved.printer.as_deref(), Some("Office")); + assert_eq!(resolved.copies, 3); + assert_eq!(resolved.collate, original.collate); + assert_eq!(resolved.grayscale, original.grayscale); + assert_eq!(resolved.scaling, original.scaling); + assert_eq!(resolved.options, original.options); + assert_eq!(resolved.title, "report"); + assert_eq!(original.duplex, None, "Printer default remains selected for the next job"); + } + } + + #[test] + fn explicit_choices_do_not_read_or_change_the_printer_default() { + for choice in [Duplex::Off, Duplex::LongEdge, Duplex::ShortEdge] { + let original = Job { duplex: Some(choice), ..Job::default() }; + let resolved = resolve(&original, |_| panic!("explicit choice must skip driver settings")); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, Some(choice)); + } + } + + #[test] + fn system_default_queue_is_resolved_once_and_pinned_to_the_job() { + let original = Job::default(); + let resolved = resolve(&original, |selected| { + assert_eq!(selected, None); + Ok(("System default".into(), Some(Duplex::ShortEdge))) + }); + assert_eq!(resolved.printer.as_deref(), Some("System default")); + assert_eq!(resolved.duplex, Some(Duplex::ShortEdge)); + } + + #[test] + fn missing_duplex_field_is_left_to_the_driver_and_invalid_values_fail() { + assert_eq!(duplex(&settings(0, 99)).unwrap(), None); + for value in [0, -1, 4, i16::MAX] { + assert!(duplex(&settings(0x1000, value)).is_err()); + } + let original = Job::default(); + let resolved = resolve(&original, |_| Ok(("Simplex".into(), None))); + assert_eq!(resolved.duplex, None); + } + + #[test] + fn a_queue_lookup_failure_preserves_the_original_job() { + let original = Job::default(); + let resolved = resolve(&original, |_| Err(PrintError::Spool("driver unavailable".into()))); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer, original.printer); + } + + #[test] + fn failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue() { + for value in [Err(PrintError::Spool("driver unavailable".into())), duplex(&settings(0x1000, 99))] { + let original = Job { copies: 3, ..Job::default() }; + let resolved = resolve(&original, |_| Ok(("Resolved queue".into(), driver_default(value)))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer.as_deref(), Some("Resolved queue")); + assert_eq!(resolved.copies, 3); + assert_eq!(original.printer, None); + } + } + + #[test] + fn manual_duplex_defaults_remain_unspecified_without_automatic_support() { + for binding in [2, 3] { + for support in [Some(false), None, Some(true)] { + let original = Job { printer: Some("Manual duplex".into()), ..Job::default() }; + let value = duplex(&settings(0x1000, binding)).unwrap(); + let resolved = resolve(&original, |_| Ok(("Manual duplex".into(), supported_default(value, || support)))); + assert_eq!(resolved.duplex, if support == Some(true) { value } else { None }); + assert_eq!(resolved.printer.as_deref(), Some("Manual duplex")); + assert_eq!(original.duplex, None); + } + } + assert_eq!(supported_default(Some(Duplex::Off), || panic!("simplex needs no capability query")), Some(Duplex::Off)); + assert_eq!(supported_default(None, || panic!("unspecified needs no capability query")), None); + } + + /// Reads settings only: never submits a job or changes the printer's preferences. + #[cfg(windows)] + #[test] + #[ignore = "needs PDFCRAFT_PRINT_TEST_PRINTER naming an installed Windows printer; sends no job"] + fn installed_windows_printer_default_is_carried_explicitly() { + let printer = std::env::var("PDFCRAFT_PRINT_TEST_PRINTER").unwrap(); + let original = Job { printer: Some(printer.clone()), ..Job::default() }; + let expected = supported_default(duplex(&craft_print::rgb::default_settings(&printer).unwrap()).unwrap(), || { + super::super::printers().into_iter().find(|p| p.name == printer).and_then(|p| p.two_sided) + }); + let resolved = job(&original); + assert_eq!(resolved.printer.as_deref(), Some(printer.as_str())); + assert_eq!(resolved.duplex, expected); + eprintln!("native Windows default: {expected:?}; resolved job: {:?}", resolved.duplex); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index f870a91a2c..686928b06c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) @@ -251,7 +253,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. 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.
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: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). 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). | +| 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).
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). | | 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); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -318,6 +320,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4da56e8544..c2b243b2f3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8273,8 +8273,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] -notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default, as in Acrobat: nothing is sent, so the printer's own default (its Properties) applies until Off or an edge is chosen (doc_print: duplex \"printer\"). Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer", "crates/print/src/spool/windows_duplex.rs::printer_default_reaches_the_windows_job_for_each_binding", "crates/print/src/spool/windows_duplex.rs::failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue", "crates/print/src/spool/windows_duplex.rs::manual_duplex_defaults_remain_unspecified_without_automatic_support"] +notes = "Manual two-sided defaults stay unspecified unless driver capabilities confirm automatic duplex (#5772); long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default: Windows reads the driver's current DEVMODE duplex and sends it explicitly (#5130), logging lookup failures and leaving duplex unspecified as a fallback; CUPS leaves duplex unspecified so the queue default applies (doc_print: duplex \"printer\"). Off or an edge overrides either. Physical output still needs hardware validation. Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" [[feature]] id = "print.n-up"