Files
ElyOffice/crates/ely-platform/src/print/linux/sheets.rs
T
ZacharyZhang-NYandClaude Opus 5.5 13546ea899 R7.6: Windows and Linux print (#3)
Windows prints through the system dialog at the document's paper;
Linux through the desktop portal, the dialog's choices laid out by Ely.
Round 3 findings fixed without a further review, as the rules say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 16:59:46 -04:00

389 lines
17 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.
//! The chosen pages laid on the chosen sheets, as PDF.
use std::collections::BTreeMap;
use anyhow::{Context as _, anyhow, bail};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, dictionary};
use super::choices::{Choices, Set};
use crate::print::ROUNDING;
/// Catalog entries that name pages no longer there.
const STALE: [&str; 4] = ["Outlines", "OpenAction", "AcroForm", "PageLabels"];
/// A PDF matrix, `[a b c d e f]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Matrix(pub [f64; 6]);
impl Matrix {
/// This, then `next`.
fn then(self, next: Matrix) -> Matrix {
let ([a, b, c, d, e, f], [g, h, i, j, k, l]) = (self.0, next.0);
Matrix([a * g + b * i, a * h + b * j, c * g + d * i, c * h + d * j, e * g + f * i + k, e * h + f * j + l])
}
pub(super) fn apply(self, (x, y): (f64, f64)) -> (f64, f64) {
let [a, b, c, d, e, f] = self.0;
(a * x + c * y + e, b * x + d * y + f)
}
}
/// A page's box, unit and turn.
pub(super) struct Shown {
corners: [f64; 4],
unit: f64,
turn: i64,
}
impl Shown {
pub(super) fn of(document: &Document, page: ObjectId) -> anyhow::Result<Self> {
let corners = |bounds: &Object| -> anyhow::Result<[f64; 4]> {
let numbers = bounds.as_array()?.iter().map(|n| Ok(f64::from(n.as_float()?))).collect::<anyhow::Result<Vec<_>>>()?;
let [x0, y0, x1, y1] = numbers[..] else {
bail!("a page box has {} numbers", numbers.len());
};
Ok([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
};
let media = corners(inherited(document, page, b"MediaBox")?.ok_or_else(|| anyhow!("a page has no media box"))?)?;
// A crop box is cut to the media box.
let [x0, y0, x1, y1] = match inherited(document, page, b"CropBox")? {
Some(crop) => {
let crop = corners(crop)?;
[crop[0].max(media[0]), crop[1].max(media[1]), crop[2].min(media[2]), crop[3].min(media[3])]
}
None => media,
};
if x1 <= x0 || y1 <= y0 {
bail!("a page's crop box lies outside its media box");
}
// UserUnit is the page's own, never inherited.
let unit = match document.get_dictionary(page)?.get(b"UserUnit") {
Ok(unit) => f64::from(document.dereference(unit)?.1.as_float()?),
Err(_) => 1.0,
};
let turn = inherited(document, page, b"Rotate")?.map_or(Ok(0), Object::as_i64)?.rem_euclid(360);
if !(unit > 0.0 && turn % 90 == 0) {
bail!("a page has a unit of {unit} or a turn of {turn}");
}
Ok(Self { corners: [x0, y0, x1, y1], unit, turn })
}
/// Width and height in points, as it shows.
pub(super) fn size(&self) -> (f64, f64) {
let [x0, y0, x1, y1] = self.corners;
let (width, height) = ((x1 - x0) * self.unit, (y1 - y0) * self.unit);
// A page turned a quarter shows its other side up.
if self.turn % 180 == 90 { (height, width) } else { (width, height) }
}
/// From the page's own space to its shown points.
fn matrix(&self) -> Matrix {
let [x0, y0, x1, y1] = self.corners;
let (w, h) = ((x1 - x0) * self.unit, (y1 - y0) * self.unit);
let unit = Matrix([self.unit, 0.0, 0.0, self.unit, -x0 * self.unit, -y0 * self.unit]);
// Rotate turns the page clockwise as shown.
unit.then(Matrix(match self.turn {
90 => [0.0, -1.0, 1.0, 0.0, 0.0, w],
180 => [-1.0, 0.0, 0.0, -1.0, w, h],
270 => [0.0, 1.0, -1.0, 0.0, h, 0.0],
_ => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
}))
}
}
/// `key` on the page or its nearest tree node.
pub(super) fn inherited<'a>(document: &'a Document, page: ObjectId, key: &[u8]) -> anyhow::Result<Option<&'a Object>> {
let mut node = document.get_dictionary(page)?;
// A loop in the tree must not hang.
for _ in 0..64 {
if let Ok(value) = node.get(key) {
return Ok(Some(document.dereference(value)?.1));
}
let Ok(parent) = node.get(b"Parent") else {
return Ok(None);
};
node = document.dereference(parent)?.1.as_dict()?;
}
bail!("the page tree is deeper than 64")
}
/// The PDF at `pdf`, laid out as `choices` asks.
pub(super) fn compose(pdf: &std::path::Path, choices: &Choices) -> anyhow::Result<Vec<u8>> {
let mut document = Document::load(pdf)?;
let pages: Vec<ObjectId> = document.get_pages().into_values().collect();
let picked = picked(choices, pages.len())?;
let mut forms = BTreeMap::new();
for &n in &picked {
if let std::collections::btree_map::Entry::Vacant(slot) = forms.entry(n) {
slot.insert(form(&mut document, pages[n])?);
}
}
let shown = picked.iter().map(|&n| Shown::of(&document, pages[n])).collect::<anyhow::Result<Vec<_>>>()?;
let laid = lay(choices, &picked, &shown)?;
let tree = document.new_object_id();
let (width, height) = choices.sheet;
let mut sheets = Vec::with_capacity(laid.len());
for placed in &laid {
let mut drawn = String::new();
let mut objects = Dictionary::new();
for &(n, Matrix([a, b, c, d, e, f])) in placed {
drawn.push_str(&format!("q {a:.4} {b:.4} {c:.4} {d:.4} {e:.4} {f:.4} cm /P{n} Do Q\n"));
objects.set(format!("P{n}"), forms[&n]);
}
let mut stream = Stream::new(Dictionary::new(), drawn.into_bytes());
stream.compress()?;
let contents = document.add_object(stream);
let media: Vec<Object> = vec![0.into(), 0.into(), (width as f32).into(), (height as f32).into()];
let mut sheet = dictionary! {
"Type" => "Page",
"Parent" => tree,
"MediaBox" => media,
"Resources" => dictionary! { "XObject" => objects },
"Contents" => contents,
};
if choices.upside_down {
sheet.set("Rotate", 180);
}
sheets.push(sheet);
}
let kids: Vec<Object> = arrange(choices, sheets.len())?.into_iter().map(|n| document.add_object(sheets[n].clone()).into()).collect();
let count = kids.len() as i64;
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into());
let root = document.trailer.get(b"Root")?.as_reference()?;
let catalog = document.get_object_mut(root)?.as_dict_mut()?;
catalog.set("Pages", tree);
for key in STALE {
catalog.remove(key.as_bytes());
}
document.prune_objects();
let mut bytes = Vec::new();
document.save_to(&mut bytes)?;
Ok(bytes)
}
/// The chosen pages, from zero, in the dialog's order.
fn picked(choices: &Choices, count: usize) -> anyhow::Result<Vec<usize>> {
let last = count.checked_sub(1).context("the document has no pages")? as u32;
let picked: Vec<usize> = match &choices.ranges {
None => (0..count).collect(),
Some(ranges) => ranges.iter().filter(|(from, _)| *from <= last).flat_map(|&(from, to)| from as usize..=to.min(last) as usize).collect(),
};
if picked.is_empty() {
bail!("none of the chosen pages are in the document");
}
Ok(picked)
}
/// Sheets in print order: set, reverse, then copies.
fn arrange(choices: &Choices, sheets: usize) -> anyhow::Result<Vec<usize>> {
let mut order: Vec<usize> = (0..sheets)
.filter(|n| match choices.set {
Set::All => true,
Set::Even => n % 2 == 1,
Set::Odd => n % 2 == 0,
})
.collect();
if order.is_empty() {
bail!("the chosen page set leaves no sheet to print");
}
if choices.reverse {
order.reverse();
}
let copies = choices.copies as usize;
Ok(match choices.collate {
true => order.iter().copied().cycle().take(order.len() * copies).collect(),
false => order.into_iter().flat_map(|n| std::iter::repeat_n(n, copies)).collect(),
})
}
/// Each sheet's pages: which page, and where it goes.
fn lay(choices: &Choices, picked: &[usize], shown: &[Shown]) -> anyhow::Result<Vec<Vec<(usize, Matrix)>>> {
let (width, height) = choices.sheet;
if choices.up == 1 {
return picked
.iter()
.zip(shown)
.map(|(&n, page)| {
let (w, h) = page.size();
let k = choices.scale;
let fits = |across: f64, down: f64| k * across <= width + ROUNDING && k * down <= height + ROUNDING;
// Placed from the sheet's top left; turned left if needed.
let place = match (fits(w, h), fits(h, w)) {
(true, _) => Matrix([k, 0.0, 0.0, k, 0.0, height - k * h]),
(false, true) => Matrix([0.0, k, -k, 0.0, k * h, height - k * w]),
(false, false) => bail!(
"page {} at {:.0}% is larger than the {:.0} × {:.0} mm paper",
n + 1,
k * 100.0,
width * 25.4 / 72.0,
height * 25.4 / 72.0
),
};
Ok(vec![(n, page.matrix().then(place))])
})
.collect();
}
let (cols, rows) = grid(choices.up, choices.sheet, shown);
let (cell_w, cell_h) = (width / f64::from(cols), height / f64::from(rows));
let mut sheets = Vec::new();
for (pages, looks) in picked.chunks(choices.up as usize).zip(shown.chunks(choices.up as usize)) {
let placed = pages.iter().zip(looks).enumerate().map(|(slot, (&n, page))| {
let (col, row) = cell(slot as u32, (cols, rows), choices);
let (w, h) = page.size();
let k = (cell_w / w).min(cell_h / h) * choices.scale;
// Centred in its cell.
let left = f64::from(col) * cell_w + (cell_w - k * w) / 2.0;
let bottom = height - f64::from(row + 1) * cell_h + (cell_h - k * h) / 2.0;
(n, page.matrix().then(Matrix([k, 0.0, 0.0, k, left, bottom])))
});
sheets.push(placed.collect());
}
Ok(sheets)
}
/// Columns and rows for `up` pages; the largest fit best.
fn grid(up: u32, (width, height): (f64, f64), shown: &[Shown]) -> (u32, u32) {
let (w, h) = shown.iter().map(Shown::size).fold((0.0, 0.0), |(a, b), (w, h)| (w.max(a), h.max(b)));
let fit = |(cols, rows): (u32, u32)| (width / f64::from(cols) / w).min(height / f64::from(rows) / h);
let grids = (1..=up).filter(|&cols| up.is_multiple_of(cols)).map(|cols| (cols, up / cols));
grids.fold((up, 1), |best, grid| if fit(grid) > fit(best) { grid } else { best })
}
/// Slot `n`'s column and row, by the dialog's n-up order.
fn cell(n: u32, (cols, rows): (u32, u32), choices: &Choices) -> (u32, u32) {
let order = choices.order;
let (col, row) = if order.across_first { (n % cols, n / cols) } else { (n / rows, n % rows) };
(if order.leftward { cols - 1 - col } else { col }, if order.upward { rows - 1 - row } else { row })
}
/// The page as a form, printable annotations drawn on top.
fn form(document: &mut Document, page: ObjectId) -> anyhow::Result<ObjectId> {
let mut drawn = b"q\n".to_vec();
for content in document.get_page_contents(page) {
drawn.extend(document.get_object(content)?.as_stream()?.decompressed_content()?);
drawn.push(b'\n');
}
drawn.extend(b"Q\n");
let mut resources = match inherited(document, page, b"Resources")? {
Some(resources) => resources.as_dict()?.clone(),
None => Dictionary::new(),
};
let mut objects = match resources.get(b"XObject") {
Ok(objects) => document.dereference(objects)?.1.as_dict()?.clone(),
Err(_) => Dictionary::new(),
};
let mut n = 0;
for (look, place) in appearances(document, page)? {
// Names the page already uses stay its own.
while objects.has(format!("ElyAnnot{n}").as_bytes()) {
n += 1;
}
let [a, b, c, d, e, f] = place.0;
drawn.extend(format!("q {a:.4} {b:.4} {c:.4} {d:.4} {e:.4} {f:.4} cm /ElyAnnot{n} Do Q\n").into_bytes());
objects.set(format!("ElyAnnot{n}"), look);
let look = document.get_object_mut(look)?.as_stream_mut()?;
if look.dict.get(b"Subtype").is_err() {
look.dict.set("Subtype", "Form");
}
}
resources.set("XObject", objects);
let [x0, y0, x1, y1] = Shown::of(document, page)?.corners;
let bounds: Vec<Object> = [x0, y0, x1, y1].into_iter().map(|n| (n as f32).into()).collect();
let mut dict = dictionary! { "Type" => "XObject", "Subtype" => "Form", "BBox" => bounds, "Resources" => resources };
if let Some(group) = inherited(document, page, b"Group")? {
dict.set("Group", group.clone());
}
let mut stream = Stream::new(dict, drawn);
stream.compress()?;
Ok(document.add_object(stream))
}
/// Printable annotations' looks, and where each goes on the page.
fn appearances(document: &Document, page: ObjectId) -> anyhow::Result<Vec<(ObjectId, Matrix)>> {
let Ok(annots) = document.get_dictionary(page)?.get(b"Annots") else {
return Ok(Vec::new());
};
let turn = Shown::of(document, page)?.turn;
let mut looks = Vec::new();
for annot in document.dereference(annots)?.1.as_array()? {
let annot = document.dereference(annot)?.1.as_dict()?;
let flags = annot.get(b"F").and_then(Object::as_i64).unwrap_or(0);
// Hidden, or not marked for print: left out.
if flags & 2 != 0 || flags & 4 == 0 {
continue;
}
let Some(look) = appearance(document, annot)? else {
continue;
};
let stream = document.get_object(look)?.as_stream()?;
let numbers = |object: &Object| -> anyhow::Result<Vec<f64>> {
document.dereference(object)?.1.as_array()?.iter().map(|n| Ok(f64::from(n.as_float()?))).collect()
};
let bounds = numbers(stream.dict.get(b"BBox")?)?;
let rect = numbers(annot.get(b"Rect")?)?;
let own = match stream.dict.get(b"Matrix") {
Ok(matrix) => Matrix(numbers(matrix)?.try_into().map_err(|_| anyhow!("an appearance matrix is not six numbers"))?),
Err(_) => Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
};
let ([bx0, by0, bx1, by1], [rx0, ry0, rx1, ry1]) = (four(&bounds)?, four(&rect)?);
// The look's box, through its matrix, fills the rectangle.
let corners = [(bx0, by0), (bx1, by0), (bx0, by1), (bx1, by1)].map(|corner| own.apply(corner));
let (lx0, ly0) = corners.iter().fold((f64::MAX, f64::MAX), |(x, y), &(a, b)| (x.min(a), y.min(b)));
let (lx1, ly1) = corners.iter().fold((f64::MIN, f64::MIN), |(x, y), &(a, b)| (x.max(a), y.max(b)));
if lx1 - lx0 <= 0.0 || ly1 - ly0 <= 0.0 {
continue;
}
let (sx, sy) = ((rx1 - rx0).abs() / (lx1 - lx0), (ry1 - ry0).abs() / (ly1 - ly0));
let mut place = Matrix([sx, 0.0, 0.0, sy, rx0.min(rx1) - sx * lx0, ry0.min(ry1) - sy * ly0]);
// NoRotate: upright as shown, turned about its top left.
if flags & 16 != 0 {
place = place.then(unturned(turn, (rx0.min(rx1), ry0.max(ry1))));
}
looks.push((look, place));
}
Ok(looks)
}
/// Turns back a page's `turn` about `(x, y)`.
fn unturned(turn: i64, (x, y): (f64, f64)) -> Matrix {
let (cos, sin) = match turn {
90 => (0.0, 1.0),
180 => (-1.0, 0.0),
270 => (0.0, -1.0),
_ => (1.0, 0.0),
};
Matrix([cos, sin, -sin, cos, x - cos * x + sin * y, y - sin * x - cos * y])
}
fn four(numbers: &[f64]) -> anyhow::Result<[f64; 4]> {
numbers.try_into().map_err(|_| anyhow!("an annotation's box is not four numbers"))
}
/// An annotation's normal look in its state; None draws nothing.
fn appearance(document: &Document, annot: &Dictionary) -> anyhow::Result<Option<ObjectId>> {
let subtype = annot.get(b"Subtype")?.as_name()?;
let normal = match annot.get(b"AP") {
Ok(looks) => document.dereference(looks)?.1.as_dict()?.get(b"N").ok(),
Err(_) => None,
};
let Some(normal) = normal else {
// Links and popups have no look of their own.
if matches!(subtype, b"Link" | b"Popup") {
return Ok(None);
}
bail!("a printable {} annotation has no appearance", String::from_utf8_lossy(subtype));
};
if let Ok(id) = normal.as_reference()
&& document.get_object(id)?.as_stream().is_ok()
{
return Ok(Some(id));
}
// A look per state: the one /AS names, if any.
let states = document.dereference(normal)?.1.as_dict()?;
let state = annot.get(b"AS").and_then(Object::as_name).context("an annotation with states names none")?;
Ok(states.get(state).ok().map(Object::as_reference).transpose()?)
}
#[cfg(test)]
mod tests;