mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M14: Arabic and Hebrew file names render in the tab strip, recent files and Properties (#285)
A tab named in Arabic showed replacement boxes: no interface font had the glyphs, and egui joins the letters of a right-to-left word but places the runs in logical order. - Fonts: the interface takes `Arab` faces from the craft-fonts build input (also on wasm), and on desktop adds one font already installed on the machine as the last fallback of every family. It is read at runtime, never embedded; PDFCRAFT_SYSTEM_FONTS=0 turns it off and `cargo xtask screenshots` sets that. AGENTS.md §1.4 records the rule. - Order: bidi::visual puts single-line text in display order with a left-to-right base, used for the painted tab label and hover text, the recent-files name and path, and the Properties File and Location rows. Accessibility names keep the logical text. misc.rtl-ui stays planned: multi-line text, text input and a mirrored layout are not done. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
879d5174c2
commit
d30bb111db
21 files changed
+415
-14
No files matched your search
@@ -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=<checkout> 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
|
||||
|
||||
|
||||
Generated
+1
@@ -3797,6 +3797,7 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<String, String> {
|
||||
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}"))?;
|
||||
|
||||
@@ -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<Item = &'a CraftFont>) -> 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<String>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<usize>, index: usize) -> egui::Response {
|
||||
let font = theme::regular(13.0);
|
||||
let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::<String>()) } 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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Arc<FontData>> {
|
||||
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
CACHE.get_or_init(load).clone()
|
||||
}
|
||||
|
||||
fn load() -> Option<Arc<FontData>> {
|
||||
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<PathBuf> {
|
||||
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<Arc<FontData>> {
|
||||
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<u32> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -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<String> = fonts.families[&FontFamily::Proportional].clone();
|
||||
for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
|
||||
let mut stack = vec![primary.to_owned()];
|
||||
|
||||
@@ -6,6 +6,7 @@ use pdfcraft_ui_egui::theme;
|
||||
|
||||
const JAPANESE: &str = "日本語の文字";
|
||||
const CHINESE: &str = "简体中文欢迎";
|
||||
const ARABIC: &str = "واحد اثنين";
|
||||
|
||||
fn families() -> Vec<FontId> {
|
||||
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<String> = 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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
Reference in new issue
Block a user