M10: Create ▸ Blank page uses the system region's paper (#5644) (#5651)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Livio Oswald
2026-11-01 15:04:52 -05:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a74e3d4715
commit 2242eb322f
8 changed files with 192 additions and 7 deletions
+1
View File
@@ -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,
+3
View File
@@ -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<String>,
+1
View File
@@ -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"))]
+150
View File
@@ -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<Paper> = 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<Paper> {
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<Paper> {
// 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<String>) -> Option<String> {
["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<Paper> {
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<String> {
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<Paper> {
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::<u32>().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));
}
}
+11 -3
View File
@@ -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<Unit> {
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<String> {
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<Unit> {
// The system's own measurement setting first: a terminal's LANG is often en_US whatever the region.
+3 -2
View File
@@ -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())]);
}
+21
View File
@@ -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}"
);
}
}
+2 -2
View File
@@ -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"