From 2242eb322f974f583eff972fdc67f6b92f0378cf Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:52 +0100 Subject: [PATCH] =?UTF-8?q?M10:=20Create=20=E2=96=B8=20Blank=20page=20uses?= =?UTF-8?q?=20the=20system=20region's=20paper=20(#5644)=20(#5651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M10: Create ▸ Blank page uses the system region's paper (#5644) A new blank document was always US Letter. It now takes the paper of the system region: Windows' own Region paper size (iPaperSize) first, then the system locale on macOS or LC_ALL/LC_PAPER/LANG, A4 everywhere outside CLDR's US-Letter regions, and Letter when the region can't be told (web). The region parsing is shared with the Units preference. * M10: the first locale variable set decides the paper; extensions are not regions (#5644) LC_ALL, LC_PAPER and LANG are read in POSIX order and the first one set decides, so LC_ALL=C no longer falls through to LANG's region; and a locale tag's region is read from its base tag only, so en-US-u-nu-latn is US, not NU (CodeRabbit on #5651). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR * M10: the blank page test covers A4 as well as Letter (#5644) The app keeps the paper Create ▸ Blank page makes (blank_paper, the system region's at start), so the test makes a blank page on A4 and on Letter whatever the host's region (CodeRabbit on #5651). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --------- Co-authored-by: Claude --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/persist.rs | 1 + crates/ui-egui/src/persist/paper.rs | 150 ++++++++++++++++++++++ crates/ui-egui/src/persist/units.rs | 14 +- crates/ui-egui/src/workflows/create_ui.rs | 5 +- crates/ui-egui/tests/it/create.rs | 21 +++ parity/acrobat-features.toml | 4 +- 8 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/persist/paper.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 68e30b24ab..4de70f3d9e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -73,6 +73,7 @@ impl PdfCraftApp { default_mode: Mode::AllTools, view_defaults: Default::default(), units: Default::default(), + blank_paper: crate::paper::Paper::system(), date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 44059ee4aa..ab5d1334e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,7 @@ use panels::search_ui; pub use panels::side_panels; pub use persist::last_session; pub use persist::last_view; +pub use persist::paper; use persist::recovery; pub use persist::units; #[cfg(not(target_arch = "wasm32"))] @@ -264,6 +265,8 @@ pub struct PdfCraftApp { pub view_defaults: canvas::ViewDefaults, /// The unit page sizes and margins are shown in (Preferences ▸ Documents and view). pub units: units::Unit, + /// The paper Create ▸ Blank page makes (#5644): the system region's. + pub blank_paper: paper::Paper, /// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid /// pattern (the session keeps the last valid one). pub date_format_draft: Option, diff --git a/crates/ui-egui/src/persist.rs b/crates/ui-egui/src/persist.rs index bb29efeff2..32a8ec048d 100644 --- a/crates/ui-egui/src/persist.rs +++ b/crates/ui-egui/src/persist.rs @@ -1,5 +1,6 @@ pub mod last_session; pub mod last_view; +pub mod paper; pub(super) mod recovery; pub mod units; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/persist/paper.rs b/crates/ui-egui/src/persist/paper.rs new file mode 100644 index 0000000000..2aeb8fceb3 --- /dev/null +++ b/crates/ui-egui/src/persist/paper.rs @@ -0,0 +1,150 @@ +//! The paper size the system's region prints on: the size of a new blank document (Create ▸ +//! Blank page). + +use super::units::region_of; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Paper { + #[default] + Letter, + A4, +} + +impl Paper { + /// Width and height in points, portrait. + pub fn points(self) -> (f64, f64) { + match self { + Paper::Letter => (612.0, 792.0), + Paper::A4 => (595.28, 841.89), + } + } + + /// The system region's paper: US Letter in the Americas that use it, A4 everywhere else; + /// Letter when the region can't be told, as before the region was read. + pub fn system() -> Paper { + static SYSTEM: std::sync::OnceLock = std::sync::OnceLock::new(); + *SYSTEM.get_or_init(|| detect().unwrap_or_default()) + } +} + +/// The paper for a locale tag's region (`en_US.UTF-8`, `fr-BE`, `es_MX`); `None` without a region. +/// The Letter regions are CLDR's `paperSize` US-Letter territories. +pub(crate) fn paper_for_tag(tag: &str) -> Option { + Some(match region_of(tag)?.as_str() { + "US" | "CA" | "MX" | "PH" | "PR" | "BZ" | "CL" | "CO" | "CR" | "GT" | "NI" | "PA" | "SV" | "VE" => Paper::Letter, + _ => Paper::A4, + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn detect() -> Option { + // A Finder-started app gets no LANG, so the system locale's region decides first. + #[cfg(target_os = "macos")] + if let Ok(out) = std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleLocale"]).output() + && out.status.success() + && let Some(paper) = paper_for_tag(String::from_utf8_lossy(&out.stdout).trim()) + { + return Some(paper); + } + // Region ▸ the paper size Windows keeps per user, read in-process like the measurement system. + #[cfg(target_os = "windows")] + if let Some(paper) = windows_paper().and_then(|v| paper_for_ipapersize(&v)) { + return Some(paper); + } + paper_for_tag(&locale_for_paper(|var| std::env::var(var).ok())?) +} + +/// The locale that decides the paper, as POSIX picks it: the first of `LC_ALL`, `LC_PAPER` and +/// `LANG` that is set and not empty, read through `var`. A later one never stands in for it, so +/// `LC_ALL=C` with `LANG=de_DE` has no paper from the locale (CodeRabbit on #5651). +#[cfg(any(test, not(target_arch = "wasm32")))] +pub(crate) fn locale_for_paper(var: impl Fn(&str) -> Option) -> Option { + ["LC_ALL", "LC_PAPER", "LANG"].iter().find_map(|name| var(name).filter(|v| !v.is_empty())) +} + +/// The paper for the Windows `iPaperSize` value (a `DMPAPER` number): "1" Letter, "9" A4. `None` +/// for anything else, so another size falls through to the locale check. +#[cfg(any(test, target_os = "windows"))] +pub(crate) fn paper_for_ipapersize(value: &str) -> Option { + match value.trim() { + "1" => Some(Paper::Letter), + "9" => Some(Paper::A4), + _ => None, + } +} + +/// `HKCU\Control Panel\International\iPaperSize` as text; `None` when it is missing, unreadable +/// or not a string. +#[cfg(target_os = "windows")] +fn windows_paper() -> Option { + use winsafe::{HKEY, RegistryValue, co::RRF}; + match HKEY::CURRENT_USER.RegGetValue(Some("Control Panel\\International"), Some("iPaperSize"), RRF::RT_REG_SZ).ok()? { + RegistryValue::Sz(v) => Some(v), + _ => None, + } +} + +#[cfg(target_arch = "wasm32")] +fn detect() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::{Paper, locale_for_paper, paper_for_ipapersize, paper_for_tag}; + + #[test] + fn region_decides_the_paper() { + assert_eq!(paper_for_tag("en_US.UTF-8"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("fr_CA"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("es-MX"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("fr_BE.UTF-8"), Some(Paper::A4)); + assert_eq!(paper_for_tag("en_GB@euro"), Some(Paper::A4)); + assert_eq!(paper_for_tag("de-DE"), Some(Paper::A4)); + assert_eq!(paper_for_tag("zh-Hans-CN"), Some(Paper::A4)); + assert_eq!(paper_for_tag("C"), None); + assert_eq!(paper_for_tag("en"), None); + assert_eq!(paper_for_tag(""), None); + // A BCP 47 extension is not the region (CodeRabbit on #5651): `nu` is a key, not Niue. + assert_eq!(paper_for_tag("en-US-u-nu-latn"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("de-DE-u-rg-uszzzz"), Some(Paper::A4)); + assert_eq!(paper_for_tag("en-x-us"), None); + } + + /// POSIX precedence (CodeRabbit on #5651): the first locale variable that is set decides, even + /// when it has no region; an empty one is not set. + #[test] + fn the_first_locale_variable_set_decides() { + let env = + |pairs: &'static [(&'static str, &'static str)]| move |name: &str| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()); + assert_eq!(locale_for_paper(env(&[("LC_ALL", "C"), ("LANG", "de_DE.UTF-8")])).as_deref(), Some("C")); + assert_eq!(locale_for_paper(env(&[("LC_ALL", ""), ("LC_PAPER", "en_US"), ("LANG", "de_DE")])).as_deref(), Some("en_US")); + assert_eq!(locale_for_paper(env(&[("LANG", "fr_FR")])).as_deref(), Some("fr_FR")); + assert_eq!(locale_for_paper(env(&[])), None); + assert_eq!(locale_for_paper(env(&[("LC_ALL", "C.UTF-8"), ("LANG", "de_DE")])).and_then(|t| paper_for_tag(&t)), None); + } + + #[test] + fn windows_paper_size_decides_the_paper() { + assert_eq!(paper_for_ipapersize("1"), Some(Paper::Letter)); + assert_eq!(paper_for_ipapersize(" 9\r\n"), Some(Paper::A4)); + assert_eq!(paper_for_ipapersize("5"), None); + assert_eq!(paper_for_ipapersize(""), None); + } + + /// The registry read works in-process and gives a value the mapping understands, or nothing. + #[cfg(target_os = "windows")] + #[test] + fn windows_reads_ipapersize_in_process() { + if let Some(v) = super::windows_paper() { + assert!(v.trim().parse::().is_ok(), "unexpected iPaperSize {v:?}"); + } + } + + #[test] + fn a4_and_letter_in_points() { + assert_eq!(Paper::Letter.points(), (612.0, 792.0)); + let (w, h) = Paper::A4.points(); + assert_eq!(((w * 25.4 / 72.0).round(), (h * 25.4 / 72.0).round()), (210.0, 297.0)); + } +} diff --git a/crates/ui-egui/src/persist/units.rs b/crates/ui-egui/src/persist/units.rs index a345456fab..147c502513 100644 --- a/crates/ui-egui/src/persist/units.rs +++ b/crates/ui-egui/src/persist/units.rs @@ -69,14 +69,22 @@ impl Unit { /// The unit for a locale tag's region: `en_US.UTF-8`, `en-US`, `bg-BG`, `zh-Hans-CN`, `en_GB@euro`. /// `None` without a region (`C`, `POSIX`, `en`). pub(crate) fn unit_for_tag(tag: &str) -> Option { - let tag = tag.split(['.', '@']).next().unwrap_or_default(); - let region = tag.split(['-', '_']).skip(1).filter(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).last()?; - Some(match region.to_ascii_uppercase().as_str() { + Some(match region_of(tag)?.as_str() { "US" | "LR" | "MM" => Unit::Inches, _ => Unit::Millimetres, }) } +/// The upper-case two-letter region of a locale tag; `None` without one (`C`, `POSIX`, `en`). +/// Only the base tag counts: a BCP 47 extension or private use part (`-u-nu-latn`, `-x-…`, each +/// after a one-letter singleton) is not a region (CodeRabbit on #5651). +pub(crate) fn region_of(tag: &str) -> Option { + let tag = tag.split(['.', '@']).next().unwrap_or_default(); + let region = + tag.split(['-', '_']).skip(1).take_while(|p| p.len() != 1).filter(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).last()?; + Some(region.to_ascii_uppercase()) +} + #[cfg(not(target_arch = "wasm32"))] fn detect() -> Option { // The system's own measurement setting first: a terminal's LANG is often en_US whatever the region. diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index 38e400a771..5a6c9caf7b 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -158,9 +158,10 @@ impl PdfCraftApp { self.open_created_bytes("Clipboard.pdf", created.map_err(|e| e.to_string())) } - /// Create ▸ Blank page: a new untitled US Letter document. + /// Create ▸ Blank page: a new untitled document on the system region's paper (#5644). pub(crate) fn create_blank(&mut self) { - let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string()); + let (w, h) = self.blank_paper.points(); + let created = self.session.create_blank(w, h, 1).map_err(|e| e.to_string()); if let Err(e) = self.open_created_bytes("Untitled.pdf", created) { self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } diff --git a/crates/ui-egui/tests/it/create.rs b/crates/ui-egui/tests/it/create.rs index 1c9a757651..7260dd816b 100644 --- a/crates/ui-egui/tests/it/create.rs +++ b/crates/ui-egui/tests/it/create.rs @@ -380,3 +380,24 @@ fn multiple_files_past_a_thousand_are_all_used() { let doc = app.session.get(app.views[0].id).unwrap(); assert_eq!(doc.info.pages.len(), 1001, "every picked file is used, in order"); } + +/// Create ▸ Blank page uses the system region's paper, not always US Letter (#5644): the app +/// starts with the system's, and a blank page is on A4 and on Letter as the paper says +/// (CodeRabbit on #5651: a Letter host alone would not tell a hard-coded Letter apart). +#[test] +fn a_blank_page_is_on_the_system_regions_paper() { + use pdfcraft_ui_egui::paper::Paper; + assert_eq!(PdfCraftApp::new().blank_paper, Paper::system()); + for paper in [Paper::A4, Paper::Letter] { + let mut app = PdfCraftApp::new(); + app.blank_paper = paper; + assert!(app.execute("create.blank")); + let docs = app.session.docs(); + let crop = docs.last().and_then(|d| d.info.pages.first()).map(|p| p.crop).unwrap(); + let (w, h) = paper.points(); + assert!( + ((crop[2] - crop[0]) as f64 - w).abs() < 0.01 && ((crop[3] - crop[1]) as f64 - h).abs() < 0.01, + "{paper:?}: {crop:?} is not {w} × {h}" + ); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index af616e7758..301c941e5c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6988,8 +6988,8 @@ milestone = "M10" status = "shipped" commands = ["create.blank"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "" +evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/ui-egui/tests/it/create.rs::a_blank_page_is_on_the_system_regions_paper", "crates/ui-egui/src/persist/paper.rs::region_decides_the_paper", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] +notes = "The page is the system region's paper (#5644): US Letter in the US, Canada, Mexico, the Philippines and the other CLDR Letter regions, A4 elsewhere; Windows' own Region paper size (iPaperSize) first, then the system locale (macOS) or LC_ALL/LC_PAPER/LANG; Letter when the region is unknown (web). The doc_create tool keeps Letter unless given width and height. Missing: a page size or count chooser like Acrobat's New Document dialog." [[feature]] id = "create.from-html"