Files
pdfcraft/crates/engine/src/spooler.rs
T
4bff30cf62 M10: Windows prints a large image whole: drawn as tiles under Windows.Data.Pdf's cap (#5131) (#5897)
* M10: Windows prints a large image whole: drawn as tiles under Windows.Data.Pdf's cap (#5131)

Windows prints each page through Windows.Data.Pdf, which draws any image
of more than 2^22 pixels from a copy shrunk to that size. With the page
at the printer's resolution (craft-libs#396, #5023), a 6000 x 6000 scan
still printed as 2048 x 2048, its one-pixel detail gone.

pdfcraft_optimize::tile_large_images replaces each such image XObject
with a form XObject of the same object number that draws it as tiles of
at most 2000 pixels a side, so whatever drew the image draws the tiles.
Samples are copied as stored (any bit depth and colour space, decode
array, interpolation, colour key); a grey or RGB JPEG is decoded and
stored with Flate; a soft or stencil mask of the image's size is cut
into the same tiles. Tiles overlap by a pixel against drivers that
soften image edges, except under a soft mask, where the overlap would
blend see-through pixels twice. JPEG 2000, CMYK or ambiguous JPEGs,
masks of another size, more than 80 million pixels, short data and
images past the decode allowance are left whole.

pdfcraft_engine::spooler::spooler_copy applies it to the copy that goes
to the spooler on Windows, from the Print dialog's worker and doc_print;
elsewhere, or when the copy can't be read or written, the bytes go as
they are. The saved print-ready PDF is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: a test that the spooler gets the tiles on Windows; tiles are up to 2001 px (#5131)

spooler_copy was only tested with prints that pass through unchanged, so
one that skipped tiling on Windows would have passed. A test now checks
that on Windows it sends the tiled copy and elsewhere the bytes as they
are. The optimize README said tiles have at most 2000 pixels a side;
with the overlap they have 2001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: the tiles' samples are StoredSamples, apart from the crate's Samples (#5131)

main has since added a public `Samples` (8-bit grey or RGB, for tools
that repaint an image), so two different things in one crate shared a
name. The tiles keep an image's samples as stored, in its own colour
space and depth, so each tile redraws exactly what it replaces; the
type now says so in its name and doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 10:25:54 +01:00

111 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The print-ready PDF as the system spooler gets it.
use std::sync::Arc;
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> {
if !cfg!(windows) {
return bytes;
}
let bytes = Arc::new(bytes);
// 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 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>> {
guard(|| {
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
let report = pdfcraft_optimize::tile_large_images(&mut doc);
(report.tiled > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten()
})
.ok()
.flatten()
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
/// A one-page PDF drawing a `w` × `h` grey image over the page.
fn page_with_image(w: i64, h: i64) -> 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 mut image = Dict::new();
image.set("Type", Object::name("XObject"));
image.set("Subtype", Object::name("Image"));
image.set("Width", Object::Int(w));
image.set("Height", Object::Int(h));
image.set("ColorSpace", Object::name("DeviceGray"));
image.set("BitsPerComponent", Object::Int(8));
let samples: Vec<u8> = (0..w * h).map(|i| (i % 251) as u8).collect();
let image = doc.add(Object::Stream(Stream::flate(image, &samples)));
let mut xobjects = Dict::new();
xobjects.set("Im", Object::Ref(image));
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(xobjects));
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 612 0 0 792 0 0 cm /Im Do Q".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("Resources", Object::Dict(resources));
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()
}
#[test]
fn a_large_image_goes_to_the_spooler_as_tiles() {
let tiled = tiled(Arc::new(page_with_image(2100, 2100))).expect("tiled");
let doc = Document::open(Arc::new(tiled)).unwrap();
let largest = doc
.scan_objects()
.filter_map(|(_, o)| match &*o {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"Width")? * s.dict.int(b"Height")?),
_ => None,
})
.max();
// The page's image is a form now; its tiles are the images it draws.
assert_eq!(largest, Some(2001 * 2001));
}
#[test]
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 small = page_with_image(100, 100);
assert_eq!(spooler_copy(small.clone()), small);
}
/// 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());
if cfg!(windows) {
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
assert_ne!(sent, large);
} else {
assert_eq!(sent, large);
}
}
}