mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 07:12:52 +00:00
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:
9 files changed
+731
-2
No files matched your search
@@ -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\")")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user