diff --git a/AGENTS.md b/AGENTS.md index fd55db70e..039cd6a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ Anything else is forbidden, including assets whose licence you cannot prove from - Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal). - craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`. +- The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository. ### 1.5 Procedure for adding or changing an asset diff --git a/Cargo.lock b/Cargo.lock index 58f19f2fc..9384aeee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3797,6 +3797,7 @@ dependencies = [ "skrifa 0.44.0", "sys-locale", "toml", + "unicode-bidi", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 053edf61e..c3ec53f5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (community, M3, #214):** In single-page view, when the whole page fits, the wheel turns pages: one per wheel notch, one per trackpad swipe including its momentum. The rail gets a Page display button between rotate and zoom with Acrobat's view choices (Fit to width scrolling, Fit one full page, Actual size, Zoom to page level, Fit visible, Read mode, Full screen) and the page layouts. Preferences gets Default page display and Default zoom, which every new tab uses. In continuous view, Fit page and Fit height fit the largest page, so mixed page sizes no longer jump while scrolling. Overall estimate unchanged (about 30–35%). - **2026-10-08 (M14, Windows Chinese Auto):** Auto now reads the Windows display language after locale environment overrides, selecting Simplified or Traditional Chinese through the existing locale mapping. Added Chinese region/script and Windows query regressions. Chinese font delivery remains incomplete. Localization remains partial; overall ≈ 30–35% (unchanged). diff --git a/crates/fonts/README.md b/crates/fonts/README.md index e8ad89313..ad1045472 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -21,12 +21,14 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: Chinese characters the Japanese faces lack show the replacement glyph). - `ui_cjk_fonts(prefer_hans)`: both in fallback order for the UI language (Chinese group first in Chinese mode, so one line never mixes faces with different vertical metrics). +- `ui_arabic_fonts`: the `Arab` faces for Arabic-script interface text (file names, document + titles), in manifest order. - `document_japanese_font` / `japanese_glyph`: the face (Shippori Mincho, then BIZ UDMincho) whose outlines become the Type 3 fallback font for Japanese text written into PDFs. Without it, `japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error. - `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`. -wasm32 builds embed only BIZ UDPGothic Regular, to keep the web build small: the web build +wasm32 builds embed only BIZ UDPGothic Regular and any `Arab` face, to keep the web build small: the web build currently has no Chinese face, so Chinese there still shows the replacement glyph. Font files are never committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 091da8c2d..5ea3f38e6 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -1,7 +1,8 @@ //! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`. //! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads -//! the local checkout (no network). On wasm32 it embeds only the UI face (BIZ UDPGothic Regular), -//! to keep the web build within hosting limits (Cloudflare Pages: 25 MiB per file). +//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular +//! and any `Arab` face), to keep the web build within hosting limits (Cloudflare Pages: 25 MiB +//! per file). use std::fmt::Write as _; use std::path::PathBuf; @@ -38,7 +39,9 @@ fn craft_fonts(dir: &std::path::Path) -> Result { let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") { + // Arabic interface faces are small, so the web build keeps them too. + let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); + if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") && !arabic { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 00c0d0d60..2b9e8b92d 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -43,6 +43,16 @@ pub fn ui_chinese_fonts() -> Vec<&'static CraftFont> { CRAFT_FONTS.iter().filter(|f| f.covers("Hans")).collect() } +/// The `Arab` craft-fonts faces for Arabic-script interface text (file names, document titles), +/// in manifest order. Empty when built without craft-fonts or when it has no Arabic face. +pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> { + arabic(CRAFT_FONTS.iter()) +} + +fn arabic<'a>(faces: impl IntoIterator) -> Vec<&'a CraftFont> { + faces.into_iter().filter(|f| f.covers("Arab")).collect() +} + /// Interface CJK faces in fallback order for the UI language: Simplified Chinese first when /// `prefer_hans`, otherwise Japanese first (the historical default). /// @@ -139,6 +149,23 @@ mod tests { assert_eq!(ja, ["BIZ UDPGothic", "Shippori Mincho", "FakeHans"]); } + #[test] + fn arabic_faces_are_picked_by_script_in_manifest_order() { + // Synthetic faces: the filter must not depend on the real build input. + static BYTES: &[u8] = b"fake"; + static ARAB_LATN: &[&str] = &["Arab", "Latn"]; + static JPAN: &[&str] = &["Jpan"]; + let naskh = CraftFont { family: "FakeNaskh", style: "Regular", scripts: ARAB_LATN, bytes: BYTES }; + let biz = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: JPAN, bytes: BYTES }; + let kufi = CraftFont { family: "FakeKufi", style: "Regular", scripts: ARAB_LATN, bytes: BYTES }; + let faces = [naskh, biz, kufi]; + let ar: Vec<&str> = arabic(&faces).iter().map(|f| f.family).collect(); + assert_eq!(ar, ["FakeNaskh", "FakeKufi"]); + // An Arabic face is never a CJK fallback, and the other way round. + assert!(order_cjk(&faces, false).iter().all(|f| f.family == "BIZ UDPGothic")); + assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count()); + } + #[test] fn craft_fonts_are_optional_and_consistent() { // Holds with or without CRAFT_FONTS_DIR. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c9be9a6d7..9c9c78218 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,7 +9,9 @@ mod craft; mod encodings; pub mod pdf; mod script; -pub use craft::{CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts}; +pub use craft::{ + CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, +}; pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, script_outline}; /// Approximate advance of `s` in Helvetica (or Arial) at `size` points. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ad3dc7bb2..ab00b6261 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -22,6 +22,8 @@ log = { workspace = true } rfd = { workspace = true } png = "0.18" base64 = "0.22" +# Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree. +unicode-bidi = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Control-channel tokens. @@ -30,6 +32,8 @@ getrandom = "0.4" arboard = { version = "3.6", default-features = false, features = ["image-data"] } # Waits for native file pickers off the UI thread (already in the tree through wgpu). pollster = "1.0" +# Checks that an installed fallback font parses and has the glyphs; the same skrifa egui uses. +skrifa = "0.44" [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs new file mode 100644 index 000000000..d86365c30 --- /dev/null +++ b/crates/ui-egui/src/bidi.rs @@ -0,0 +1,149 @@ +//! Visual order for single-line interface text that may hold right-to-left script (file names, +//! document titles). +//! +//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic +//! word join and run right to left, but it places the runs themselves left to right in logical +//! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display +//! order and leaves the letters of each word alone for the shaper. + +use std::borrow::Cow; + +use unicode_bidi::{BidiInfo, Level}; + +/// `text` rearranged for an egui call that lays out one line. Text without right-to-left +/// characters comes back borrowed and unchanged. +/// +/// The base direction is left to right, as the interface is: a file name keeps its extension +/// at the end, the way the window title shows it. Use it for painting only; accessibility +/// labels and anything stored keep the logical text. +pub fn visual(text: &str) -> Cow<'_, str> { + if !text.chars().any(is_rtl_script) { + return Cow::Borrowed(text); + } + let info = BidiInfo::new(text, Some(Level::ltr())); + let mut out = String::with_capacity(text.len()); + for para in &info.paragraphs { + let (levels, runs) = info.visual_runs(para, para.range.clone()); + for run in runs { + let rtl = levels.get(run.start).is_some_and(Level::is_rtl); + // Runs lie on character boundaries; a missing slice is skipped, never a panic. + let Some(piece) = text.get(run) else { continue }; + if rtl { + push_rtl_run(&mut out, piece); + } else { + out.extend(piece.chars().filter(|c| !is_bidi_control(*c))); + } + } + } + Cow::Owned(out) +} + +/// One right-to-left run, in display order. Stretches of right-to-left script stay in logical +/// order (egui gives each to the shaper as one face run, which reverses and joins it); what +/// lies between them (spaces, punctuation: drawn by the Latin face, left to right) is reversed +/// here, with brackets mirrored. +fn push_rtl_run(out: &mut String, run: &str) { + let mut pieces: Vec<(bool, String)> = Vec::new(); + for c in run.chars().filter(|c| !is_bidi_control(*c)) { + let script = is_rtl_script(c); + match pieces.last_mut() { + Some((kind, piece)) if *kind == script => piece.push(c), + _ => pieces.push((script, c.to_string())), + } + } + for (script, piece) in pieces.iter().rev() { + if *script { + out.push_str(piece); + } else { + out.extend(piece.chars().rev().map(mirrored)); + } + } +} + +/// Characters of the right-to-left scripts' blocks: letters, marks and the scripts' own +/// punctuation, all drawn by the script's face. +fn is_rtl_script(c: char) -> bool { + matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFC | 0x1_0800..=0x1_0FFF | 0x1_E800..=0x1_EFFF) +} + +/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') +} + +fn mirrored(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_without_rtl_is_borrowed_unchanged() { + for s in ["", "report.pdf", "日本語の文字.pdf", "a (b) [c]", "caf\u{00E9} \u{200E}x"] { + assert!(matches!(visual(s), Cow::Borrowed(v) if v == s), "{s:?}"); + } + } + + #[test] + fn arabic_words_swap_and_the_extension_stays_last() { + // The reported name: "one two.pdf" in Arabic. + assert_eq!(visual("واحد اثنين.pdf"), "اثنين واحد.pdf"); + assert_eq!(visual("ملف.pdf"), "ملف.pdf"); + assert_eq!(visual("تقرير نهائي جدا.pdf"), "جدا نهائي تقرير.pdf"); + } + + #[test] + fn latin_and_numbers_keep_their_own_order() { + assert_eq!(visual("report واحد اثنين final.pdf"), "report اثنين واحد final.pdf"); + // A number inside right-to-left text reads left to right, to the left of what precedes it. + assert_eq!(visual("ملف 2024.pdf"), "2024 ملف.pdf"); + assert_eq!(visual(r"C:\Users\me\واحد اثنين\a.pdf"), r"C:\Users\me\اثنين واحد\a.pdf"); + } + + #[test] + fn hebrew_and_brackets() { + assert_eq!(visual("שלום עולם.pdf"), "עולם שלום.pdf"); + // Brackets between right-to-left words flip with the direction. + assert_eq!(visual("واحد (اثنين) ثلاثة.pdf"), "ثلاثة (اثنين) واحد.pdf"); + } + + #[test] + fn direction_controls_are_not_drawn() { + assert_eq!(visual("\u{202B}واحد اثنين\u{202C}.pdf"), "اثنين واحد.pdf"); + assert_eq!(visual("\u{200F}واحد\u{061C}"), "واحد"); + } + + #[test] + fn odd_input_never_panics() { + let long = "ا ب ".repeat(20_000); + let odd = [ + "\u{064B}", + "\u{064B}\u{064C} \u{0301}", + "\u{202E}\u{202E}\u{202E}ا", + "\u{2067}ا\u{2066}b", + "ا\nب\r\nج\u{2029}د", + "\u{FEFF}ا\u{0000}ب", + "ا\u{10FFFF}\u{E000}ب", + "((((ا]]]]", + long.as_str(), + ]; + for s in odd { + let v = visual(s); + assert!(v.chars().count() <= s.chars().count(), "{:?}", s.chars().take(12).collect::()); + } + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a04586649..c45914443 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -80,6 +80,8 @@ fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, close: &mut Option, index: usize) -> egui::Response { let font = theme::regular(13.0); let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::()) } else { name.to_string() }; + // Painted text only: the accessibility name below keeps the logical order. + let label = crate::bidi::visual(&label).into_owned(); let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(text_w + 64.0, 30.0), Sense::click()); let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() }; @@ -114,7 +116,8 @@ fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, clo if x.clicked() { *close = Some(index); } - resp.on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", name)]) } else { name.to_string() }) + let shown = crate::bidi::visual(name); + resp.on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) } else { shown.into_owned() }) } pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 50aca72df..a35ccfa3b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -113,7 +113,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { PropsTab::Description => { - row(ui, "File", doc.name.clone()); + row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); match app.props_draft.as_mut() { Some((_, draft)) if doc.allows_modification() => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { @@ -322,7 +322,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { } PropsTab::Advanced => { row(ui, "PDF version", i.pdf_version.clone()); - row(ui, "Location", doc.path.clone().unwrap_or_default()); + row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned()); row(ui, "File size", format!("{} ({} bytes)", human_size(i.file_size), i.file_size)); let p = &i.pages[0]; row(ui, "Page size", format!("{:.2} × {:.2} in", p.width / 72.0, p.height / 72.0)); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b95675bf1..e87a531ea 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -116,8 +116,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 22.0, egui::Color32::from_rgb(0xE0, 0x3E, 0x3E), ); - ui.painter().text(rect.min + vec2(46.0, 15.0), Align2::LEFT_CENTER, &r.name, theme::medium(13.5), t.text); - ui.painter().text(rect.min + vec2(46.0, 32.0), Align2::LEFT_CENTER, &r.path, theme::regular(11.0), t.text_faint); + ui.painter().text(rect.min + vec2(46.0, 15.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.name), theme::medium(13.5), t.text); + ui.painter().text(rect.min + vec2(46.0, 32.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint); ui.painter().text( rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 606a9d5a3..d4fe419ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,7 @@ pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignStep}; mod autoscroll; +mod bidi; mod dialogs; mod edit_text_ui; mod editing; @@ -75,6 +76,8 @@ pub mod i18n; pub(crate) const MAX_AUTHOR_CHARS: usize = 200; mod protect; mod recovery; +#[cfg(not(target_arch = "wasm32"))] +mod system_fonts; pub mod theme; pub mod updates; mod wheel_pager; diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs new file mode 100644 index 000000000..a7a77d70e --- /dev/null +++ b/crates/ui-egui/src/system_fonts.rs @@ -0,0 +1,105 @@ +//! The last-resort interface font: one face already installed on this machine. +//! +//! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one +//! only draws characters none of them has, such as an Arabic file name in a build without +//! craft-fonts. It is read at runtime and never embedded or shipped (AGENTS.md §1.4), and +//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off (published screenshots do). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use egui::FontData; + +/// Larger files are not read: a font path is still untrusted input. +const MAX_BYTES: u64 = 32 << 20; +/// Faces tried in a collection (`.ttc`). +const MAX_FACES: u32 = 16; +/// Arabic letter alef: the face must have it to be worth loading. +const PROBE: char = '\u{0627}'; + +/// The installed fallback face, read once. `None` when it is turned off or no candidate fits. +pub fn fallback() -> Option> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(load).clone() +} + +fn load() -> Option> { + if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { + return None; + } + candidates().iter().find_map(|path| read(path)) +} + +/// Well-known locations of faces with broad script coverage, best first. +fn candidates() -> Vec { + if cfg!(windows) { + let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + ["segoeui.ttf", "tahoma.ttf", "arial.ttf"].iter().map(|f| dir.join("Fonts").join(f)).collect() + } else if cfg!(target_os = "macos") { + ["/System/Library/Fonts/SFArabic.ttf", "/System/Library/Fonts/GeezaPro.ttc", "/System/Library/Fonts/Supplemental/Arial.ttf"] + .iter() + .map(PathBuf::from) + .collect() + } else { + let files = [ + "truetype/noto/NotoSansArabic-Regular.ttf", + "noto/NotoSansArabic-Regular.ttf", + "google-noto/NotoSansArabic-Regular.ttf", + "truetype/dejavu/DejaVuSans.ttf", + "TTF/DejaVuSans.ttf", + "dejavu/DejaVuSans.ttf", + "dejavu-sans-fonts/DejaVuSans.ttf", + ]; + ["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| files.iter().map(move |f| Path::new(dir).join(f))).collect() + } +} + +fn read(path: &Path) -> Option> { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + return None; + } + let bytes = std::fs::read(path).ok()?; + let index = face_with(&bytes, PROBE)?; + let mut data = FontData::from_owned(bytes); + data.index = index; + log::info!("interface font fallback: {}", path.display()); + Some(Arc::new(data)) +} + +/// The first face of the file that parses and maps `c`. egui parses fonts with the same skrifa, +/// so a face accepted here is one it can load. +fn face_with(bytes: &[u8], c: char) -> Option { + use skrifa::MetadataProvider as _; + (0..MAX_FACES).find(|&index| skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().map(c).is_some())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn broken_and_missing_files_are_skipped() { + assert_eq!(face_with(b"", PROBE), None); + assert_eq!(face_with(b"not a font at all", PROBE), None); + assert_eq!(face_with(&[0u8; 4096], PROBE), None); + assert!(read(Path::new("definitely/not/here.ttf")).is_none()); + // A directory is not a font. + assert!(read(&std::env::temp_dir()).is_none()); + } + + #[test] + fn a_face_without_the_probe_is_rejected() { + // Inter is Latin, Greek and Cyrillic only. + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + assert_eq!(face_with(inter, PROBE), None); + assert_eq!(face_with(inter, 'A'), Some(0)); + } + + #[test] + fn candidates_are_absolute_font_files() { + let list = candidates(); + assert!(!list.is_empty()); + assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc"))); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index cb8734009..fabb0b375 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -135,12 +135,32 @@ pub fn install_fonts(ctx: &egui::Context) { /// first for Simplified Chinese, Japanese first otherwise). Call it when the language /// changes; the new faces take effect next frame. pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { - ctx.set_fonts(font_definitions_for(prefer_hans)); + ctx.set_fonts(installed_font_definitions(prefer_hans)); +} + +/// The name of the installed face [`installed_font_definitions`] may add after the embedded ones. +pub const SYSTEM_FALLBACK: &str = "system-fallback"; + +/// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face +/// already installed on this machine as the last fallback of every family. It only draws +/// characters no embedded face has (an Arabic file name in a build without craft-fonts); +/// `PDFCRAFT_SYSTEM_FONTS=0` leaves it out. +pub fn installed_font_definitions(prefer_hans: bool) -> FontDefinitions { + #[cfg_attr(target_arch = "wasm32", expect(unused_mut))] + let mut fonts = font_definitions_for(prefer_hans); + #[cfg(not(target_arch = "wasm32"))] + if let Some(data) = crate::system_fonts::fallback() { + fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data); + for stack in fonts.families.values_mut() { + stack.push(SYSTEM_FALLBACK.to_owned()); + } + } + fonts } /// The interface fonts: Inter (and JetBrains Mono for code) first, then egui's defaults, then -/// the CJK faces of the optional craft-fonts build input as the last fallback in every family. -/// Without craft-fonts there is no Japanese or Chinese face. +/// the CJK and Arabic faces of the optional craft-fonts build input as the last fallback in +/// every family. Without craft-fonts there is no Japanese, Chinese or Arabic face here. pub fn font_definitions() -> FontDefinitions { font_definitions_for(false) } @@ -171,6 +191,19 @@ pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions { fonts.families.entry(family).or_default().push(name.clone()); } } + // Arabic-script faces (file names, document titles) after the CJK ones; the ranges don't overlap. + for face in pdfcraft_fonts::ui_arabic_fonts() { + let name = face.name(); + if !fonts.font_data.contains_key(&name) { + add(&mut fonts, &name, face.bytes); + } + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let stack = fonts.families.entry(family).or_default(); + if !stack.contains(&name) { + stack.push(name.clone()); + } + } + } let fallback: Vec = fonts.families[&FontFamily::Proportional].clone(); for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] { let mut stack = vec![primary.to_owned()]; diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index beb738b61..70e615529 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -6,6 +6,7 @@ use pdfcraft_ui_egui::theme; const JAPANESE: &str = "日本語の文字"; const CHINESE: &str = "简体中文欢迎"; +const ARABIC: &str = "واحد اثنين"; fn families() -> Vec { vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)] @@ -72,6 +73,51 @@ fn chinese_ui_text_prefers_the_chinese_face() { } } +/// Built with a craft-fonts Arabic face, Arabic text has real glyphs in every family, from a +/// face placed after the app's own fonts. +#[test] +fn arabic_ui_text_uses_craft_fonts() { + let arabic: Vec = pdfcraft_fonts::ui_arabic_fonts().iter().map(|f| f.name()).collect(); + if arabic.is_empty() { + eprintln!("skipping arabic_ui_text_uses_craft_fonts: no Arab face bundled (set CRAFT_FONTS_DIR with an Arabic face to run it)"); + return; + } + let defs = theme::font_definitions(); + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let stack = &defs.families[&family]; + let first_ar = stack.iter().position(|n| arabic.contains(n)).expect("an Arab face is a fallback"); + let own = stack.iter().position(|n| n == "Inter" || n == "JetBrainsMono").expect("the app's own font"); + assert!(own < first_ar, "{family:?}: {stack:?}"); + } + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + assert!(fonts.has_glyphs(&id, ARABIC), "{id:?} lacks {ARABIC}"); + } +} + +/// On a machine with a suitable installed font, the installed definitions end every family +/// with it and Arabic text has glyphs; the embedded-only definitions never name it. +#[test] +fn system_fallback_fills_missing_scripts() { + assert!(!theme::font_definitions().font_data.contains_key(theme::SYSTEM_FALLBACK)); + let defs = theme::installed_font_definitions(false); + if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { + eprintln!("skipping system_fallback_fills_missing_scripts: no installed fallback font (or PDFCRAFT_SYSTEM_FONTS=0)"); + assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == theme::SYSTEM_FALLBACK))); + return; + } + for (family, stack) in &defs.families { + assert_eq!(stack.last().map(String::as_str), Some(theme::SYSTEM_FALLBACK), "{family:?}: {stack:?}"); + assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); + } + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + assert!(fonts.has_glyphs(&id, ARABIC), "{id:?} lacks {ARABIC}"); + assert!(fonts.has_glyphs(&id, "PdfCraft"), "{id:?}"); + } + assert!(layout_widths(&mut fonts, ARABIC).iter().all(|w| w.is_finite() && *w > 0.0)); +} + /// Without craft-fonts the interface fonts still install and lay out any text (Japanese falls /// back to egui's replacement glyph) without panicking; Latin text is unaffected. #[test] diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 23d8de888..e63c8b723 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -57,6 +57,21 @@ fn opening_a_pdf_shows_comments_and_bookmarks() { h.get_by_label("Beta section"); } +/// A right-to-left file name opens, lays out and paints; the tab's accessible name keeps the +/// logical text (only the painted label is put in visual order). +#[test] +fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() { + let name = "واحد اثنين.pdf"; + let mut h = harness(move |app| app.open_bytes(name, None, FIXTURE.to_vec()).expect("fixture opens")); + let tab = h.get_by_label(name).rect(); + assert!(tab.width() > 64.0 && tab.height() > 0.0, "{tab:?}"); + // A name longer than the tab's 28-character limit is cut on a character boundary. + let long = "واحد اثنين ثلاثة أربعة خمسة ستة سبعة ثمانية.pdf"; + h.state_mut().open_bytes(long, None, FIXTURE.to_vec()).expect("fixture opens"); + h.run_steps(3); + h.get_by_label(long); +} + #[test] fn garbage_input_is_rejected_without_panicking() { let mut app = PdfCraftApp::new(); diff --git a/docs/development.md b/docs/development.md index ec88ae136..adcfd9732 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,3 +31,4 @@ covers every target starting with it (`pdfcraft*=debug`). The logger is | `WGPU_POWER_PREF` | GPU choice; by default PdfCraft prefers the low-power (integrated) GPU | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | +| `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | diff --git a/docs/localization.md b/docs/localization.md index 1b446613e..0c35ce17b 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -16,6 +16,8 @@ Dialogs, panels, menus, notices and history labels go through the catalog (`tl!` Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Czech and Brazilian Portuguese need only the bundled Latin faces (tested in `tests/fonts.rs`). +There is no right-to-left interface language yet. File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. + Simplified Chinese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Generated history labels and diagnostics are also tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). ## How translations work diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2d478e797..0c410fc40 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9951,7 +9951,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "" +notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language." [[feature]] id = "misc.installers" diff --git a/xtask/src/screenshots.rs b/xtask/src/screenshots.rs index 28c175bdf..243d8b0ba 100644 --- a/xtask/src/screenshots.rs +++ b/xtask/src/screenshots.rs @@ -70,6 +70,8 @@ pub fn run(args: &[String]) -> Result<()> { .arg(PDF) .args(["--size", "1440x900", "--scale", "2", "--width", "1600"]) .args(*scene) + // Published images show only the embedded, openly licensed fonts (AGENTS.md §1.2). + .env("PDFCRAFT_SYSTEM_FONTS", "0") .current_dir(&root) .status() .with_context(|| format!("running shot for {name}"))?;