From f3b643d0813123c6d9934db4eb470960208a8883 Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 10:41:24 -0500 Subject: [PATCH] prepress: separate a page in bands, past the 4 MP plate cap * prepress: separate a page in bands, past the 4 MP plate cap `plates_in_bands` finds a page's inks once, then separates it in row bands of at most MAX_PLATE_PIXELS each, rendering every band through craft-pdf raster's size/offset viewport. A page can now be separated at print resolution with bounded memory: only the renderer's 8192-pixel side limit and a 256 MP per-plate total (MAX_BANDED_PIXELS) lower the resolution. `plates` is unchanged in behaviour: it is the one-band case of the same code. Tests: bands of 7 rows join byte for byte into the whole-page render of an anti-aliased disc under an overprinting spot (fails when the band offset is dropped), and a 40 pt page at 3700 dpi separates at full resolution in bands under the cap (fails when banding uses the plate cap's scale). Refs #838. * prepress: bands pass the renderer's whole-page limits The page's pixel size came from `raster::dimensions` on the whole page, which enforces the renderer's 64 MP and 8192-pixel side limits, so a Letter page past about 830 dpi failed with OutputLimit instead of banding. The size is now computed as the renderer floors it, and only each band's viewport reaches the renderer. Bands are also capped at MAX_SIDE rows, and ink discovery renders within the renderer's limits. A Letter page at 900 dpi now separates in 19 bands at full resolution; across the page the side limit still lowers it, near 960 dpi. Test: a 10 x 2000 pt strip at 600 dpi (16 666 rows) separates in three bands with its black square on the right rows. It fails with either the whole-page dimensions call or the uncapped band height restored. * prepress: check the page before discovery; say what the banded cap bounds The page-size check now runs before ink discovery renders anything. MAX_BANDED_PIXELS bounds pixels, not the number of bands; the doc comment and the README caps table say so. * prepress: count a sub-pixel side as one pixel in the banded cap A side scaled under one pixel still renders one, so a very thin, tall page could pass MAX_BANDED_PIXELS by far. banded_scale now fits the other side alone in that case, and separate() refuses any floored size over the cap before it renders a band. * prepress: keep the banded area scale under the cap after rounding The area scale came from the exact cap, so f32 rounding could floor a page one row past it (17 x 15 058 824 pt at 72 dpi), which then failed with OutputLimit. The same 0.1% margin as the one-pixel branches now applies to the area scale too, with a test on that page. --------- Co-authored-by: KenM76 --- crates/prepress/README.md | 21 ++- crates/prepress/src/lib.rs | 2 +- crates/prepress/src/plates.rs | 304 +++++++++++++++++++++++++++++++--- 3 files changed, 299 insertions(+), 28 deletions(-) diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 80b1e08f7..fe559e8d4 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -57,6 +57,24 @@ let black = p.tint(InkId::process(Process::Black), 10, 10); let gold = p.inks.lookup("Gold").and_then(|id| p.tint(id, 10, 10)); ``` +At print resolution, separate in bands. Each band holds every ink for a run of rows, at most +`MAX_PLATE_PIXELS`, so memory stays bounded however high the resolution: + +```rust +use std::ops::ControlFlow; +use craft_prepress::{plates_in_bands, Options}; + +plates_in_bands(&pdf, 0, 600.0, &Options::default(), |band| { + // band.plates is page-wide and band.plates.height rows tall, from row band.top. + ControlFlow::Continue(()) +})?; +``` + +Each band renders the page again once per ink, so time grows with the number of bands: a +Letter page at 900 dpi makes 19 bands. Across the page the renderer's side limit +(`craft_pdf::raster::MAX_SIDE`, 8192 pixels) still applies, about 960 dpi on Letter or A4; down +the page it doesn't. + The plates are the PDF as printed, so a preview built on them shows what the writer produced, not what the document model intended. @@ -90,7 +108,8 @@ separate as they are. | Constant | Value | Why | |---|---|---| | `MAX_SPOT_INKS` | 32 spot inks per page | A `DeviceN` space has at most 32 colorants, and each plate costs a render and a byte per pixel. Further spots are listed in `Plates::unseparated`. | -| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. The resolution is lowered to fit; `Plates::dpi` reports the one used. | +| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. `plates` lowers the resolution to fit, and `plates_in_bands` makes bands this size; `Plates::dpi` reports the one used. | +| `MAX_BANDED_PIXELS` | 256 MP per plate of a page separated in bands | Bounds the pixels a banded page adds up to; the resolution is lowered to fit. It does not bound the band count: a very thin, tall page makes one band per 8192 rows. | The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolution. diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 5b8967a89..3fa4f472e 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -19,4 +19,4 @@ pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name, to_pdf_color}; pub use marks::{MarkOptions, Marks, MarksError}; pub use output::{Output, OutputError}; -pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; +pub use plates::{Band, Conversion, Error, Fallback, MAX_BANDED_PIXELS, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates, plates_in_bands}; diff --git a/crates/prepress/src/plates.rs b/crates/prepress/src/plates.rs index 332c8b6b2..b2e818efa 100644 --- a/crates/prepress/src/plates.rs +++ b/crates/prepress/src/plates.rs @@ -12,6 +12,7 @@ //! 6fcf2a4, MIT OR Apache-2.0), with craft-cms in place of moxcms. use std::collections::HashSet; +use std::ops::ControlFlow; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::{Arc, Mutex}; @@ -35,6 +36,12 @@ pub const MAX_SPOT_INKS: usize = 32; /// hundred megabytes. The resolution is reduced to fit; [`Plates::dpi`] says what was used. pub const MAX_PLATE_PIXELS: f32 = 4.0e6; +/// Pixels per plate of a page separated in bands (256 MP, 16000 × 16000). It bounds the memory a +/// page's bands add up to; a long page reaches it, while across a Letter or A4 page the side limit +/// stops near 960 dpi first. The resolution is reduced to fit. It does not bound the band count: a +/// page a few points wide and very tall still makes one band per 8192 rows. +pub const MAX_BANDED_PIXELS: f32 = 2.56e8; + /// The resolution, in pixels per point, inks are discovered at before the plates render. const DISCOVERY_SCALE: f32 = 0.1; @@ -148,8 +155,51 @@ pub enum Error { /// Separates page `page` (from 0) of `pdf` at `dpi`, reduced to fit [`MAX_PLATE_PIXELS`] and /// the renderer's side limit: one plate per ink, the four process inks and up to /// [`MAX_SPOT_INKS`] spot inks the page paints. Spot inks only soft masks use are left out -/// (masks don't print). Never panics. +/// (masks don't print). [`plates_in_bands`] separates at higher resolutions. Never panics. pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result { + let mut whole = None; + // One band of the whole page: `plate_scale` keeps it within `MAX_PLATE_PIXELS`. + separate(pdf, page, dpi, opts, plate_scale, Some(u32::MAX), &mut |band: Band| { + whole = Some(band.plates); + ControlFlow::Break(()) + })?; + whole.ok_or(Error::Render(raster::Error::OutputLimit)) +} + +/// Rows of a page's plates, from [`plates_in_bands`]. +#[derive(Clone, Debug)] +pub struct Band { + /// The band's first row on the page, from the top. + pub top: u32, + /// The page's height in pixels. The band's own height is the plates' height. + pub page_height: u32, + /// The band's plates: as wide as the page, `height` rows from `top`. Every band has the + /// same inks, in the same order. + pub plates: Plates, +} + +/// Separates page `page` (from 0) of `pdf` at `dpi` as [`plates`] does, in bands from the top +/// down, each at most [`MAX_PLATE_PIXELS`]. The resolution is reduced only to fit the +/// renderer's side limit across the page (`MAX_SIDE`, 8192 pixels) and [`MAX_BANDED_PIXELS`] +/// in all; [`Plates::dpi`] says what was used. The page's inks are found once, so every band has the same ones. +/// +/// `each` gets the bands in order and returns [`ControlFlow::Break`] to stop. Each band renders +/// the page again, clipped to its rows. Never panics. +pub fn plates_in_bands(pdf: &[u8], page: usize, dpi: f32, opts: &Options, mut each: impl FnMut(Band) -> ControlFlow<()>) -> Result<(), Error> { + separate(pdf, page, dpi, opts, banded_scale, None, &mut each) +} + +/// Separates in bands of `rows` rows, else as many as fit [`MAX_PLATE_PIXELS`], at `scale_of`'s +/// scale. +fn separate( + pdf: &[u8], + page: usize, + dpi: f32, + opts: &Options, + scale_of: fn(f32, f32, f32) -> f32, + rows: Option, + each: &mut dyn FnMut(Band) -> ControlFlow<()>, +) -> Result<(), Error> { if !dpi.is_finite() || dpi <= 0.0 { return Err(Error::InvalidDpi); } @@ -161,12 +211,51 @@ pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result MAX_BANDED_PIXELS as u64 { + return Err(Error::Render(raster::Error::OutputLimit)); + } + let rows = rows.unwrap_or(fit).clamp(1, page_height); + let mut top = 0; + while top < page_height { + let height = rows.min(page_height - top); + let mut planes = Vec::with_capacity(separated.len()); + for ink in &separated { + let plate = Plate { ink: ink.clone(), overprint: opts.overprint, simulation: simulation.clone(), spots_as_process: false }; + let settings = InterpreterSettings { plate: Some(plate), ..Default::default() }; + let viewport = raster::Options { size: Some([width, height]), offset: [0.0, top as f32], ..film(scale) }; + let film = raster::render(p, &cache, &settings, &viewport)?; + // The film is gray on white: its red channel is the paper left uncovered. + planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect()); + } + let plates = + Plates { inks: inks.clone(), unseparated: unseparated.clone(), width, height, dpi: scale * 72.0, conversion: conversion.clone(), planes }; + if each(Band { top, page_height, plates }).is_break() { + break; + } + top += height; + } + Ok(()) +} +/// The inks page `p` paints, found at `scale`, a low resolution: the process inks, then up to +/// [`MAX_SPOT_INKS`] spots as the [`InkSet`], the renderer's inks to separate in the same +/// order, and the names of spots left unseparated. +fn discover<'a>(p: &'a craft_pdf::syntax::page::Page<'a>, cache: &RenderCache<'a>, scale: f32) -> Result<(InkSet, Vec, Vec), Error> { let log = Arc::new(Mutex::new(SpotLog::default())); - let discover = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() }; - render(p, &cache, &discover, DISCOVERY_SCALE.min(scale))?; + let settings = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() }; + raster::render(p, cache, &settings, &film(scale))?; let spots = std::mem::take(&mut log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).order); let mut inks = InkSet::new(); @@ -186,30 +275,31 @@ pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result unseparated.push(ink.name().to_string()), } } - - let mut size = None; - let mut planes = Vec::with_capacity(separated.len()); - for ink in separated { - let plate = Plate { ink, overprint: opts.overprint, simulation: simulation.clone(), spots_as_process: false }; - let settings = InterpreterSettings { plate: Some(plate), ..Default::default() }; - let film = render(p, &cache, &settings, scale)?; - size = Some((film.width, film.height)); - // The film is gray on white: its red channel is the paper left uncovered. - planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect()); - } - let (width, height) = size.unwrap_or((0, 0)); - Ok(Plates { inks, unseparated, width, height, dpi: scale * 72.0, conversion, planes }) + Ok((inks, separated, unseparated)) } -fn render<'a>( - page: &'a craft_pdf::syntax::page::Page<'a>, - cache: &RenderCache<'a>, - settings: &InterpreterSettings, - scale: f32, -) -> Result { - // The plate's gray film must keep its tints: no color replacement. - let options = raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() }; - Ok(raster::render(page, cache, settings, &options)?) +/// Raster options for a gray film at `scale`. The plate's film must keep its tints: no color +/// replacement. +fn film(scale: f32) -> raster::Options { + raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() } +} + +/// The scale a `w` × `h` pt page separates at in bands: `scale`, within the renderer's side +/// limit across the page and [`MAX_BANDED_PIXELS`]. Down the page there is no side limit. +fn banded_scale(w: f32, h: f32, scale: f32) -> f32 { + let (w, h) = (w.max(1.0), h.max(1.0)); + // The margin keeps the floored size under the cap despite f32 rounding. + let cap = MAX_BANDED_PIXELS * 0.999; + let by_area = (cap / (w * h)).sqrt(); + let mut s = scale.min(by_area).min(MAX_SIDE as f32 / w); + // A side scaled under one pixel still renders one, so the other side alone must fit. + if w * s < 1.0 { + s = s.min(cap / h); + } + if h * s < 1.0 { + s = s.min(cap / w); + } + if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE } } /// The scale a `w` × `h` pt page separates at: `scale`, within [`MAX_PLATE_PIXELS`] and @@ -341,6 +431,168 @@ fn output_intents(pdf: &Pdf, page: usize) -> Vec> { mod tests { use super::*; + /// A 40 × 40 pt page: a black disc with anti-aliased edges, a spot square overprinting it. + fn disc_and_spot() -> Vec { + let content = "0 0 0 1 k 20 4 m 29 4 36 11 36 20 c 36 29 29 36 20 36 c 11 36 4 29 4 20 c 4 11 11 4 20 4 c f /GS0 gs /CS0 cs 0.5 scn 10.3 10.3 19.4 19.4 re f"; + let resources = "/ColorSpace << /CS0 [/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>] >> /ExtGState << /GS0 << /op true >> >>"; + let mut out = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +" + .to_vec(); + out.extend_from_slice( + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +", + ); + out.extend_from_slice( + format!( + "3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj +" + ) + .as_bytes(), + ); + out.extend_from_slice( + format!( + "4 0 obj << /Length {} >> stream +{content} +endstream endobj +", + content.len() + ) + .as_bytes(), + ); + out.extend_from_slice( + b"trailer << /Root 1 0 R >> +%%EOF", + ); + out + } + + #[test] + fn bands_join_into_the_page_rendered_whole() { + let pdf = disc_and_spot(); + let whole = plates(&pdf, 0, 216.0, &Options::default()).unwrap(); + assert_eq!((whole.width, whole.height, whole.planes.len()), (120, 120, 5)); + let mut joined = vec![Vec::new(); 5]; + let mut tops = Vec::new(); + separate(&pdf, 0, 216.0, &Options::default(), plate_scale, Some(7), &mut |band: Band| { + assert_eq!((band.plates.width, band.page_height, band.plates.inks.len()), (120, 120, 5)); + tops.push((band.top, band.plates.height)); + for (plane, part) in joined.iter_mut().zip(&band.plates.planes) { + plane.extend_from_slice(part); + } + ControlFlow::Continue(()) + }) + .unwrap(); + assert_eq!(tops.len(), 18, "17 bands of 7 rows and one of 1: {tops:?}"); + assert_eq!(tops.last(), Some(&(119, 1))); + assert!(whole.planes.iter().any(|p| p.iter().any(|t| *t > 0 && *t < 255)), "the disc's edge is anti-aliased"); + assert_eq!(joined, whole.planes, "no seam between bands"); + } + + #[test] + fn bands_reach_resolutions_past_the_plate_cap_and_can_stop() { + let pdf = disc_and_spot(); + // 40 pt at 3700 dpi is 2055 pixels a side: 4.2 MP, past `MAX_PLATE_PIXELS`. + assert!(plates(&pdf, 0, 3700.0, &Options::default()).unwrap().dpi < 3700.0); + let mut bands = Vec::new(); + plates_in_bands(&pdf, 0, 3700.0, &Options::default(), |band| { + let p = &band.plates; + assert!((p.width * p.height) as f32 <= MAX_PLATE_PIXELS); + bands.push((band.top, p.height, p.width, p.dpi, band.page_height)); + ControlFlow::Continue(()) + }) + .unwrap(); + let &(_, _, width, dpi, page_height) = bands.first().unwrap(); + assert_eq!((width, page_height), (2055, 2055)); + assert!((dpi - 3700.0).abs() < 0.01); + assert_eq!(bands.iter().map(|b| b.1).sum::(), 2055); + + let mut seen = 0; + plates_in_bands(&pdf, 0, 3700.0, &Options::default(), |_| { + seen += 1; + ControlFlow::Break(()) + }) + .unwrap(); + assert_eq!(seen, 1); + assert!(matches!(plates_in_bands(&pdf, 0, f32::NAN, &Options::default(), |_| ControlFlow::Continue(())), Err(Error::InvalidDpi))); + } + + /// A 10 × 2000 pt strip, black only in its bottom 10 pt. + fn tall_strip() -> Vec { + let content = "0 0 0 1 k 0 0 10 10 re f"; + let mut out = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +" + .to_vec(); + out.extend_from_slice( + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +", + ); + out.extend_from_slice( + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 10 2000] /Contents 4 0 R >> endobj +", + ); + out.extend_from_slice( + format!( + "4 0 obj << /Length {} >> stream +{content} +endstream endobj +", + content.len() + ) + .as_bytes(), + ); + out.extend_from_slice( + b"trailer << /Root 1 0 R >> +%%EOF", + ); + out + } + + #[test] + fn a_page_past_the_renderers_height_limit_separates_in_bands() { + // At 600 dpi the strip is 83 × 16 666 pixels: past `MAX_SIDE` down the page. + let pdf = tall_strip(); + let mut rows = 0; + let mut bands = 0; + let mut black_rows = Vec::new(); + plates_in_bands(&pdf, 0, 600.0, &Options::default(), |band| { + let p = &band.plates; + assert_eq!((p.width, band.page_height), (83, 16_666)); + let k = p.plane(InkId::process(crate::Process::Black)).unwrap(); + for (y, row) in k.chunks(p.width as usize).enumerate() { + if row.iter().all(|t| *t == 255) { + black_rows.push(band.top + y as u32); + } + } + rows += p.height; + bands += 1; + ControlFlow::Continue(()) + }) + .unwrap(); + assert_eq!((rows, bands), (16_666, 3), "bands of at most MAX_SIDE rows"); + // The square's top edge is 1990 pt down, row 16 583.3: rows 16 584 to the last are solid. + assert_eq!(black_rows.first(), Some(&16_584), "{:?}", &black_rows[..black_rows.len().min(3)]); + assert_eq!(black_rows.len(), 82); + } + + #[test] + fn banded_scale_keeps_the_side_and_area_limits() { + assert_eq!(banded_scale(40.0, 40.0, 10.0), 10.0); + assert!(612.0 * banded_scale(612.0, 792.0, 2400.0 / 72.0) <= MAX_SIDE as f32); + let s = banded_scale(100.0, 1.0e6, 10.0); + assert!(100.0 * 1.0e6 * s * s <= MAX_BANDED_PIXELS * 1.0001); + assert!(banded_scale(0.0, f32::NAN, 1.0) > 0.0); + // A 1 pt wide, very tall page: its width floors to one pixel, which the cap still counts. + let s = banded_scale(1.0, 1.0e9, 1.0); + let [width, height] = [1.0f32, 1.0e9].map(|side| (side * s).floor().max(1.0)); + assert!(width * height <= MAX_BANDED_PIXELS); + // A page whose exact area scale rounds up in f32 by a pixel row. + let s = banded_scale(17.0, 15_058_824.0, 1.0); + let [width, height] = [17.0f32, 15_058_824.0].map(|side| u64::from((side * s).floor().max(1.0) as u32)); + assert!(width * height <= MAX_BANDED_PIXELS as u64); + } + #[test] fn plates_fit_the_pixel_and_side_caps() { assert_eq!(plate_scale(40.0, 40.0, 1.0), 1.0);