From 01149dea77b167510637a92118684c3f2b9bfbb1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 12:32:28 +0200 Subject: [PATCH 1/3] 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. --- crates/edit/src/lib.rs | 72 +++++++++++++++++++++++++++++- crates/edit/src/tests.rs | 94 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b410..b86cabdc5 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 879ebc6c6..042e22d6b 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(); From 5bf48e0f8c8fa95d58a359fb50043a3155e5392c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 12:40:39 +0200 Subject: [PATCH 2/3] M7: doc_header_footer takes shrink (#2140) The tool passes shrink through to the header and footer, with an end-to-end test that reads the moved text back from a saved copy, refuses margins that leave no room and checks that Remove brings the content back to full size. --- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 3 ++- crates/automation/tests/automation.rs | 37 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0e2e0dd69..52a397c48 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1207,6 +1207,7 @@ impl Automation { if let Some(s) = a.opt_int("start_number")? { hf.start_number = s.clamp(1, u32::MAX as i64) as u32; } + hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); Edit::AddHeaderFooter { pages, settings: hf, replace } } "doc_watermark" => { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eeb..74a194146 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -869,7 +869,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( @@ -882,6 +882,7 @@ pub fn tools() -> Vec { "color": { "type": "string", "description": "#RRGGBB or a colour name." }, "margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." }, "start_number": { "type": "integer", "minimum": 1 }, + "shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." }, "replace": { "type": "boolean" }, }), &["doc"], diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f196..2e1fdee4f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2241,6 +2241,43 @@ fn headers_watermarks_and_backgrounds_through_tools() { ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); } +#[test] +fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { + let dir = workdir("hf-shrink"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The box of "Page 1" (left, top, width, height in points), read back from a saved copy. + let mut saved = 0; + let mut text_box = |a: &mut Automation| -> [f64; 4] { + saved += 1; + let path = format!("shrunk{saved}.pdf"); + ok(a, "doc_save", json!({ "doc": doc, "path": path })); + let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let found = ok(a, "text_find", json!({ "doc": copy, "query": "Page 1" })); + let r = found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("Page 1 is on the page: {found}")); + let v: Vec = r.iter().map(|x| x.as_f64().unwrap()).collect(); + [v[0], v[1], v[2] - v[0], v[3] - v[1]] + }; + let before = text_box(&mut a); + ok( + &mut a, + "doc_header_footer", + json!({ "doc": doc, "pages": [1], "header_center": "ACME", "footer_right": "<>", "shrink": true }), + ); + let after = text_box(&mut a); + // 200 × 300 page, 8 pt text and 36 pt margins: the header takes 48 pt, the footer 46.4 pt. + let k = (300.0 - 48.0 - 46.4) / 300.0; + assert!((after[2] / before[2] - k).abs() < 0.02 && (after[3] / before[3] - k).abs() < 0.02, "scaled by {k}: {before:?} → {after:?}"); + assert!((after[0] - (20.0 * k + (200.0 - 200.0 * k) / 2.0)).abs() < 1.0, "centred across: {before:?} → {after:?}"); + assert!(page_text(&mut a, doc)[0].contains("000001"), "the Bates number is drawn"); + // Margins that leave no room are refused; Remove brings the content back to full size. + let full = json!({ "doc": doc, "header_center": "X", "margins": [295, 36, 72, 72], "shrink": true, "replace": true }); + assert!(matches!(a.call("doc_header_footer", &full), Err(ToolError::Failed(_))), "no room"); + ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "header_footer" })); + let restored = text_box(&mut a); + assert!(restored.iter().zip(before).all(|(r, b)| (r - b).abs() < 0.01), "{before:?} → {restored:?}"); +} + #[test] fn watermarks_move_from_the_page_centre_through_tools() { let dir = workdir("wm-offset"); From 6beecdacf931a3d4c5753edc073070fc496cbf95 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 12:58:04 +0200 Subject: [PATCH 3/3] M7: Header & footer dialog: Shrink the page content checkbox (#2140) The Header and Footer dialog (also opened by Bates numbering) gets a checkbox that turns on the shrink, and its preview shows the band the page content shrinks into. The label is translated in every catalog (the catalog checksums in ATTRIBUTION.toml follow). The parity entry for the shrink is shipped, with its limits: annotations and added text and images are not moved yet. --- ATTRIBUTION.toml | 44 +++++++++++++------------- ROADMAP.md | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/marks_ui.rs | 10 ++++++ crates/ui-egui/tests/ui_group/marks.rs | 15 +++++++++ parity/acrobat-features.toml | 10 +++--- 27 files changed, 75 insertions(+), 27 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db3dd6124..612daeb48 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" +sha256 = "cc5cefd2b01894dccf4fa1a2e2ca953380c1f727b1d59dfcb5808c893f3fe339" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" +sha256 = "354fb5a45ebd3efd94776be5ee776a7f7055d571891447cb84107347ce862b24" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" +sha256 = "54c0108eaede065a9130696130892b7069dbcd7a2389c4e3d87d8935e514a92a" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" +sha256 = "fc09a706dc94b99c58b0411e819f6141e3de96777bfc1ff5a00f7de739e2217d" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" +sha256 = "f3994c36cc9d027f07725be8fe4d858bd7d50e54b7695cfc99f9dd394100c427" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" +sha256 = "092c511a516c828fd127d6a5a3a902b0ab18a7de7ca1842e05e4b1acb99d23a1" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" +sha256 = "f72f5ec4939ebf1099d6a39a49293aa334ea797aebdc3e775c011dabccf3a500" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" +sha256 = "391030d0d86aa82e5b03fe3414cc0070ab000bbfe021691ef3f6695a06d21112" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" +sha256 = "44c331cc28758e60aeacbdf3a6ad3b433d34486b599a0e25273cae4b79ba47b2" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" +sha256 = "8cb38a3fa2c1eb2a8b340eb12148cc3e7db7da5b56c4b254e2fa912226a11247" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" +sha256 = "ba2433d85043992917df36bb095cadeb10b45b09a534fe3eb8f8ed48935de577" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" +sha256 = "fbb6d790af0daef88e986e45d09a54b16d48009e0da649b1d1ff50f4c939e3ef" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" +sha256 = "4ec90e877277c505862389d850af7d3bc8b8e9f75bac739171cc2eae6d08c5f8" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" +sha256 = "b5c4f04b0000b7fbef5ab24add2ebbdf3da46104540294530a030be695e1a30f" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" +sha256 = "432845aeaee622c0f932aafb630421024e077febc6b1215b9484f46a14686d05" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" +sha256 = "a8586b83b7422b39fd34cdd4008cfd270f0353ecaffc78d7b87ba21c52fcc7cd" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" +sha256 = "245d1f0746787e1fbc720ac9bb62e9512c9e3237c82a53c3fbade3baea1f5aae" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" +sha256 = "a054b47831871695753beb11f0c2c3fda668328103189f9448b939c7cac7eb99" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" +sha256 = "70703cfc7125cd85e39eeab470f5010199c74bce41158de0c4dd1f22015813b2" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" +sha256 = "5496a3e28a70b050d326f976ebbdf084322af417a1b85715111e55d6f9c00eae" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" +sha256 = "25b809cd0e758a673810646fb7e3d1ab5f36ccc68c9621df9f8ba6f40f32857d" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" +sha256 = "8b8be48d2dd13422a769a4497d57abb483d81c9c14bce3abf921e8860fc4ee7b" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index b1283e775..929c0b826 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,6 +201,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. - **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. - **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged. +- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e1..cda2894c3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -811,6 +811,7 @@ Insert Page Number إدراج رقم الصفحة Insert Date إدراج التاريخ Start page number رقم صفحة البداية + Shrink the page content to make room for the header and footer تصغير محتوى الصفحة لإفساح المجال للرأس والتذييل Source المصدر Fit to page ملاءمة الصفحة Rotation التدوير diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae..e643e3172 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -484,6 +484,7 @@ Insert Page Number Вмъкни номер на страница Insert Date Вмъкни дата Start page number Начален номер на страница + Shrink the page content to make room for the header and footer Смаляване на съдържанието на страницата, за да има място за горния и долния колонтитул Source Източник Fit to page Побери в страницата Rotation Завъртане diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 993632552..a11471340 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -886,6 +886,7 @@ key Space Mezerník Insert Page Number Vložit číslo stránky Insert Date Vložit datum Start page number Počáteční číslo stránky + Shrink the page content to make room for the header and footer Zmenšit obsah stránky, aby se vešlo záhlaví a zápatí Source Zdroj Fit to page Přizpůsobit stránce Rotation Otočení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d9207eb9..0b537fd9c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -789,6 +789,7 @@ Insert Page Number Seitenzahl einfügen Insert Date Datum einfügen Start page number Erste Seitenzahl + Shrink the page content to make room for the header and footer Seiteninhalt verkleinern, damit Kopf- und Fußzeile Platz haben Source Quelle Fit to page An Seite anpassen Rotation Drehung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06fe2b540..849204014 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -424,6 +424,7 @@ Insert Page Number Εισαγωγή αριθμού σελίδας Insert Date Εισαγωγή ημερομηνίας Start page number Αρχικός αριθμός σελίδας + Shrink the page content to make room for the header and footer Σμίκρυνση του περιεχομένου της σελίδας ώστε να χωρούν η κεφαλίδα και το υποσέλιδο Source Πηγή Appear behind page Εμφάνιση πίσω από τη σελίδα Appear on top of page Εμφάνιση πάνω από τη σελίδα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992..2fbf84825 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -805,6 +805,7 @@ Insert Page Number Insertar número de página Insert Date Insertar fecha Start page number Número de página inicial + Shrink the page content to make room for the header and footer Reducir el contenido de la página para dejar espacio al encabezado y al pie de página Source Origen Fit to page Ajustar a la página Rotation Rotación diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9bdcf0bc8..6f8e2608d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -785,6 +785,7 @@ Insert Page Number Insérer le numéro de page Insert Date Insérer la date Start page number Numéro de page de départ + Shrink the page content to make room for the header and footer Réduire le contenu de la page pour laisser de la place à l'en-tête et au pied de page Source Source Fit to page Ajuster à la page Rotation Rotation diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d537677c7..c5217a10a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -782,6 +782,7 @@ Insert Page Number Oldalszám beszúrása Insert Date Dátum beszúrása Start page number Kezdő oldalszám + Shrink the page content to make room for the header and footer Az oldal tartalmának kicsinyítése, hogy elférjen az élőfej és az élőláb Source Forrás Fit to page Oldalhoz igazítás Rotation Elforgatás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 35cba7a41..47eec4ff1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -808,6 +808,7 @@ Insert Page Number Inserisci numero di pagina Insert Date Inserisci data Start page number Numero di pagina iniziale + Shrink the page content to make room for the header and footer Riduci il contenuto della pagina per fare spazio a intestazione e piè di pagina Source Origine Fit to page Adatta alla pagina Rotation Rotazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e845a736d..e629a532c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -812,6 +812,7 @@ Insert Page Number ページ番号を挿入 Insert Date 日付を挿入 Start page number 開始ページ番号 + Shrink the page content to make room for the header and footer ヘッダーとフッターの領域を空けるためにページの内容を縮小 Source ソース Fit to page ページに合わせる Rotation 回転 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1b..eca1b8637 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -768,6 +768,7 @@ Insert Page Number Бет нөмірін енгізу Insert Date Күнді енгізу Start page number Бастапқы бет нөмірі + Shrink the page content to make room for the header and footer Жоғарғы және төменгі колонтитулға орын босату үшін бет мазмұнын кішірейту Source Дереккөз Fit to page Бетке сыйдыру Rotation Бұрылу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc3304..8b6e6812a 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1115,6 +1115,7 @@ Start Pradėti Start Checking Pradėti tikrinimą Start page number Pradinio puslapio numeris + Shrink the page content to make room for the header and footer Sumažinti puslapio turinį, kad tilptų antraštė ir poraštė Status Būsena Steps Veiksmai Strength: {s} Stiprumas: {s} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c432a6a92..2f46a3948 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -804,6 +804,7 @@ Insert Page Number Wstaw numer strony Insert Date Wstaw datę Start page number Początkowy numer strony + Shrink the page content to make room for the header and footer Zmniejsz zawartość strony, aby zrobić miejsce na nagłówek i stopkę Source Źródło Fit to page Dopasuj do strony Rotation Obrót diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 89ff80f9d..8cf66af4e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -439,6 +439,7 @@ Insert Page Number Inserir número de página Insert Date Inserir data Start page number Número da página inicial + Shrink the page content to make room for the header and footer Reduzir o conteúdo da página para dar espaço ao cabeçalho e ao rodapé Source Origem Fit to page Ajustar à página Rotation Rotação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 73f21aa0d..cdf4902d4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -805,6 +805,7 @@ Insert Page Number Вставить номер страницы Insert Date Вставить дату Start page number Начальный номер страницы + Shrink the page content to make room for the header and footer Уменьшить содержимое страницы, чтобы освободить место для колонтитулов Source Источник Fit to page По странице Rotation Поворот diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc96..815bc277c 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1733,6 +1733,7 @@ Start Začetek Start Checking Začni preverjanje Start page number Številka začetne strani + Shrink the page content to make room for the header and footer Pomanjšaj vsebino strani, da bo prostor za glavo in nogo Status Stanje Steps Koraki Sticky note Opomba diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47aee36f8..ac24a0fd7 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -786,6 +786,7 @@ comment menu Edit సవరించు Insert Page Number పేజీ సంఖ్యను చొప్పించు Insert Date తేదీని చొప్పించు Start page number ప్రారంభ పేజీ సంఖ్య + Shrink the page content to make room for the header and footer హెడర్ మరియు ఫుటర్‌కు చోటు కల్పించేందుకు పేజీ కంటెంట్‌ను కుదించు Source మూలం Fit to page పేజీకి సరిపోయేలా Rotation భ్రమణం diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608..2e562e159 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -767,6 +767,7 @@ Insert Page Number Sayfa Numarası Ekle Insert Date Tarih Ekle Start page number Başlangıç sayfa numarası + Shrink the page content to make room for the header and footer Üst bilgi ve alt bilgiye yer açmak için sayfa içeriğini küçült Source Kaynak Fit to page Sayfaya sığdır Rotation Döndürme diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7993a0618..2ef5ab15e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -809,6 +809,7 @@ Insert Page Number Вставити номер сторінки Insert Date Вставити дату Start page number Початковий номер сторінки + Shrink the page content to make room for the header and footer Зменшити вміст сторінки, щоб звільнити місце для колонтитулів Source Джерело Fit to page За розміром сторінки Rotation Поворот diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6..f3d4715be 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1106,6 +1106,7 @@ theme Appearance Giao diện Start Bắt đầu Start Checking Bắt đầu kiểm tra Start page number Số trang bắt đầu + Shrink the page content to make room for the header and footer Thu nhỏ nội dung trang để chừa chỗ cho đầu trang và chân trang Status Trạng thái Steps Các bước Strength: {s} Độ mạnh: {s} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddecc655..8ee82b564 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -491,6 +491,7 @@ Insert Page Number 插入页码 Insert Date 插入日期 Start page number 起始页码 + Shrink the page content to make room for the header and footer 缩小页面内容,为页眉和页脚留出空间 Source 来源 Fit to page 适合页面 Rotation 旋转 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649..f7ddd8763 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -444,6 +444,7 @@ Insert Page Number 插入頁碼 Insert Date 插入日期 Start page number 起始頁碼 + Shrink the page content to make room for the header and footer 縮小頁面內容,為頁首和頁尾留出空間 Source 來源 Fit to page 適合頁面 Rotation 旋轉 diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index d91a69f93..7f2dfb892 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -223,6 +223,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.label(tl!("Start page number")); ui.add(egui::DragValue::new(&mut d.hf.start_number).range(1..=999_999)); }); + ui.add(widgets::checkbox(&mut d.hf.shrink, tl!("Shrink the page content to make room for the header and footer"))); } MarkKind::Watermark => { ui.horizontal(|ui| { @@ -306,11 +307,20 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.add_space(8.0); // Preview of the current page. let hf = d.hf.clone(); + let size_h = page.1.max(1.0); let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit); let file_name = d.use_file.then(|| d.file.as_ref().map(|f| f.0.clone())).flatten(); ui.horizontal(|ui| { preview(ui, t, page, |p, r, s| match kind { MarkKind::HeaderFooter => { + if hf.shrink { + // The band the page content shrinks into, centred across. + let (top, bottom) = hf.bands(); + let k = ((size_h - top - bottom) / size_h).clamp(0.0, 1.0) as f32; + let band = Rect::from_min_max(pos2(r.left(), r.top() + top as f32 * s), pos2(r.right(), r.bottom() - bottom as f32 * s)); + let content = Rect::from_center_size(band.center(), vec2(r.width() * k, band.height().max(0.0))); + p.rect_filled(content, CornerRadius::ZERO, Color32::from_gray(232)); + } for (k, text) in hf.text.iter().enumerate() { if text.trim().is_empty() { continue; diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 1f1515ad6..4b71d3bf7 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -58,6 +58,21 @@ fn header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset() { assert!(marks(&h).is_empty()); } +#[test] +fn header_and_footer_dialog_can_shrink_the_page_content() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.bates")); + h.run_steps(3); + assert!(!h.state().marks_draft.hf.shrink, "off by default, as in Acrobat"); + h.get_by_label("Shrink the page content to make room for the header and footer").click(); + h.run_steps(2); + assert!(h.state().marks_draft.hf.shrink); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, None); + assert_eq!(marks(&h), [MarkKind::HeaderFooter]); +} + #[test] fn watermark_and_background_dialogs() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8..9da2b98d2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2433,11 +2433,11 @@ title = "Shrink document to avoid overwriting content" area = "C" tier = "P2" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.header_footer"] +tools = ["doc_header_footer"] +evidence = ["crates/edit/src/tests.rs::shrink_scales_the_original_content_into_the_band_between_header_and_footer", "crates/edit/src/tests.rs::shrink_follows_the_display_space_of_a_rotated_page", "crates/edit/src/tests.rs::shrink_is_undone_by_remove_and_not_doubled_by_update", "crates/automation/tests/automation.rs::a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools", "crates/ui-egui/tests/ui_group/marks.rs::header_and_footer_dialog_can_shrink_the_page_content"] +notes = "#2140: the page's original content is scaled evenly into the band the header and footer leave free (margin, tallest box, half the font size of clearance), centred across, in display space; Remove and Update undo it. Not moved yet: annotations (links, comments, form fields) and PdfCraft's own added text and images; not compared side by side with Acrobat's exact scale" [[feature]] id = "edit.watermark-add-text"