mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
* 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:
co-authored by
Claude Opus 5.5
parent
a74e3d4715
commit
2242eb322f
@@ -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,
|
||||
|
||||
@@ -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,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"))]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())]);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user