diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 55fec9ef55..0d433d014d 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -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\")")), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf5e4da7a4..161b2c1d34 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/spooler.rs b/crates/engine/src/spooler.rs new file mode 100644 index 0000000000..d3fbecd339 --- /dev/null +++ b/crates/engine/src/spooler.rs @@ -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) -> Vec { + 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>) -> Option> { + 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 { + 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 = (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); + } + } +} diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3207b69cab..08fb23d22b 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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 diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 97296844a2..750f641a07 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -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 { diff --git a/crates/optimize/src/tiles.rs b/crates/optimize/src/tiles.rs new file mode 100644 index 0000000000..efe71f0276 --- /dev/null +++ b/crates/optimize/src/tiles.rs @@ -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, + 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 { + 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| 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> { + 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 { + 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 { + 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 { + (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, +} + +/// An image cut into tiles, with its mask cut the same way, ready to be stored. +struct Tiles { + size: (u32, u32), + tiles: Vec, + /// 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 { + 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 { + 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::>(); + 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 = (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"); + } +} diff --git a/crates/optimize/tests/tiles.rs b/crates/optimize/tests/tiles.rs new file mode 100644 index 0000000000..12e5f60352 --- /dev/null +++ b/crates/optimize/tests/tiles.rs @@ -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 { + (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 { + 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 = 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 = (0..48u8).map(|i| i.wrapping_mul(37)).collect(); + let nibbles: Vec = 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 = 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 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); +} diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 2f43f74ddd..d4bc6aa951 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -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), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e2d32d8b56..0b9d4aad30 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"