Files
pdfcraft/crates/print/src/sheets.rs
T
bflatastic 2ce2d3cf81 Merge main into merge/ui-print-optimize2
# Conflicts:
#	crates/automation/src/redact.rs
#	crates/automation/src/tools.rs
#	crates/print/src/lib.rs
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
2026-11-09 20:56:15 +07:00

401 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::error::PrintError;
use super::imposition::decoded_contents;
use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode};
use super::{DecodeBudget, Dict, Document, Matrix, Object};
/// The CUPS job option that keeps landscape sheets landscape (`orientation-requested` 4).
///
/// The print-ready PDF's sheets are already laid out on their paper, but the paper is named
/// portrait (`Letter`), and without this option CUPS leaves a landscape sheet unrotated and
/// shrinks it into the portrait paper instead of turning it to fill it (#2713). It is a property
/// of the whole job, so it is given only when *every* sheet is landscape; a job that mixes
/// orientations is left to the spooler, as portrait sheets would be turned wrongly.
pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> {
let landscape = !sheets.is_empty() && sheets.iter().all(|s| s.size.0 > s.size.1);
landscape.then(|| ("orientation-requested".to_string(), "4".to_string()))
}
/// Add [`cups_orientation`] to a CUPS job's `options`, for the Print dialog's jobs and
/// `doc_print`'s alike, unless they already name an orientation (a driver choice, or one a
/// caller passed, in any case: CUPS reads option names without regard to case): `lp` must not be
/// given the option twice. Nothing on Windows, whose print ticket sets each sheet's orientation
/// itself.
pub fn add_cups_orientation(options: &mut Vec<(String, String)>, sheets: &[Sheet]) {
if cfg!(windows) || options.iter().any(|(k, _)| k.eq_ignore_ascii_case("orientation-requested")) {
return;
}
options.extend(cups_orientation(sheets));
}
/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid
/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects
/// are followed a few levels deep.
pub fn text_reads_up(doc: &Document, index: usize) -> bool {
texts_read_up(doc, &[index]).first().copied().unwrap_or(false)
}
/// [`text_reads_up`] for each of `indices`, reading the page tree once: once per page, the
/// whole tree would be walked for every page asked about.
pub fn texts_read_up(doc: &Document, indices: &[usize]) -> Vec<bool> {
let pages = pdfcraft_model::pages(doc);
indices.iter().map(|&i| pages.get(i).is_some_and(|page| page_reads_up(doc, page))).collect()
}
fn page_reads_up(doc: &Document, page: &pdfcraft_model::Page) -> bool {
let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default();
let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut score = 0i64;
let mut budget = DecodeBudget::page();
// Content past the budget is left out: the answer is a guess from the text read so far.
let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default();
text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget);
score > 0
}
pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) {
let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new());
// One operator at a time: only the running score is kept, never the stream's operators.
for op in pdfcraft_content::ops(data) {
match op.op.as_slice() {
// Bounded like the renderer's graphics-state stack: deeper saves are ignored.
b"q" if stack.len() < 256 => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(base),
b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm),
b"BT" => tm = Matrix::IDENTITY,
b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(),
b"Tj" | b"TJ" | b"'" | b"\"" => {
let [a, b, ..] = tm.then(&ctm).0;
*score += i64::from(b > a.abs()) - i64::from(-b > a.abs());
}
b"Do" if depth < 4 => {
let Some(name) = op.name(0) else { continue };
let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue };
let Object::Stream(form) = &*doc.resolve(&xobj) else { continue };
if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) {
continue;
}
// The visit is paid for before anything else is read, and the form's resources
// (or the inherited ones) are borrowed, not copied for each invocation.
if !budget.enter_form() {
continue;
}
let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default();
let own = form.dict.get(b"Resources").map(|r| doc.resolve(r));
let inner = own.as_deref().and_then(Object::as_dict).unwrap_or(res);
if let Ok(body) = budget.decode(form) {
text_direction(doc, &body, inner, m.then(&ctm), depth + 1, score, budget);
}
}
_ => {}
}
}
}
/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed
/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet.
pub const LABEL_MARGIN: f64 = 8.0;
/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by
/// `quarter` quarter turns counter-clockwise (0–3).
fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement {
let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]);
let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) };
let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0);
let [a, b, c, d, e, f] = match quarter % 4 {
0 => [s, 0.0, 0.0, s, x, y],
1 => [0.0, s, -s, 0.0, x + pw, y],
2 => [-s, 0.0, 0.0, -s, x + pw, y + ph],
_ => [0.0, -s, s, 0.0, x, y + ph],
};
// Move the crop's corner to the origin first.
Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop }
}
/// Match a displayed PDF page to a standard sheet, independent of orientation.
/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes.
pub fn matching_paper(size: (f64, f64)) -> Option<usize> {
if !size.0.is_finite() || !size.1.is_finite() {
return None;
}
let size = (size.0.min(size.1), size.0.max(size.1));
PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5)
}
/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet.
pub const MARGIN: f64 = 18.0;
/// One page as placed on a sheet: the source page, the matrix from its *display space* to the
/// sheet, and the visible part of it in display space (clipping).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placement {
pub page: usize,
pub matrix: Matrix,
pub clip: [f64; 4],
}
/// One output sheet.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Sheet {
pub size: (f64, f64),
pub placed: Vec<Placement>,
/// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines.
pub borders: Vec<[f64; 4]>,
pub lines: Vec<[f64; 4]>,
}
fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) {
let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1));
match o {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
Orientation::Auto => {
if landscape_wanted {
(h, w)
} else {
(w, h)
}
}
}
}
/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s`
/// (centred), optionally rotated 90° (counter-clockwise).
fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement {
let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]);
let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) };
let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0);
let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) };
Placement { page, matrix, clip: [0.0, 0.0, dw, dh] }
}
fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 {
if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) }
}
/// Lay out the sheets for `settings` given each source page's display size.
pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, PrintError> {
let pages: Vec<usize> = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect();
if pages.is_empty() {
return Err(PrintError::NoPages);
}
let paper = settings.paper;
if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) {
return Err(PrintError::Invalid("invalid paper size".into()));
}
let mut sheets = Vec::new();
match settings.layout {
Layout::Size(mode) => {
for &p in &pages {
let d = sizes[p];
let size = oriented(paper, settings.orientation, d.0 > d.1);
let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN));
let s = match mode {
SizeMode::Fit => fit,
SizeMode::Actual => 1.0,
SizeMode::Shrink => fit.min(1.0),
SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0,
SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())),
};
sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() });
}
}
Layout::Label { flip } => {
sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip));
}
Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate, custom } => {
// A predefined count has at most 16 × 16 cells; a custom grid up to 99 each way.
let fits = if custom { cols <= crate::MAX_GRID && rows <= crate::MAX_GRID } else { cols.checked_mul(rows).is_some_and(|n| n <= 256) };
let cells = cols.checked_mul(rows).filter(|&n| n > 0 && fits);
let Some(cells) = cells else {
return Err(PrintError::Invalid("invalid number of pages per sheet".into()));
};
// The grid can have more cells than the pages asked for on each sheet; the rest stay blank.
let per_sheet = per_sheet.min(cells);
if per_sheet == 0 {
return Err(PrintError::Invalid("invalid number of pages per sheet".into()));
}
// A grid with more columns than rows wants a landscape sheet, and the reverse.
let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?;
let page_landscape = first.0 > first.1;
let (gc, gr, sheet_landscape) = if custom {
// A custom grid is laid out as given, on the sheet as chosen (Auto: portrait).
(cols, rows, false)
} else if cols == rows {
(cols, rows, page_landscape)
} else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) {
// 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper.
(rows, cols, true)
} else {
(cols, rows, false)
};
let size = oriented(paper, settings.orientation, sheet_landscape);
let gap = 6.0;
let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN);
let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64);
if cw <= 0.0 || ch <= 0.0 {
return Err(PrintError::Invalid("the page grid does not fit on the paper".into()));
}
let sheet_count = pages.len().div_ceil(per_sheet);
for sheet_index in 0..sheet_count {
let mut sheet = Sheet { size, ..Sheet::default() };
if order == PageOrder::CutStack {
// Marks align across every sheet, including unoccupied cells. Cut in the
// middle of each gutter; marks stay in the printable area, clear of cells.
for col in 1..gc {
let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0;
sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]);
}
for row in 1..gr {
let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0;
sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]);
}
}
for k in 0..per_sheet {
// A cell's pile gets consecutive pages. Missing pages leave blank cells,
// rather than shifting later piles left on the final sheets.
let index = if order == PageOrder::CutStack {
k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index))
} else {
sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k))
};
let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue };
let (col, row) = match order {
PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc),
PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc),
PageOrder::Vertical => (k / gr, k % gr),
PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr),
};
let x0 = MARGIN + col as f64 * (cw + gap);
let y1 = size.1 - MARGIN - row as f64 * (ch + gap);
let cell = [x0, y1 - ch, x0 + cw, y1];
let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?;
if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) {
return Err(PrintError::Invalid("invalid page size".into()));
}
let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0;
let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch));
if !s.is_finite() || s <= 0.0 {
return Err(PrintError::Invalid("page size cannot be scaled to the grid".into()));
}
let pl = place_in(p, d, cell, s, rotate);
if border {
let b = pl.matrix.bbox(pl.clip);
sheet.borders.push(b);
}
sheet.placed.push(pl);
}
sheets.push(sheet);
}
}
Layout::Booklet { subset, binding } => {
// Pad to a multiple of 4 with blank pages (None).
let n4 = pages.len().div_ceil(4) * 4;
let at = |i: usize| pages.get(i).copied();
let size = oriented(paper, Orientation::Landscape, true);
let half = (size.0 - 2.0 * MARGIN) / 2.0;
let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]];
for k in 0..n4 / 4 {
let front = (at(n4 - 1 - 2 * k), at(2 * k));
let back = (at(2 * k + 1), at(n4 - 2 - 2 * k));
let mut sides = Vec::new();
if subset != BookletSubset::BackOnly {
sides.push(front);
}
if subset != BookletSubset::FrontOnly {
sides.push(back);
}
for (l, r) in sides {
let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) };
let mut sheet = Sheet { size, ..Sheet::default() };
for (p, cell) in [(l, cells[0]), (r, cells[1])] {
if let Some(p) = p {
let d = sizes[p];
let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1]));
sheet.placed.push(place_in(p, d, cell, s, false));
}
}
sheets.push(sheet);
}
}
}
Layout::Poster { scale, overlap, cut_marks } => {
if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) {
return Err(PrintError::Invalid("invalid tile scale or overlap".into()));
}
let s = scale / 100.0;
for &p in &pages {
let d = sizes[p];
let size = oriented(paper, settings.orientation, d.0 > d.1);
// Each tile shows `step` of the scaled page plus `overlap` shared with the next.
let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN);
if overlap * 2.0 >= tw.min(th) {
return Err(PrintError::Invalid("the overlap is larger than the tile".into()));
}
let (sw, sh) = (d.0 * s, d.1 * s);
let (stepx, stepy) = (tw - overlap, th - overlap);
let nx = (((sw - overlap) / stepx).ceil() as usize).max(1);
let ny = (((sh - overlap) / stepy).ceil() as usize).max(1);
// The counts saturate at usize::MAX for a huge scale, so their product can overflow.
if nx.checked_mul(ny).is_none_or(|n| n > 1024) {
return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into()));
}
// Tiles from the top-left, row by row.
for ty in 0..ny {
for tx in 0..nx {
// The window into the scaled page (scaled display space, y up).
let wx0 = tx as f64 * stepx;
let wy1 = sh - ty as f64 * stepy;
let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]);
let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s];
let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() };
if cut_marks {
let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN);
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
let dx = if x == x0 { -1.0 } else { 1.0 };
let dy = if y == y0 { -1.0 } else { 1.0 };
sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]);
sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]);
}
}
sheets.push(sheet);
}
}
}
}
}
Ok(sheets)
}
fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec<Sheet> {
let size = oriented(paper, orientation, false);
let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN];
pages
.iter()
.map(|&p| {
let d = sizes[p];
let crop = settings.crops.get(p).copied().flatten();
let rect = crop
.map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)])
.filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0)
.unwrap_or([0.0, 0.0, d.0, d.1]);
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]);
let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) {
(false, _) => 0,
(true, true) => 3,
(true, false) => 1,
};
if flip {
quarter = (quarter + 2) % 4;
}
let s = if turn {
fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1]))
} else {
fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1]))
};
Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }
})
.collect()
}