M7: header and footer can shrink the page content out of their way (#2140)

HeaderFooter::shrink scales the original page content evenly into the
band the header and footer leave free, centred across the page, in
display space so rotated pages shrink the way they are shown. The scale
is a content stream first inside the q/Q wrapper, tagged as part of the
header and footer, so Remove and Update take it away again and
backgrounds behind the page stay full size. Margins that leave no room
are refused before anything is drawn.
This commit is contained in:
Gert Pellin
2026-10-21 09:02:09 +02:00
parent d695bc5654
commit 01149dea77
2 changed files with 165 additions and 1 deletions
+71 -1
View File
@@ -65,12 +65,24 @@ pub struct HeaderFooter {
pub margins: [f64; 4],
/// The number `<<1>>` shows on the first page of the range.
pub start_number: u32,
/// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance
/// Options): the original content is scaled down evenly into the band the header and footer
/// leave free, centred across the page. Remove and Update take the scaling away again.
pub shrink: bool,
}
impl Default for HeaderFooter {
/// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right.
fn default() -> Self {
Self { text: Default::default(), font_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 }
Self {
text: Default::default(),
font_size: 8.0,
color: [0.0; 3],
underline: false,
margins: [36.0, 36.0, 72.0, 72.0],
start_number: 1,
shrink: false,
}
}
}
@@ -83,6 +95,42 @@ impl HeaderFooter {
pub fn first_undrawable(&self) -> Option<char> {
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
}
/// The room the header and the footer take at the top and the bottom of a page, in points:
/// the margin, the lines of the tallest box, and a clearance of half the font size. A side
/// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free.
pub fn bands(&self) -> (f64, f64) {
let size = self.font_size;
let line_h = size * 1.2;
let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max();
let (header, footer) = self.text.split_at(3);
// Header lines hang below the top margin (a full line each); the footer's last line sits
// on the bottom margin, so only its capitals (0.8 of the size) rise above it.
let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0);
let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0);
(top, bottom)
}
}
/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free,
/// for a page shown `size` (display space) with view matrix `view` (display to user space): an
/// even scale, centred across, the content's bottom on the footer's band.
fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> {
let (w, h) = size;
let (top, bottom) = hf.bands();
let free = h - top - bottom;
if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) {
return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into()));
}
let k = free / h;
let v = kurbo::Affine::new(view);
let det = v.determinant();
if !(det.is_finite() && det.abs() > 1e-12) {
return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into()));
}
let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]);
// User space to display space, shrink there, and back.
Ok((v * shrink * v.inverse()).as_coeffs())
}
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
@@ -552,6 +600,18 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Ok(())
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
/// on it and is left alone.
fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) };
let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
list.insert(open + 1, stream);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
}
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
@@ -632,6 +692,11 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
u64::MAX
)));
}
// Shrinking needs room on every page: refuse before anything is drawn or replaced.
let shrinks = pages
.iter()
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
.collect::<Result<Vec<_>, _>>()?;
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
@@ -679,6 +744,11 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
add_resources(doc, page, None, None)?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
let cm = format!("{} cm\n", m.map(matrix_number).join(" "));
let page = &page_list(doc)[i];
place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?;
}
}
Ok(())
}
+94
View File
@@ -156,6 +156,100 @@ fn replace_and_remove_restore_the_original_content() {
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
/// The six numbers of the first `… cm` in `content`.
fn first_cm(content: &str) -> [f64; 6] {
let line = content.lines().find(|l| l.ends_with(" cm")).unwrap_or_else(|| panic!("no cm in {content:?}"));
let v: Vec<f64> = line.split_whitespace().take(6).map(|x| x.parse().unwrap()).collect();
<[f64; 6]>::try_from(v).unwrap()
}
fn map_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01
}
fn shrinking(header: &str, footer: &str) -> HeaderFooter {
let mut hf = HeaderFooter { shrink: true, ..HeaderFooter::default() };
hf.text[1] = header.into();
hf.text[5] = footer.into();
hf
}
#[test]
fn shrink_scales_the_original_content_into_the_band_between_header_and_footer() {
let mut doc = fixture();
// 8 pt text, margins 36 top and bottom: the header takes 36 + 8 (+ 4 clearance) at the top,
// the footer 36 + 6.4 (its capitals above the baseline, + 4) at the bottom.
add_header_footer(&mut doc, &[0], &shrinking("ACME", "<<Bates Number#6#1##>>"), false, &cx()).unwrap();
let doc = reopen(&doc);
let s = streams(&doc, 0);
// q-wrapper, shrink, original, Q-wrapper, header/footer.
assert_eq!(s.len(), 5, "{s:?}");
assert_eq!((s[0].as_str(), s[3].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n"));
assert!(s[2].contains("(Hi) Tj"), "the original content follows the shrink: {s:?}");
let m = first_cm(&s[1]);
let k = (800.0 - 48.0 - 46.4) / 800.0;
assert!((m[0] - k).abs() < 1e-3 && (m[3] - k).abs() < 1e-3 && m[1] == 0.0 && m[2] == 0.0, "uniform scale {k}: {m:?}");
// The page's corners land on the band, centred across.
assert!(near(map_point(m, 0.0, 0.0), ((600.0 - 600.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, 600.0, 800.0), ((600.0 + 600.0 * k) / 2.0, 800.0 - 48.0)), "{m:?}");
assert!(s[4].contains("(000001) Tj"), "{}", s[4]);
}
#[test]
fn shrink_follows_the_display_space_of_a_rotated_page() {
let mut doc = fixture();
// Page 2 is rotated 90°: shown 800 wide and 600 high. A footer alone keeps the top.
add_header_footer(&mut doc, &[1], &shrinking("", "<<1>>"), false, &cx()).unwrap();
let s = streams(&doc, 1);
let m = first_cm(&s[1]);
let page = &pdfcraft_model::pages(&doc)[1];
let v = page.view_matrix(&doc);
let k = (600.0 - 46.4) / 600.0;
// Display (x, y) is user v·(x, y): the shrunk content's display corners.
let to_user = |x: f64, y: f64| map_point(v, x, y);
assert!(near(map_point(m, to_user(0.0, 0.0).0, to_user(0.0, 0.0).1), to_user((800.0 - 800.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, to_user(800.0, 600.0).0, to_user(800.0, 600.0).1), to_user((800.0 + 800.0 * k) / 2.0, 600.0)), "{m:?}");
}
#[test]
fn shrink_is_undone_by_remove_and_not_doubled_by_update() {
let mut doc = fixture();
let original = streams(&doc, 0);
add_background(&mut doc, &[0], &Background::default(), false).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("First", ""), false, &cx()).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("Second", ""), true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains(" cm") && !x.contains("/PCMark")).count(), 2, "one shrink besides the original's own cm: {s:?}");
assert!(s[0].contains("/PCMark /Background"), "the background stays behind, full size: {s:?}");
// Update without shrinking: back to full size.
let mut plain = shrinking("Third", "");
plain.shrink = false;
add_header_footer(&mut doc, &[0], &plain, true, &cx()).unwrap();
assert_eq!(streams(&doc, 0).len(), 5, "background, wrapper, original, wrapper, header: {:?}", streams(&doc, 0));
add_header_footer(&mut doc, &[0], &shrinking("Fourth", ""), true, &cx()).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 2, "the header and its shrink");
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
#[test]
fn shrink_with_no_room_left_is_refused_without_drawing() {
let mut doc = fixture();
let mut hf = shrinking("Top", "Bottom");
hf.margins = [400.0, 400.0, 72.0, 72.0];
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("no room"), "{err}");
assert!(!doc.is_modified());
// A page without content has nothing to shrink: it just gets the header.
let mut doc = fixture();
add_header_footer(&mut doc, &[2], &shrinking("Top", ""), false, &cx()).unwrap();
assert_eq!(streams(&doc, 2).len(), 1);
}
#[test]
fn watermarks_rotate_fade_and_can_go_behind() {
let mut doc = fixture();