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>
This commit is contained in:
authored and GitHub committed 2026-11-10 10:25:54 +01:00
1 parent b7cfbd3980
commit 4bff30cf62
9 files changed
+731 -2

No files matched your search

+1
View File
@@ -277,6 +277,7 @@ impl Automation {
}
(None, Some(printer)) => {
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?;
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
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\")")),
+1
View File
@@ -56,6 +56,7 @@ mod session;
pub mod signature_image;
pub mod signature_scan;
mod sources;
pub mod spooler;
mod stamp_preview;
#[cfg(test)]
mod test_hooks;
+110
View File
@@ -0,0 +1,110 @@
//! 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);
}
}
}
+10
View File
@@ -147,6 +147,16 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
action; a bookmark keeps its title and children. Links within the document and web links
(`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error.
Apart from optimizing, `tile_large_images` redraws large pictures for Windows printing (#5131).
Windows' PDF renderer draws an image of more than 2²² pixels from a copy shrunk to that size, so
in the copy of a print that goes to the spooler (`pdfcraft_engine::spooler::spooler_copy`) each
such image becomes a form XObject of the same object number drawing tiles, one every 2000 pixels.
Each overlaps the next by a pixel, against seams on drivers that soften image edges, so a tile has
at most 2001 pixels a side; except for an image with a soft mask, whose see-through pixels would
be blended twice there. A soft or stencil
mask of the image's size is cut the same way. JPEG 2000, JPEGs that aren't grey or RGB, masks of
another size and images over 80 million pixels are left whole.
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
then this, then merges identical objects and writes a full, compressed save
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
+5
View File
@@ -29,6 +29,9 @@
//! runs alongside. Links that open another document, and bookmark actions that do, are discarded
//! here (Discard User Data ▸ Discard external cross-references, see
//! [`Settings::discard_external_refs`]).
//!
//! Apart from optimizing, [`tile_large_images`] redraws large pictures as tiles in the copy of a
//! document printed on Windows, whose PDF renderer shrinks any image over 2²² pixels.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -40,6 +43,7 @@ mod images;
mod links;
mod pool;
mod samples;
mod tiles;
mod unused;
pub use audit::{SpaceCategory, SpaceUse, audit_space};
@@ -50,6 +54,7 @@ use pdfcraft_filters::{Filter, Params};
pub use images::{ImageTarget, effective_resolutions, recolour_image};
pub use pool::give_way_to_other_programs;
pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
pub use tiles::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images};
#[derive(Debug, thiserror::Error)]
pub enum OptimizeError {
+357
View File
@@ -0,0 +1,357 @@
//! Large pictures drawn as tiles, for Windows printing (#5131).
//!
//! Windows prints each page through its own PDF renderer (`Windows.Data.Pdf`), which draws an
//! image of more than 2²² pixels from a copy shrunk to that size, however finely the page is
//! printed: a 600 ppi scan of a page prints at about 200 ppi. Drawn as tiles under that size, the
//! same picture keeps every pixel, and tiles that meet leave no seam (measured in #5131).
//! [`tile_large_images`] does this to the copy of a document that goes to the spooler.
use std::collections::HashSet;
use std::fmt::Write as _;
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
use pdfcraft_filters::Filter;
use crate::images::{self, MAX_PIXELS};
/// Images of more pixels than this are printed from a smaller copy on Windows: every larger image
/// in #5131 came out at 4,194,304 pixels.
pub const DOWNSAMPLED_ABOVE: u64 = 1 << 22;
/// A tile's side in pixels, before its overlap. With the overlap a tile has at most 2001 × 2001
/// pixels, under [`DOWNSAMPLED_ABOVE`]. A multiple of 8, so that in every row a tile's samples
/// start on a byte whatever the image's bits per pixel.
const TILE: u32 = 2000;
/// Pixels each tile reaches into the next one, so that rounding in the tiles' matrices, or a
/// printer driver that softens an image's edges, can't open a seam between them.
/// `Windows.Data.Pdf` itself leaves none either way, turned and scaled too (#5131). Not for an image
/// with a soft mask: where two tiles overlap, a pixel the mask makes see-through would be blended
/// twice and come out darker. The same goes for an opaque image drawn see-through (`/ca` below 1),
/// which this can't tell from the image: a faint line one pixel wide, against a seam on paper for
/// every tiled image on such a driver.
const OVERLAP: u32 = 1;
/// What [`tile_large_images`] did.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TileReport {
/// Images now drawn as tiles.
pub tiled: usize,
/// Images over [`DOWNSAMPLED_ABOVE`] left whole: JPEG 2000, a JPEG that isn't grey or RGB or
/// whose colour transform readers disagree about, a mask of another size than its image, more
/// than 80 million pixels, data shorter than the image, or past the decode allowance.
pub left_whole: usize,
}
/// Replaces every image XObject of more than [`DOWNSAMPLED_ABOVE`] pixels with a form XObject of
/// the same object number that draws it as tiles of at most [`TILE`] pixels a side, overlapping
/// by [`OVERLAP`] unless it has a soft mask. Whatever draws the image (pages, forms, patterns, appearances) then draws the
/// tiles, unchanged. The samples are copied as they are, colour space, decode array and
/// interpolation included; a JPEG's are decoded and stored with Flate. A soft mask or stencil
/// mask of the image's size is cut into the same tiles. An image used as another's mask goes with
/// that image, and is otherwise left whole, as are the images [`TileReport::left_whole`] lists.
pub fn tile_large_images(doc: &mut Document) -> TileReport {
let mut budget = ImageBudget::new(doc);
let mut large = Vec::new();
let mut masks = HashSet::new();
for (r, object) in doc.scan_objects() {
let Object::Stream(s) = &*object else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
masks.extend([b"SMask".as_slice(), b"Mask"].into_iter().filter_map(|key| s.dict.reference(key)));
if size(s).is_some_and(|(w, h)| u64::from(w) * u64::from(h) > DOWNSAMPLED_ABOVE) {
large.push(r);
}
}
let mut report = TileReport::default();
for r in large.into_iter().filter(|r| !masks.contains(r)) {
let object = doc.get(r);
let Object::Stream(image) = &*object else { continue };
match tiles(doc, image, &mut budget) {
Some(tiles) => {
let form = tiles.store(doc, image);
doc.set(r, Object::Stream(form));
report.tiled += 1;
}
None => report.left_whole += 1,
}
}
report
}
/// An image's `/Width` and `/Height`, when both are positive.
fn size(s: &Stream) -> Option<(u32, u32)> {
let side = |key: &[u8]| s.dict.int(key).and_then(|v| u32::try_from(v).ok()).filter(|v| *v > 0);
Some((side(b"Width")?, side(b"Height")?))
}
/// An image's samples, decoded: rows of `bits_per_pixel` × width bits each, padded to a byte.
/// Kept as the image stores them, in its own colour space and depth (CMYK, indexed, 16-bit, a
/// stencil mask), so each tile redraws exactly the picture it replaces. That is why this is not
/// the crate's [`crate::Samples`], which turns an image into 8-bit grey or RGB for tools that
/// repaint it and refuses the rest.
struct StoredSamples {
data: Vec<u8>,
bits_per_pixel: usize,
/// Bits per colour component as stored: 8 for a decoded JPEG.
bits_per_component: i64,
}
impl StoredSamples {
/// Image `s`, `w` × `h`, decoded. A JPEG gives eight-bit grey or RGB ([`images::pixels`]),
/// anything else its samples as they are stored ([`ImageBudget::decode`]). A soft mask is grey
/// whether or not it says so: ISO 32000-2 requires `/ColorSpace /DeviceGray` of one.
fn decode(doc: &Document, s: &Stream, (w, h): (u32, u32), soft_mask: bool, budget: &mut ImageBudget) -> Option<StoredSamples> {
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let stencil = matches!(s.dict.get(b"ImageMask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Bool(true)));
let components = |colour_space: fn(&Document, &Object) -> Option<usize>| match s.dict.get(b"ColorSpace") {
Some(cs) => colour_space(doc, cs),
None => soft_mask.then_some(1),
};
match s.filters().last().map(|(f, _)| f.clone()) {
Some(Filter::Jpx) => None,
Some(Filter::Dct) if !stencil => {
let components = components(images::components)?;
let data = images::pixels(doc, s, components, budget)?;
Some(StoredSamples { data, bits_per_pixel: components.checked_mul(8)?, bits_per_component: 8 })
}
Some(Filter::Dct) => None,
_ => {
let (components, bits_per_component) =
if stencil { (1, 1) } else { (components(sample_components)?, s.dict.int(b"BitsPerComponent")?) };
if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) {
return None;
}
let bits_per_pixel = components.checked_mul(usize::try_from(bits_per_component).ok()?)?;
let len = row_bytes(w, bits_per_pixel)?.checked_mul(usize::try_from(h).ok()?)?;
let mut data = budget.decode(doc, s, images::allowance(len, h)?).ok()?;
// Fewer bytes would leave rows unpainted; readers ignore what follows the last row.
if data.len() < len {
return None;
}
data.truncate(len);
Some(StoredSamples { data, bits_per_pixel, bits_per_component })
}
}
}
/// The samples of columns `x` and rows `y` (start and end), rows padded to a byte. `x.0` is a
/// multiple of [`TILE`], so of 8: each row of the tile starts on a byte of the image's row.
fn cut(&self, w: u32, x: (u32, u32), y: (u32, u32)) -> Option<Vec<u8>> {
let stride = row_bytes(w, self.bits_per_pixel)?;
let start = usize::try_from(x.0).ok()?.checked_mul(self.bits_per_pixel)? / 8;
let len = row_bytes(x.1.checked_sub(x.0)?, self.bits_per_pixel)?;
let mut out = Vec::with_capacity(len.checked_mul(usize::try_from(y.1.checked_sub(y.0)?).ok()?)?);
for row in y.0..y.1 {
let from = usize::try_from(row).ok()?.checked_mul(stride)?.checked_add(start)?;
out.extend_from_slice(self.data.get(from..from.checked_add(len)?)?);
}
Some(out)
}
}
/// Bytes in a row of `w` pixels of `bits_per_pixel` bits, padded to a byte.
fn row_bytes(w: u32, bits_per_pixel: usize) -> Option<usize> {
Some(usize::try_from(w).ok()?.checked_mul(bits_per_pixel)?.div_ceil(8))
}
/// Colour components in a sample of colour space `cs` (ISO 32000-2 §8.6): `None` for a space an
/// image can't be in, or one that can't be read.
fn sample_components(doc: &Document, cs: &Object) -> Option<usize> {
match &*doc.resolve(cs) {
Object::Name(n) => match n.as_slice() {
b"DeviceGray" | b"G" => Some(1),
b"DeviceRGB" | b"RGB" => Some(3),
b"DeviceCMYK" | b"CMYK" => Some(4),
_ => None,
},
Object::Array(a) => match doc.resolve(a.first()?).as_name()? {
b"CalGray" | b"Indexed" | b"I" | b"Separation" => Some(1),
b"CalRGB" | b"Lab" => Some(3),
b"ICCBased" => match &*doc.resolve(a.get(1)?) {
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| (1..=32).contains(n)),
_ => None,
},
b"DeviceN" => Some(doc.resolve(a.get(1)?).as_array()?.len()).filter(|n| (1..=32).contains(n)),
_ => None,
},
_ => None,
}
}
/// The spans, start and end, an image side of `len` pixels is cut into: one every [`TILE`],
/// each `overlap` longer where the side goes on. None lies wholly inside the one before's overlap.
fn spans(len: u32, overlap: u32) -> impl Iterator<Item = (u32, u32)> {
(0..len.saturating_sub(overlap).div_ceil(TILE).max(1)).map(move |i| {
let start = i.saturating_mul(TILE);
(start, start.saturating_add(TILE).saturating_add(overlap).min(len))
})
}
/// One tile of an image: the columns and rows it covers (start and end), its samples, and its
/// mask's when the image has a mask stream.
struct Tile {
x: (u32, u32),
y: (u32, u32),
image: Stream,
mask: Option<Stream>,
}
/// An image cut into tiles, with its mask cut the same way, ready to be stored.
struct Tiles {
size: (u32, u32),
tiles: Vec<Tile>,
/// The key the image names its mask by: `SMask` or `Mask`.
mask_key: &'static [u8],
}
/// Image `image` cut into tiles, or `None` when it can't be read faithfully.
fn tiles(doc: &Document, image: &Stream, budget: &mut ImageBudget) -> Option<Tiles> {
let dims = size(image)?;
let samples = StoredSamples::decode(doc, image, dims, false, budget)?;
// A soft mask overrides `/Mask` (ISO 32000-2, the image dictionary's `/SMask`). A mask stream
// goes into the same tiles, so it has to have the image's size.
let soft = image.dict.contains(b"SMask");
let mask_key: &'static [u8] = if soft { b"SMask" } else { b"Mask" };
let mask_object = image.dict.get(mask_key).map(|m| doc.resolve(m));
let mask = match mask_object.as_deref() {
Some(Object::Stream(m)) if size(m) == Some(dims) => Some((m, StoredSamples::decode(doc, m, dims, soft, budget)?)),
// A colour key mask goes on each tile as it is (`tile_dict`).
Some(Object::Array(_)) if !soft => None,
None | Some(Object::Null) => None,
Some(_) => return None,
};
let mut tiles = Vec::new();
// Overlapping tiles blend a see-through pixel twice ([`OVERLAP`]).
let overlap = if soft { 0 } else { OVERLAP };
for y in spans(dims.1, overlap) {
for x in spans(dims.0, overlap) {
let tile = |s: &Stream, samples: &StoredSamples| -> Option<Stream> {
let dict = tile_dict(doc, &s.dict, (x.1.saturating_sub(x.0), y.1.saturating_sub(y.0)), samples.bits_per_component);
Some(Stream::flate(dict, &samples.cut(dims.0, x, y)?))
};
let mask_tile = match &mask {
Some((m, samples)) => Some(tile(m, samples)?),
None => None,
};
tiles.push(Tile { x, y, image: tile(image, &samples)?, mask: mask_tile });
}
}
Some(Tiles { size: dims, tiles, mask_key })
}
/// The dictionary of a tile `w` × `h` of the image whose dictionary is `image`: what paints its
/// samples, and nothing that belongs to the image as a whole (its mask goes on separately; its
/// optional content, structure and metadata stay with the form that replaces it).
fn tile_dict(doc: &Document, image: &Dict, (w, h): (u32, u32), bits_per_component: i64) -> Dict {
let mut d = Dict::new();
d.set("Type", Object::name("XObject"));
d.set("Subtype", Object::name("Image"));
d.set("Width", Object::Int(i64::from(w)));
d.set("Height", Object::Int(i64::from(h)));
d.set("BitsPerComponent", Object::Int(bits_per_component));
for key in [b"ColorSpace".as_slice(), b"Decode", b"ImageMask", b"Interpolate", b"Intent", b"Matte"] {
if let Some(v) = image.get(key) {
d.set(key.to_vec(), v.clone());
}
}
// A colour key mask lists sample values, which the tiles keep.
if let Some(Object::Array(key)) = image.get(b"Mask").map(|m| doc.resolve(m)).as_deref() {
d.set("Mask", Object::Array(key.clone()));
}
d
}
impl Tiles {
/// Adds the tiles to `doc` and returns the form XObject that draws them where `image` was: the
/// unit square, each tile in its share of it, the image's top row at the top.
fn store(self, doc: &mut Document, image: &Stream) -> Stream {
let (w, h) = (f64::from(self.size.0), f64::from(self.size.1));
let mut names = Dict::new();
let mut content = String::new();
for (i, Tile { x, y, image: mut tile, mask }) in self.tiles.into_iter().enumerate() {
if let Some(mask) = mask {
tile.dict.set(self.mask_key.to_vec(), Object::Ref(doc.add(Object::Stream(mask))));
}
names.set(format!("T{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(tile))));
let [a, d] = [f64::from(x.1.saturating_sub(x.0)) / w, f64::from(y.1.saturating_sub(y.0)) / h];
let [e, f] = [f64::from(x.0) / w, 1.0 - f64::from(y.1) / h];
// Writing to a String can't fail.
let _ = writeln!(content, "q {} 0 0 {} {} {} cm /T{i} Do Q", number(a), number(d), number(e), number(f));
}
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(names));
let mut form = Dict::new();
form.set("Type", Object::name("XObject"));
form.set("Subtype", Object::name("Form"));
form.set("BBox", Object::Array([0, 0, 1, 1].map(Object::Int).to_vec()));
form.set("Resources", Object::Dict(resources));
if let Some(oc) = image.dict.get(b"OC") {
form.set("OC", oc.clone());
}
Stream::flate(form, content.as_bytes())
}
}
/// `v` in a content stream: ten decimals, enough for a pixel of an image 80 million wide, trailing
/// zeros dropped.
fn number(v: f64) -> String {
let s = format!("{v:.10}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sides_are_cut_every_tile_with_an_overlap() {
let spans = |len| spans(len, OVERLAP).collect::<Vec<_>>();
assert_eq!(spans(1), [(0, 1)]);
assert_eq!(spans(2000), [(0, 2000)]);
// The 2001st pixel is the first tile's overlap: no tile of its own.
assert_eq!(spans(2001), [(0, 2001)]);
assert_eq!(spans(2002), [(0, 2001), (2000, 2002)]);
assert_eq!(spans(6000), [(0, 2001), (2000, 4001), (4000, 6000)]);
// Every pixel is in a tile, and no tile has more than its side and the overlap.
for len in [1, 1999, 2000, 2001, 2002, 4001, 4002, 6000, 123_457] {
let s = spans(len);
let zero: Vec<_> = super::spans(len, 0).collect();
assert!(zero.windows(2).all(|w| w[1].0 == w[0].1), "{len}: tiles without an overlap meet exactly");
assert_eq!(s.first().map(|t| t.0), Some(0));
assert_eq!(s.last().map(|t| t.1), Some(len));
assert!(s.windows(2).all(|w| w[1].0 < w[0].1), "{len}: a gap");
assert!(s.iter().all(|(a, b)| b - a <= TILE + OVERLAP && a % 8 == 0), "{len}");
}
}
#[test]
fn a_tile_takes_whole_bytes_of_each_row_at_any_bit_depth() {
// Two rows of 4004 one-bit pixels: 501 bytes each, the last holding 4 pixels.
let row: Vec<u8> = (0..501).map(|i| i as u8).collect();
let samples = StoredSamples { data: [row.clone(), row.iter().map(|b| !b).collect()].concat(), bits_per_pixel: 1, bits_per_component: 1 };
let tile = samples.cut(4004, (4000, 4004), (0, 2)).unwrap();
assert_eq!(tile, [row[500], !row[500]]);
let first = samples.cut(4004, (0, 2001), (1, 2)).unwrap();
assert_eq!(first.len(), 251);
assert_eq!(first[0], !row[0]);
// Sixteen-bit RGB: six bytes a pixel.
let rgb16 = StoredSamples { data: (0..=255u8).cycle().take(6 * 2002).collect(), bits_per_pixel: 48, bits_per_component: 16 };
assert_eq!(rgb16.cut(2002, (2000, 2002), (0, 1)).unwrap(), rgb16.data[12_000..12_012]);
// Rows past the data are no tile, not a panic.
assert_eq!(samples.cut(4004, (0, 8), (1, 3)), None);
}
#[test]
fn numbers_are_written_plainly() {
assert_eq!(number(0.0), "0");
assert_eq!(number(1.0), "1");
assert_eq!(number(0.5), "0.5");
assert_eq!(number(2001.0 / 35_000.0), "0.0571714286");
assert_eq!(number(1.0 - 4001.0 / 6000.0), "0.3331666667");
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Large pictures drawn as tiles for Windows printing (#5131): the page looks the same, and no
//! image it draws is over the size Windows' renderer shrinks.
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use pdfcraft_optimize::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images};
/// 4200 × 1000: just over [`DOWNSAMPLED_ABOVE`], three tiles wide.
const W: u32 = 4200;
const H: u32 = 1000;
fn image_dict(w: u32, h: u32, entries: &[(&str, Object)]) -> Dict {
let mut d = Dict::new();
d.set("Type", Object::name("XObject"));
d.set("Subtype", Object::name("Image"));
d.set("Width", Object::Int(i64::from(w)));
d.set("Height", Object::Int(i64::from(h)));
for (k, v) in entries {
d.set(*k, v.clone());
}
d
}
/// Samples that change at every pixel, so a tile out of place by one shows: `n` bytes a pixel.
fn texture(w: u32, h: u32, n: usize) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).flat_map(move |x| (0..n).map(move |c| (x.wrapping_mul(7) ^ y.wrapping_mul(13) ^ (c as u32 * 85)) as u8))).collect()
}
/// A document with one page `w` × `h` points that draws image `im` (added by `add`) over it
/// after `before` (a fill colour, say), so that at scale 1 each sample is a pixel.
fn page_drawing(w: u32, h: u32, before: &str, add: impl FnOnce(&mut Document) -> ObjRef) -> (Document, ObjRef) {
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 im = add(&mut doc);
let mut xobjects = Dict::new();
xobjects.set("Im", Object::Ref(im));
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(xobjects));
let content = format!("{before} q {w} 0 0 {h} 0 0 cm /Im Do Q");
let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.into_bytes())));
let mut p = Dict::new();
p.set("Type", Object::name("Page"));
p.set("Parent", Object::Ref(pages));
p.set("MediaBox", Object::Array(vec![0.into(), 0.into(), Object::Int(i64::from(w)), Object::Int(i64::from(h))]));
p.set("Resources", Object::Dict(resources));
p.set("Contents", Object::Ref(c));
let r = doc.add(Object::Dict(p));
doc.update_dict(pages, |d| {
d.set("Count", Object::Int(1));
d.set("Kids", Object::Array(vec![Object::Ref(r)]));
})
.unwrap();
(doc, im)
}
fn render(doc: &Document) -> Vec<u8> {
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
out.rgba.to_vec()
}
/// Tiles the image of `doc`, then checks that the image is now a form that draws `count` tiles under
/// the cap, and that the page renders as before, within `tolerance` a channel.
fn tiles_and_looks_the_same(mut doc: Document, im: ObjRef, count: usize, tolerance: u8) {
let before = render(&doc);
assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 1, left_whole: 0 });
let form = doc.get(im);
let Object::Stream(form) = &*form else { panic!("the image isn't a stream") };
assert_eq!(form.dict.name(b"Subtype"), Some(b"Form".as_slice()));
let tiles = form.dict.get(b"Resources").and_then(Object::as_dict).and_then(|r| r.get(b"XObject")).and_then(Object::as_dict).unwrap();
assert_eq!(tiles.len(), count);
for (_, tile) in tiles.iter() {
let tile = doc.get(tile.as_ref().unwrap());
let Object::Stream(t) = &*tile else { panic!("a tile isn't a stream") };
let pixels = t.dict.int(b"Width").unwrap() * t.dict.int(b"Height").unwrap();
assert!(pixels as u64 <= DOWNSAMPLED_ABOVE, "a tile of {pixels} pixels");
}
let after = render(&doc);
assert_eq!(before.len(), after.len());
let worst = before.iter().zip(&after).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(worst <= tolerance, "the tiled page differs by {worst} in a channel");
}
#[test]
fn rgb_samples_are_cut_to_the_pixel() {
let (doc, im) = page_drawing(W, H, "", |doc| {
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]);
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
});
tiles_and_looks_the_same(doc, im, 3, 0);
}
#[test]
fn one_bit_grey_and_four_bit_indexed_samples_are_cut_on_byte_boundaries() {
let bits: Vec<u8> = texture(W.div_ceil(8), H, 1);
let (doc, im) = page_drawing(W, H, "", |doc| {
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(1))]);
doc.add(Object::Stream(Stream::flate(d, &bits)))
});
tiles_and_looks_the_same(doc, im, 3, 0);
let palette: Vec<u8> = (0..48u8).map(|i| i.wrapping_mul(37)).collect();
let nibbles: Vec<u8> = texture(W.div_ceil(2), H, 1);
let (doc, im) = page_drawing(W, H, "", |doc| {
let indexed = Object::Array(vec![
Object::name("Indexed"),
Object::name("DeviceRGB"),
Object::Int(15),
Object::String(pdfcraft_cos::PdfString::literal(palette.clone())),
]);
let d = image_dict(W, H, &[("ColorSpace", indexed), ("BitsPerComponent", Object::Int(4))]);
doc.add(Object::Stream(Stream::flate(d, &nibbles)))
});
tiles_and_looks_the_same(doc, im, 3, 0);
}
#[test]
fn a_cmyk_image_with_a_decode_array_keeps_both() {
let (doc, im) = page_drawing(W, H, "", |doc| {
let decode = Object::Array([1, 0, 0, 1, 1, 0, 0, 1].map(Object::Int).to_vec());
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceCMYK")), ("BitsPerComponent", Object::Int(8)), ("Decode", decode)]);
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 4))))
});
tiles_and_looks_the_same(doc, im, 3, 0);
}
#[test]
fn a_soft_mask_is_cut_into_the_same_tiles() {
let (doc, im) = page_drawing(W, H, "0 0 1 rg 0 0 4200 1000 re f", |doc| {
// Without `/ColorSpace`, as many writers leave a soft mask.
let mask = image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]);
let mask = doc.add(Object::Stream(Stream::flate(mask, &texture(W, H, 1))));
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]);
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
});
tiles_and_looks_the_same(doc, im, 3, 0);
}
#[test]
fn a_stencil_mask_paints_the_fill_colour_through_its_tiles() {
let bits: Vec<u8> = texture(W.div_ceil(8), H, 1);
let (doc, im) = page_drawing(W, H, "1 0 0 rg", |doc| {
let d = image_dict(W, H, &[("ImageMask", Object::Bool(true))]);
doc.add(Object::Stream(Stream::flate(d, &bits)))
});
tiles_and_looks_the_same(doc, im, 3, 0);
}
#[test]
fn a_jpeg_is_decoded_into_its_tiles() {
let px = texture(W, H, 3);
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95).encode(&px, W, H, image::ExtendedColorType::Rgb8).unwrap();
let (doc, im) = page_drawing(W, H, "", |doc| {
let d = image_dict(
W,
H,
&[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("Filter", Object::name("DCTDecode"))],
);
doc.add(Object::Stream(Stream::from_raw(d, jpeg)))
});
// The renderer's JPEG decoder and the one tiling uses may round a sample differently.
tiles_and_looks_the_same(doc, im, 3, 2);
}
/// Adds an image to a document.
type AddImage = Box<dyn Fn(&mut Document) -> ObjRef>;
#[test]
fn images_it_cannot_cut_faithfully_are_left_whole() {
fn rgb() -> Dict {
image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))])
}
let cases: Vec<(&str, AddImage)> = vec![
(
"JPEG 2000",
Box::new(|doc| {
let mut d = rgb();
d.set("Filter", Object::name("JPXDecode"));
doc.add(Object::Stream(Stream::from_raw(d, b"not decoded here".to_vec())))
}),
),
("data short of the image", Box::new(|doc| doc.add(Object::Stream(Stream::flate(rgb(), &texture(W, H - 1, 3)))))),
(
"a soft mask of another size",
Box::new(|doc| {
let mask = doc.add(Object::Stream(Stream::flate(image_dict(10, 10, &[("BitsPerComponent", Object::Int(8))]), &[0; 100])));
let mut d = rgb();
d.set("SMask", Object::Ref(mask));
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
}),
),
(
"an unknown colour space",
Box::new(|doc| {
let mut d = rgb();
d.set("ColorSpace", Object::name("Fancy"));
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
}),
),
(
"12 bits a component",
Box::new(|doc| {
let mut d = rgb();
d.set("BitsPerComponent", Object::Int(12));
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
}),
),
];
for (what, add) in cases {
let (mut doc, im) = page_drawing(W, H, "", add);
let before = doc.get(im);
assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 0, left_whole: 1 }, "{what}");
assert!(Arc::ptr_eq(&before, &doc.get(im)) || *before == *doc.get(im), "{what}: changed");
}
}
#[test]
fn small_images_and_a_large_mask_of_a_small_image_are_not_touched() {
let (mut doc, im) = page_drawing(W, H, "", |doc| {
let mask = doc.add(Object::Stream(Stream::flate(image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]), &texture(W, H, 1))));
// Exactly the cap: drawn whole by Windows.
let d =
image_dict(2048, 2048, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]);
doc.add(Object::Stream(Stream::flate(d, &texture(2048, 2048, 1))))
});
let before = doc.get(im);
assert_eq!(tile_large_images(&mut doc), TileReport::default());
assert_eq!(*before, *doc.get(im));
}
#[test]
fn a_grid_of_tiles_keeps_its_rows_and_columns_in_place() {
// 2100 × 2100: two tiles a side, the top row first in the samples and at the top of the page.
let (doc, im) = page_drawing(2100, 2100, "", |doc| {
let d = image_dict(2100, 2100, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]);
doc.add(Object::Stream(Stream::flate(d, &texture(2100, 2100, 3))))
});
tiles_and_looks_the_same(doc, im, 4, 0);
}
+3
View File
@@ -565,6 +565,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);
// The receiver may be gone (the app quit): nothing to report to then.
let result = match &spooler {
Some(submit) => submit(&bytes, &job),
+2 -2
View File
@@ -8201,8 +8201,8 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["printers", "printer_options", "doc_print"]
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer."
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing", "crates/optimize/tests/tiles.rs::a_grid_of_tiles_keeps_its_rows_and_columns_in_place", "crates/engine/src/spooler.rs::a_large_image_goes_to_the_spooler_as_tiles"]
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate; an image of more than 2²² pixels as tiles under that size, which Windows.Data.Pdf would draw from a shrunk copy, #5131), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer."
[[feature]]
id = "print.copies-collate"