use pdfrum_common::kurbo::Affine;
use crate::content::num::write_matrix;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PageEdit {
pub(crate) start_x: f32,
pub(crate) start_y: f32,
pub(crate) scale: f32,
}
impl PageEdit {
#[must_use]
pub(crate) fn matrix(self) -> Affine {
Affine::new([
f64::from(self.scale),
0.0,
0.0,
f64::from(self.scale),
f64::from(self.start_x),
f64::from(self.start_y),
])
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct NupGrid {
pub(crate) sheet_width: f32,
pub(crate) sheet_height: f32,
pub(crate) x: u32,
pub(crate) y: u32,
}
impl NupGrid {
#[must_use]
pub(crate) fn slot_size(self) -> (f32, f32) {
(
self.sheet_width / as_f32(self.x.max(1)),
self.sheet_height / as_f32(self.y.max(1)),
)
}
#[must_use]
pub(crate) fn per_sheet(self) -> u32 {
self.x.saturating_mul(self.y)
}
#[must_use]
pub(crate) fn slot(self, index: u32) -> (u32, u32) {
let x = self.x.max(1);
let column = index % x;
let row_from_top = index / x;
let row = self.y.saturating_sub(row_from_top).saturating_sub(1);
(column, row)
}
#[must_use]
pub(crate) fn edit(self, index: u32, page_width: f32, page_height: f32) -> PageEdit {
let (slot_w, slot_h) = self.slot_size();
let (column, row) = self.slot(index);
let mut start_x = as_f32(column) * slot_w;
let mut start_y = as_f32(row) * slot_h;
if page_width <= 0.0 || page_height <= 0.0 {
return PageEdit {
start_x,
start_y,
scale: 1.0,
};
}
let ratio_x = slot_w / page_width;
let ratio_y = slot_h / page_height;
let scale = ratio_x.min(ratio_y);
if ratio_x > ratio_y {
start_x += (slot_w - page_width * scale) / 2.0;
} else {
start_y += (slot_h - page_height * scale) / 2.0;
}
PageEdit {
start_x,
start_y,
scale,
}
}
}
fn as_f32(value: u32) -> f32 {
#[expect(
clippy::cast_precision_loss,
reason = "a grid dimension past 2^24 gives every slot a sub-pixel \
width; the geometry is meaningless long before the cast is"
)]
{
value as f32
}
}
#[must_use]
pub(crate) fn sub_page_fragment(name: &str, edit: PageEdit) -> String {
let mut out = String::from("q\n");
write_matrix(&mut out, edit.matrix());
out.push_str(" cm\n/");
out.push_str(name);
out.push_str(" Do Q\n");
out
}
#[cfg(test)]
mod tests {
#![expect(
clippy::float_cmp,
reason = "these assertions pin exact values a writer must produce"
)]
use super::{NupGrid, PageEdit, sub_page_fragment};
fn grid(width: f32, height: f32, x: u32, y: u32) -> NupGrid {
NupGrid {
sheet_width: width,
sheet_height: height,
x,
y,
}
}
#[test]
fn the_sub_page_fragment_golden() {
let edit = PageEdit {
start_x: 0.000_001,
start_y: 1_000_000_000_000.0,
scale: 0.5,
};
assert_eq!(
sub_page_fragment("foo", edit),
"q\n.5 0 0 .5 .000001 1000000000000 cm\n/foo Do Q\n"
);
}
#[test]
fn slots_fill_left_to_right_top_to_bottom() {
let g = grid(600.0, 600.0, 3, 2);
assert_eq!(g.slot(0), (0, 1));
assert_eq!(g.slot(1), (1, 1));
assert_eq!(g.slot(2), (2, 1));
assert_eq!(g.slot(3), (0, 0));
assert_eq!(g.slot(4), (1, 0));
assert_eq!(g.slot(5), (2, 0));
}
#[test]
fn a_single_column_stacks_downwards() {
let g = grid(600.0, 900.0, 1, 3);
assert_eq!(g.slot(0), (0, 2));
assert_eq!(g.slot(1), (0, 1));
assert_eq!(g.slot(2), (0, 0));
}
#[test]
fn sheet_counts_round_up() {
let sheets = |g: NupGrid, pages: usize| pages.div_ceil(g.per_sheet().max(1) as usize);
assert_eq!(sheets(grid(612.0, 792.0, 2, 1), 5), 3);
assert_eq!(sheets(grid(612.0, 792.0, 5, 1), 5), 1);
assert_eq!(sheets(grid(792.0, 612.0, 8, 1), 5), 1);
assert_eq!(sheets(grid(792.0, 612.0, 128, 1), 5), 1);
assert_eq!(sheets(grid(792.0, 612.0, 3, 1), 5), 2);
}
#[test]
fn an_exact_fit_is_centred_on_neither_axis() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(2, 300.0, 300.0);
assert_eq!(edit.scale, 1.0);
assert_eq!((edit.start_x, edit.start_y), (0.0, 0.0));
}
#[test]
fn the_slack_axis_is_the_one_centred() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(2, 300.0, 150.0);
assert_eq!(edit.scale, 1.0);
assert_eq!(edit.start_x, 0.0, "the tight axis is not moved");
assert_eq!(edit.start_y, 75.0, "the slack axis is centred");
let edit = g.edit(2, 150.0, 300.0);
assert_eq!(edit.start_x, 75.0);
assert_eq!(edit.start_y, 0.0);
}
#[test]
fn a_page_smaller_than_its_slot_is_scaled_up() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(2, 150.0, 150.0);
assert_eq!(edit.scale, 2.0);
}
#[test]
fn a_page_larger_than_its_slot_is_scaled_down_uniformly() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(2, 600.0, 300.0);
assert_eq!(edit.scale, 0.5);
assert_eq!(edit.start_y, 75.0);
}
#[test]
fn slot_offsets_place_the_sub_page_on_the_sheet() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(1, 300.0, 300.0);
assert_eq!((edit.start_x, edit.start_y), (300.0, 300.0));
}
#[test]
fn a_degenerate_source_page_is_placed_unscaled() {
let g = grid(600.0, 600.0, 2, 2);
let edit = g.edit(0, 0.0, 300.0);
assert_eq!(edit.scale, 1.0);
}
#[test]
fn the_matrix_scales_then_translates() {
let edit = PageEdit {
start_x: 10.0,
start_y: 20.0,
scale: 0.5,
};
assert_eq!(edit.matrix().as_coeffs(), [0.5, 0.0, 0.0, 0.5, 10.0, 20.0]);
}
#[test]
fn slot_size_divides_the_sheet() {
assert_eq!(grid(612.0, 792.0, 2, 1).slot_size(), (306.0, 792.0));
assert_eq!(grid(600.0, 600.0, 3, 2).slot_size(), (200.0, 300.0));
}
}