Print: flatten transparent sheets for GDI (v3) printer drivers (#6155, #5838, #5940) (#6955)

* Print: flatten transparent sheets for GDI (v3) printer drivers

Windows sends a job for a GDI (v3) driver through its XPS-to-GDI
conversion, which fails on soft masks (#6155), prints halos around them
(#5838) or prints transparency as black boxes (#5940). XPS (v4) drivers
print the same pages correctly.

spooler_copy takes a flatten_transparency flag. With it, after greying
and before tiling, each sheet that uses transparency is replaced by an
opaque picture of itself (spooler::flattened): rendered at 300 dpi, lower
for a sheet past 80 MP or 16000 px a side, in tiles of at most
2000 x 2000 px, each an RGB Flate image over white, so no tile reaches
Windows' 2^22 px image cap and tiling leaves them alone. The sheet keeps
its boxes and rotation; its group and annotations go. Sheets without
transparency stay vector.

pdfcraft-preflight gets page_uses_transparency, the PDF/A transparency
search without counting a transparency group on its own, which Word
puts on every page.

Both callers pass false until the GDI-driver check lands.

* Print: flatten transparent sheets only for a GDI (v3) printer driver

spool::xps_driver / spool::gdi_driver read whether the printer's driver renders XPS itself,
through craft-print's new xps_driver (storytold/craft-libs#804, winprint
storytold/winprint.rs#21). The Print dialog and doc_print pass gdi_driver to spooler_copy's
flatten_transparency, so XPS (v4) drivers, PDF printers among them, keep vector sheets.

craft-print pinned to the craft-libs#804 branch, winprint to #21's, until they merge.

* craft-print 60ede99985ef1e5df87a2aca66ed538eb23dec99: craft-libs main with #804 (xps_driver), winprint bc06a7c4 (#21)

* Cargo.lock: the two winprint entries in Cargo's order (by source, after the re-pin)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-11 08:41:07 +01:00
1 parent 495cba8efc
commit 235ede4896
9 files changed
+526 -41

No files matched your search

Generated
+24 -24
View File
@@ -1243,14 +1243,14 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1ee01a4c58729d781638b8afbd8982726dbdefcb#1ee01a4c58729d781638b8afbd8982726dbdefcb"
source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0)",
"winsafe",
]
@@ -8320,28 +8320,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396"
dependencies = [
"flate2",
"fmt-derive",
"reqwest",
"scopeguard",
"sigstore-trust-root",
"sigstore-types",
"sigstore-verify",
"strum",
"tar",
"thiserror 2.0.21",
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
[[package]]
name = "winprint"
version = "0.2.1"
@@ -8363,6 +8341,28 @@ dependencies = [
"xml",
]
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
dependencies = [
"flate2",
"fmt-derive",
"reqwest",
"scopeguard",
"sigstore-trust-root",
"sigstore-types",
"sigstore-verify",
"strum",
"tar",
"thiserror 2.0.21",
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
[[package]]
name = "winreg"
version = "0.55.0"
+1 -1
View File
@@ -26,7 +26,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "1ee01a4c58729d781638b8afbd8982726dbdefcb" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
+4 -1
View File
@@ -372,7 +372,10 @@ impl Automation {
(None, Some(printer)) => {
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
// A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS
// (v4) drivers print them as they are.
let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver);
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten);
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
+1 -1
View File
@@ -59,7 +59,7 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
}
/// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma).
fn over_white(rgba: &mut [u8], grayscale: bool) {
pub(crate) fn over_white(rgba: &mut [u8], grayscale: bool) {
for px in rgba.as_chunks_mut::<4>().0 {
let gap = 255 - u32::from(px[3]);
let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255));
+424 -12
View File
@@ -2,25 +2,51 @@
use std::sync::Arc;
use pdfcraft_cos::{SaveOptions, write_full};
use pdfcraft_cos::{Dict, Object, SaveOptions, Stream, write_full};
use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderConfig, RenderRequest, Tile};
use crate::guard;
/// The resolution [`spooler_copy`] flattens a sheet at, unless the sheet is too large for it
/// ([`MAX_FLATTENED_PIXELS`], [`MAX_FLATTENED_SIDE`]).
pub const FLATTEN_DPI: f64 = 300.0;
/// The side of the largest tile a flattened sheet is drawn with, in pixels: 2000 × 2000 stays
/// under the 2²² pixels past which Windows' PDF renderer shrinks an image (#5131), so [`tiled`]
/// leaves the tiles alone, and under the renderer's own tile limit.
pub const FLATTEN_TILE: u32 = 2000;
/// The most pixels one flattened sheet has. A 24 × 36 in sheet at 300 dpi (78 MP) keeps its
/// resolution; a larger one is drawn at a lower one. The sheet is rendered and encoded a tile at
/// a time, so this bounds the encoded picture (240 MB as raw RGB) and the time it takes, not the
/// memory one render needs.
pub const MAX_FLATTENED_PIXELS: f64 = 80.0e6;
/// The most pixels along a flattened sheet's longer side: a long banner gets a lower resolution
/// rather than hundreds of tiles, each of which interprets the whole sheet's content again.
pub const MAX_FLATTENED_SIDE: f64 = 16_000.0;
/// The most bytes of encoded pictures one copy holds. A sheet whose picture would pass it keeps
/// its vectors and prints as it would have without flattening.
const MAX_FLATTENED_BYTES: usize = 512 << 20;
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler
/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are.
/// with `job`. Three things happen on Windows only, in this order; elsewhere the bytes go as they
/// are.
///
/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so.
/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft
/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright
/// (#5806). The driver is still asked for grey where it has it.
/// - With `flatten_transparency`, each sheet that uses transparency becomes an opaque picture of
/// itself at [`FLATTEN_DPI`] ([`flattened`]). It is meant for GDI (v3) drivers, which get the
/// job through Windows' XPS-to-GDI conversion: that conversion fails on soft masks (#6155),
/// prints halos around them (#5838) or prints transparency as black boxes (#5940). It comes
/// after greying, so the picture is of the grey sheet.
/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows'
/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out
/// at the printer's resolution.
/// at the printer's resolution. A flattened sheet's tiles are already under that.
///
/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a
/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey,
/// When the copy can't be read or written, the bytes go as they are: no step ever stops a print,
/// and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey,
/// strictly, as before.
pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job) -> Vec<u8> {
pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job, flatten_transparency: bool) -> Vec<u8> {
if !cfg!(windows) {
return bytes;
}
@@ -35,6 +61,9 @@ pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job) -> Vec<u
None => {}
}
}
if flatten_transparency && let Some(flat) = flattened(bytes.clone(), FLATTEN_DPI) {
bytes = Arc::new(flat);
}
// 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()))
}
@@ -77,6 +106,154 @@ pub fn tiled(bytes: Arc<Vec<u8>>) -> Option<Vec<u8>> {
.flatten()
}
/// `bytes` with each page that uses transparency ([`pdfcraft_preflight::page_uses_transparency`])
/// replaced by an opaque picture of itself, or `None` when no page needs it, none could be
/// rendered, or the copy can't be read or written. Pages without transparency stay as they are.
///
/// The picture is at `dpi` (72 to 600; [`FLATTEN_DPI`] if not a number), lower on a sheet past
/// [`MAX_FLATTENED_PIXELS`] or [`MAX_FLATTENED_SIDE`], in tiles of at most [`FLATTEN_TILE`]
/// pixels a side, each one image (RGB over white, Flate) rendered and encoded on its own. The
/// page keeps its boxes, `/Rotate` and `/UserUnit`, so the sheet keeps its size and orientation.
/// Its transparency group goes, since nothing on it is transparent any more, and so do its
/// annotations, which the picture already shows. A print-ready PDF has none:
/// [`pdfcraft_print::impose`] draws them into the sheet.
///
/// A page whose picture can't be rendered keeps its vectors, as does one whose picture would take
/// the pictures past 512 MB encoded.
pub fn flattened(bytes: Arc<Vec<u8>>, dpi: f64) -> Option<Vec<u8>> {
guard(|| {
let mut doc = pdfcraft_cos::Document::open(bytes.clone()).ok()?;
let pages = pdfcraft_model::pages(&doc);
let transparent: Vec<usize> =
pages.iter().enumerate().filter(|(_, p)| pdfcraft_preflight::page_uses_transparency(&doc, &p.dict)).map(|(i, _)| i).collect();
if transparent.is_empty() {
return None;
}
// As Print as image renders (`print_raster`): printing is not the View event, comments
// are drawn, and the sheet clears to white; `PRINT_TAG` turns reading colours off. The
// print-ready PDF already holds the layers as they print.
let config = RenderConfig { for_print: true, ..RenderConfig::default() };
let mut renderer = PageRenderer::new(bytes, config);
// The renderer must list the same pages, or a sheet could get another one's picture.
if renderer.page_count() != pages.len() {
return None;
}
let dpi = if dpi.is_finite() { dpi.clamp(72.0, 600.0) } else { FLATTEN_DPI };
let mut budget = MAX_FLATTENED_BYTES;
let mut done = 0usize;
for i in transparent {
let Some(page) = pages.get(i) else { continue };
let Some(picture) = sheet_picture(&doc, &mut renderer, page, i, dpi, &mut budget) else { continue };
let mut xobjects = Dict::new();
for (name, image) in picture.tiles {
xobjects.set(name, Object::Ref(doc.add(Object::Stream(image))));
}
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(xobjects));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), &picture.content)));
let replaced = doc.update_dict(page.obj, |d| {
d.set("Contents", Object::Ref(contents));
d.set("Resources", Object::Dict(resources));
d.remove(b"Annots");
d.remove(b"Group");
});
if replaced.is_ok() {
done += 1;
}
}
(done > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten()
})
.ok()
.flatten()
}
/// A page drawn as tiles: the content that draws them, and each tile's resource name and image.
struct Picture {
content: Vec<u8>,
tiles: Vec<(Vec<u8>, Stream)>,
}
/// The scale (pixels per point) a `w` × `h` pt sheet is flattened at for `dpi`: lowered so the
/// sheet stays within [`MAX_FLATTENED_PIXELS`] and [`MAX_FLATTENED_SIDE`].
fn flatten_scale(w: f64, h: f64, dpi: f64) -> f64 {
let (w, h) = (w.max(1.0), h.max(1.0));
(dpi / 72.0).min((MAX_FLATTENED_PIXELS / (w * h)).sqrt()).min(MAX_FLATTENED_SIDE / w.max(h))
}
/// Page `index` of `doc` rendered by `renderer` as tiles at `dpi` (see [`flattened`]), with the
/// content that draws them where the page shows. `None` when a tile doesn't render, or the
/// tiles' encoded size would pass what is left of `budget`, which a page that succeeds is
/// charged.
fn sheet_picture(
doc: &pdfcraft_cos::Document,
renderer: &mut PageRenderer,
page: &pdfcraft_model::Page,
index: usize,
dpi: f64,
budget: &mut usize,
) -> Option<Picture> {
// The renderer draws the page as displayed (crop box, `/UserUnit`, `/Rotate`), y down; the
// view matrix takes that display space (y up) back to the page's user space.
let (w, h) = page.display_size(doc);
let scale = flatten_scale(w, h, dpi) as f32;
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let (width, height) = (pdfcraft_render::device_pixels(w as f32, scale), pdfcraft_render::device_pixels(h as f32, scale));
let s = f64::from(scale);
let mut content = format!("q {} cm\n", matrix(page.view_matrix(doc)));
let mut tiles = Vec::new();
let mut used = 0usize;
for y in (0..height).step_by(FLATTEN_TILE as usize) {
for x in (0..width).step_by(FLATTEN_TILE as usize) {
let (tw, th) = (FLATTEN_TILE.min(width - x), FLATTEN_TILE.min(height - y));
let r =
renderer.render(RenderRequest { page: index, scale, tile: Some(Tile { x, y, w: tw, h: th }), tag: PRINT_TAG, ..Default::default() });
let pixels = (tw as usize) * (th as usize);
if r.error.is_some() || r.width != tw || r.height != th || r.rgba.len() != pixels * 4 {
return None;
}
let mut rgba = r.rgba.to_vec();
crate::print_raster::over_white(&mut rgba, false);
let rgb: Vec<u8> = rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let mut image = Dict::new();
image.set("Type", Object::name("XObject"));
image.set("Subtype", Object::name("Image"));
image.set("Width", Object::Int(i64::from(tw)));
image.set("Height", Object::Int(i64::from(th)));
image.set("ColorSpace", Object::name("DeviceRGB"));
image.set("BitsPerComponent", Object::Int(8));
let image = Stream::flate(image, &rgb);
used = used.saturating_add(image.raw.len());
if used > *budget {
return None;
}
// The tile's place in display space, y up: its top edge is `y` pixels below the top.
// The last row and column may reach past the page by under a pixel, which the crop
// box clips.
let name = format!("PCFlat{}", tiles.len());
let place = [f64::from(tw) / s, 0.0, 0.0, f64::from(th) / s, f64::from(x) / s, h - f64::from(y + th) / s];
content.push_str(&format!("q {} cm /{name} Do Q\n", matrix(place)));
tiles.push((name.into_bytes(), image));
}
}
content.push_str("Q\n");
*budget -= used;
Some(Picture { content: content.into_bytes(), tiles })
}
/// `m` as the six operands of `cm`, at full precision.
fn matrix(m: [f64; 6]) -> String {
let mut out = Vec::new();
for (i, v) in m.into_iter().enumerate() {
if i > 0 {
out.push(b' ');
}
pdfcraft_cos::serialize(&Object::Real(v), &mut out);
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
@@ -137,13 +314,13 @@ mod tests {
assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None);
assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None);
let mut job = Job::default();
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF");
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job, false), b"not a PDF");
let small = page_with_image(100, 100);
assert_eq!(spooler_copy(small.clone(), &mut job), small);
assert_eq!(spooler_copy(small.clone(), &mut job, false), small);
assert!(!job.grey_pages);
// A grayscale job whose copy can't be read asks the driver for grey as before.
let mut grey = Job { grayscale: true, ..Job::default() };
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF");
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey, false), b"not a PDF");
assert!(!grey.grey_pages);
}
@@ -195,7 +372,7 @@ mod tests {
fn a_grayscale_job_sends_grey_pages_on_windows_only() {
let red = red_page();
let mut job = Job { grayscale: true, ..Job::default() };
let sent = spooler_copy(red.clone(), &mut job);
let sent = spooler_copy(red.clone(), &mut job, false);
if cfg!(windows) {
assert!(job.grey_pages);
assert!(!first_page_content(sent).contains(" rg"));
@@ -204,7 +381,7 @@ mod tests {
assert_eq!(sent, red);
}
let mut colour = Job::default();
assert_eq!(spooler_copy(red.clone(), &mut colour), red);
assert_eq!(spooler_copy(red.clone(), &mut colour, false), red);
assert!(!colour.grey_pages);
}
@@ -212,7 +389,7 @@ mod tests {
#[test]
fn the_spooler_gets_tiles_on_windows_only() {
let large = page_with_image(2100, 2100);
let sent = spooler_copy(large.clone(), &mut Job::default());
let sent = spooler_copy(large.clone(), &mut Job::default(), false);
if cfg!(windows) {
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
assert_ne!(sent, large);
@@ -220,4 +397,239 @@ mod tests {
assert_eq!(sent, large);
}
}
/// A PDF with the pages `make` returns, each given its type, its parent and, when it has
/// none, a US Letter MediaBox. `make` adds what the pages refer to.
fn pdf_of(make: impl FnOnce(&mut Document) -> Vec<Dict>) -> 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 made = make(&mut doc);
let count = made.len() as i64;
let kids: Vec<Object> = made
.into_iter()
.map(|mut page| {
page.set("Type", Object::name("Page"));
page.set("Parent", Object::Ref(pages));
if !page.contains(b"MediaBox") {
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
}
Object::Ref(doc.add(Object::Dict(page)))
})
.collect();
doc.update_dict(pages, |d| {
d.set("Count", Object::Int(count));
d.set("Kids", Object::Array(kids));
})
.unwrap();
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// A page drawing `content` with `resources`.
fn page(doc: &mut Document, resources: Dict, content: &[u8]) -> Dict {
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec())));
let mut page = Dict::new();
page.set("Resources", Object::Dict(resources));
page.set("Contents", Object::Ref(content));
page
}
/// An image XObject dictionary of `w` × `h` 8-bit samples in `space`.
fn image_dict(w: i64, h: i64, space: &str) -> Dict {
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(space));
image.set("BitsPerComponent", Object::Int(8));
image
}
/// A blue square, the content of a page without transparency.
const BLUE_SQUARE: &[u8] = b"0 0 1 rg 100 100 200 200 re f";
/// A page without transparency: a blue square.
fn vector_page(doc: &mut Document) -> Dict {
page(doc, Dict::new(), BLUE_SQUARE)
}
/// A page drawing a red image over a blue square, with a soft mask that fades the image in
/// from left to right.
fn soft_masked_page(doc: &mut Document) -> Dict {
let alpha: Vec<u8> = (0..64 * 64).map(|i| (i % 64 * 4) as u8).collect();
let mask = doc.add(Object::Stream(Stream::flate(image_dict(64, 64, "DeviceGray"), &alpha)));
let mut image = image_dict(64, 64, "DeviceRGB");
image.set("SMask", Object::Ref(mask));
let red: Vec<u8> = (0..64 * 64).flat_map(|_| [255, 0, 0]).collect();
let image = doc.add(Object::Stream(Stream::flate(image, &red)));
let mut xobjects = Dict::new();
xobjects.set("Im", Object::Ref(image));
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(xobjects));
page(doc, resources, b"0 0 1 rg 100 100 200 200 re f q 300 0 0 300 150 150 cm /Im Do Q")
}
/// Page `n`'s dictionary, inherited entries filled in, and the operators of its content.
fn page_of(doc: &Document, n: usize) -> (Dict, String) {
let page = pdfcraft_model::pages(doc).swap_remove(n);
let contents = page.dict.reference(b"Contents").unwrap();
let content = match &*doc.get(contents) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
other => panic!("{other:?}"),
};
(page.dict, content)
}
/// The images among page `n`'s XObjects, as (width, height, has a soft mask), sorted.
fn page_images(doc: &Document, n: usize) -> Vec<(i64, i64, bool)> {
let (page, _) = page_of(doc, n);
let resources = doc.resolve(page.get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
let mut images: Vec<(i64, i64, bool)> = xobjects
.as_dict()
.unwrap()
.iter()
.filter_map(|(_, x)| match &*doc.resolve(x) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => {
Some((s.dict.int(b"Width")?, s.dict.int(b"Height")?, s.dict.contains(b"SMask")))
}
_ => None,
})
.collect();
images.sort();
images
}
/// #6155, #5838, #5940: a page with a soft-masked image becomes an opaque picture in tiles
/// under Windows' image cap, on a sheet of the same size, and tiling leaves them alone.
#[test]
fn a_soft_masked_page_is_flattened_into_tiles() {
let flat = flattened(Arc::new(pdf_of(|doc| vec![soft_masked_page(doc)])), FLATTEN_DPI).expect("flattened");
let doc = Document::open(Arc::new(flat.clone())).unwrap();
// US Letter at 300 dpi is 2550 × 3300 pixels: two tiles across, two down.
assert_eq!(page_images(&doc, 0), [(550, 1300, false), (550, 2000, false), (2000, 1300, false), (2000, 2000, false)]);
assert!(doc.scan_objects().all(|(_, o)| !matches!(&*o, Object::Stream(s) if s.dict.contains(b"SMask"))));
let (page, content) = page_of(&doc, 0);
assert_eq!(page.get(b"MediaBox"), Some(&Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())));
assert_eq!(content.matches(" Do").count(), 4, "{content}");
assert!(!pdfcraft_preflight::page_uses_transparency(&doc, &page));
assert_eq!(tiled(Arc::new(flat)), None);
}
/// A page without transparency, or with a transparency group around opaque content only (as
/// a print-ready sheet draws a page that Word gave one), keeps its vectors; so does a copy
/// that can't be read.
#[test]
fn a_page_without_transparency_or_unreadable_is_not_flattened() {
assert_eq!(flattened(Arc::new(pdf_of(|doc| vec![vector_page(doc)])), FLATTEN_DPI), None);
assert_eq!(flattened(Arc::new(page_with_image(100, 100)), FLATTEN_DPI), None);
let grouped = pdf_of(|doc| {
let mut group = Dict::new();
group.set("S", Object::name("Transparency"));
let mut form = Dict::new();
form.set("Type", Object::name("XObject"));
form.set("Subtype", Object::name("Form"));
form.set("BBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
form.set("Group", Object::Dict(group));
let form = doc.add(Object::Stream(Stream::from_raw(form, BLUE_SQUARE.to_vec())));
let mut xobjects = Dict::new();
xobjects.set("Fm", Object::Ref(form));
let mut resources = Dict::new();
resources.set("XObject", Object::Dict(xobjects));
vec![page(doc, resources, b"/Fm Do")]
});
assert_eq!(flattened(Arc::new(grouped), FLATTEN_DPI), None);
assert_eq!(flattened(Arc::new(b"not a PDF".to_vec()), FLATTEN_DPI), None);
}
/// Of two pages, only the one with transparency becomes a picture; the other keeps its
/// content as it was.
#[test]
fn only_the_transparent_page_of_two_is_flattened() {
let source = pdf_of(|doc| vec![vector_page(doc), soft_masked_page(doc)]);
let flat = flattened(Arc::new(source), 150.0).expect("flattened");
let doc = Document::open(Arc::new(flat)).unwrap();
assert_eq!(page_of(&doc, 0).1, String::from_utf8_lossy(BLUE_SQUARE));
// US Letter at 150 dpi is 1275 × 1650 pixels: one tile.
assert_eq!(page_images(&doc, 1), [(1275, 1650, false)]);
assert_eq!(page_of(&doc, 1).1.matches(" Do").count(), 1);
}
/// The picture lands where the page showed what it pictures, on a page turned a quarter
/// with a crop box away from the origin.
#[test]
fn a_flattened_turned_page_looks_as_it_did() {
let source = pdf_of(|doc| {
let mut half = Dict::new();
half.set("ca", Object::Real(0.5));
let mut states = Dict::new();
states.set("Half", Object::Dict(half));
let mut resources = Dict::new();
resources.set("ExtGState", Object::Dict(states));
// Half-transparent red over the crop box's lower left 100 × 100 pt.
let mut page = page(doc, resources, b"/Half gs 1 0 0 rg 50 0 100 100 re f");
page.set("MediaBox", Object::Array([0, 0, 300, 200].map(Object::Int).to_vec()));
page.set("CropBox", Object::Array([50, 0, 300, 200].map(Object::Int).to_vec()));
page.set("Rotate", Object::Int(90));
vec![page]
});
let flat = flattened(Arc::new(source.clone()), 72.0).expect("flattened");
let (page, _) = page_of(&Document::open(Arc::new(flat.clone())).unwrap(), 0);
assert_eq!(page.get(b"Rotate"), Some(&Object::Int(90)));
assert_eq!(page.get(b"CropBox"), Some(&Object::Array([50, 0, 300, 200].map(Object::Int).to_vec())));
let render = |bytes: Vec<u8>| {
let r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
page: 0,
scale: 1.0,
tag: PRINT_TAG,
..Default::default()
});
assert!(r.error.is_none(), "{:?}", r.error);
r
};
let (before, after) = (render(source), render(flat));
// Turned a quarter clockwise, the page shows 200 × 250 pt with the red at its top left.
assert_eq!((before.width, before.height), (200, 250));
assert_eq!((after.width, after.height), (200, 250));
let px = |r: &pdfcraft_render::RenderedPage, x: u32, y: u32| {
let i = ((y * r.width + x) * 4) as usize;
[r.rgba[i], r.rgba[i + 1], r.rgba[i + 2]]
};
let pink = px(&before, 50, 50);
assert!(pink[0] > 240 && (100..160).contains(&pink[1]), "{pink:?}");
assert_eq!(px(&before, 150, 200), [255, 255, 255]);
// Sampled away from the red's edges (at 100 px), where pixels may blend.
for y in (5..250).step_by(10) {
for x in (5..200).step_by(10) {
let (b, a) = (px(&before, x, y), px(&after, x, y));
assert!(b.iter().zip(a).all(|(b, a)| b.abs_diff(a) <= 3), "({x}, {y}): {b:?} became {a:?}");
}
}
}
/// A sheet keeps the full resolution up to 24 × 36 in; a larger one gets a lower one within
/// the pixel bound, and a long banner one within the side bound.
#[test]
fn a_large_sheet_is_flattened_at_a_lower_resolution() {
let dpi = |w: f64, h: f64| flatten_scale(w * 72.0, h * 72.0, FLATTEN_DPI) * 72.0;
assert!((dpi(8.5, 11.0) - 300.0).abs() < 1e-9);
assert!((dpi(24.0, 36.0) - 300.0).abs() < 1e-9);
let large = dpi(36.0, 48.0);
assert!(large < 300.0 && 36.0 * 48.0 * large * large <= MAX_FLATTENED_PIXELS * 1.000_001, "{large}");
assert!((dpi(36.0, 1200.0) * 1200.0 - MAX_FLATTENED_SIDE).abs() < 1e-6);
}
/// With the flag off a transparent page goes to the spooler as it is; on, Windows gets the
/// flattened copy.
#[test]
fn the_spooler_flattens_transparency_only_when_asked() {
let source = pdf_of(|doc| vec![soft_masked_page(doc)]);
assert_eq!(spooler_copy(source.clone(), &mut Job::default(), false), source);
let sent = spooler_copy(source.clone(), &mut Job::default(), true);
if cfg!(windows) {
assert_eq!(Some(&sent), flattened(Arc::new(source), FLATTEN_DPI).as_ref());
} else {
assert_eq!(sent, source);
}
}
}
+16
View File
@@ -868,6 +868,22 @@ fn check_catalog_actions(doc: &Document, issue: &mut impl FnMut(&'static str, &'
}
}
/// Whether something the page `page` paints with can paint transparently: a graphics state with
/// a soft mask, a constant alpha (`/CA`, `/ca`) below 1 or a blend mode other than `Normal` or
/// `Compatible`, or an image with a soft mask. `page` is a page dictionary with its inherited
/// attributes filled in, as [`pdfcraft_model::pages`] lists it.
///
/// The search is the one PDF/A verification makes (ISO 32000-1 11.6.4.4 to 11.6.6): through the
/// page's resources, the form XObjects, tiling patterns and Type 3 fonts they hold, and its
/// annotations' appearance streams, within the same budgets, so a page that nests deeper than
/// those reports what was found within them. A resource the page holds counts whether or not its
/// content uses it. Unlike PDF/A, a transparency group on its own doesn't count: opaque content
/// with normal blending looks the same inside one as without it, and many producers (Microsoft
/// Word among them) give every page a group.
pub fn page_uses_transparency(doc: &Document, page: &Dict) -> bool {
transparency::page_painting(doc, page, &mut transparency::Shared::default()).paints_transparently
}
fn check_transparency(
doc: &Document,
pages: &[pdfcraft_model::Page],
+32 -1
View File
@@ -35,6 +35,10 @@ const MAX_SHARED: usize = 1 << 16;
pub(crate) struct Painting {
/// Something paints transparently.
pub(crate) transparency: bool,
/// Something paints with a soft mask, a constant alpha below 1 or a blend mode: what
/// `transparency` holds except a transparency group, which on its own paints opaque content
/// as it would be painted without it ([`crate::page_uses_transparency`]).
pub(crate) paints_transparently: bool,
/// The blend modes graphics states name that ISO 32000-1 doesn't define.
pub(crate) unknown_blend_modes: Vec<String>,
}
@@ -42,6 +46,7 @@ pub(crate) struct Painting {
impl Painting {
fn add(&mut self, other: &Painting) {
self.transparency |= other.transparency;
self.paints_transparently |= other.paints_transparently;
for name in &other.unknown_blend_modes {
self.add_blend_mode(name);
}
@@ -297,7 +302,9 @@ impl Walk<'_> {
// A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58).
let smask = g.get(b"SMask").map(|m| held(doc, m));
let mask = smask.as_deref().is_some_and(|m| matches!(m, Object::Dict(_)));
self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask;
let paints = below_one(b"CA") || below_one(b"ca") || blend || mask;
self.found.transparency |= paints;
self.found.paints_transparently |= paints;
if let Some(bm) = bm.as_deref() {
unknown_blend_modes(doc, bm, &mut self.found);
}
@@ -319,6 +326,7 @@ impl Walk<'_> {
let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_)));
let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0);
self.found.transparency |= soft_mask || in_data;
self.found.paints_transparently |= soft_mask || in_data;
return;
}
let group = d
@@ -523,4 +531,27 @@ mod tests {
assert!(!page_names_blending_space(&doc, &pages[2].dict));
assert!(!page_names_blending_space(&doc, &pages[3].dict));
}
#[test]
fn a_transparency_group_alone_does_not_paint_transparently() {
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Fm 4 0 R >> >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Group << /S /Transparency >> /Length 0 >>\nstream\n\nendstream",
]);
let page = &pdfcraft_model::pages(&doc)[0];
assert!(page_painting(&doc, &page.dict, &mut Shared::default()).transparency);
assert!(!crate::page_uses_transparency(&doc, &page.dict));
// The same group with a constant alpha below 1 inside it.
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Fm 4 0 R >> >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Group << /S /Transparency >> /Resources << /ExtGState << /G << /ca 0.5 >> >> >> /Length 0 >>\nstream\n\nendstream",
]);
let page = &pdfcraft_model::pages(&doc)[0];
assert!(crate::page_uses_transparency(&doc, &page.dict));
}
}
+20
View File
@@ -89,6 +89,26 @@ pub fn printer_queues() -> Vec<Printer> {
craft_print::printer_queues()
}
/// Whether `printer`'s driver renders XPS itself (Windows: v4 drivers, and v3 drivers with an XPS
/// rendering filter); `Some(false)` for a GDI driver, `None` when it can't be read, on a Windows
/// installation without the print DLLs, and on other systems. See [`gdi_driver`].
pub fn xps_driver(printer: &str) -> Option<bool> {
if missing_print_dll().is_some() {
return None;
}
#[cfg(windows)]
let _provider = print_ticket_provider();
craft_print::xps_driver(printer)
}
/// Whether `printer`'s driver is a GDI (v3) one, which the XPS print API reaches through Windows'
/// XPS-to-GDI conversion: a page with transparency prints there with halos, as black boxes or not
/// at all (#5838, #5940, #6155), so the copy for it has its transparent sheets flattened first
/// (`pdfcraft_engine::spooler::spooler_copy`). False when that can't be told.
pub fn gdi_driver(printer: &str) -> bool {
xps_driver(printer) == Some(false)
}
/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait
/// on the device, so call it off the UI thread. Unknown on a Windows installation without the
/// print DLLs: reading it goes through them.
+4 -1
View File
@@ -699,7 +699,10 @@ impl PdfCraftApp {
};
// On Windows grayscale makes the pages grey (#5806) and large pictures become tiles
// (#5131): work on the whole file, kept off the UI thread with the rest.
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
// A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS (v4)
// drivers print them as they are.
let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver);
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten);
// The receiver may be gone (the app quit): nothing to report to then.
let result = match &spooler {
Some(submit) => submit(&bytes, &job),