diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b4105..b86cabdc50 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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 { 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) -> 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::, _>>()?; 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(()) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c66..042e22d6b6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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 = 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", "<>"), 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();