Chinese interface shows every character instead of boxes on Windows (#248)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged Fixed by #46 but only text boxes were covered. These walk the editor's path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test, select, resize with arrange.bounds, drag to float with Square wrap, nudge, undo. Covers #142 and #82. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Font list offers every weight a family installs, named as Word names them (#219) Fonts were listed and matched only by their typographic family name (name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic (Light, Medium, SemiBold, Black, Condensed...) never appeared in the font list, and a document asking for "Roboto Light" fell back to a substitute. Each face now also carries its legacy family name, the one Word lists and stores in documents: name ID 1 for static fonts ("Source Sans 3 Semibold"), or the family plus the style words other than Regular, Bold and Italic for a variable font's named instances ("Inter Light"). The font list includes these names, has_family and face() resolve them (bold and italic still choose or synthesise within the faces the name covers), and asking for a legacy name loads the whole installed family. The system font scan now also reads fvar, so a variable font's instances are listed before it is loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Absolutely positioned VML text boxes float instead of sitting inline (#149) A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its style was read as an inline object, so a tall text box in a header made the header as tall as the box and pushed the body down the page. The reader now maps the VML style to the same float model DrawingML anchors use: left/margin-left and top/margin-top offsets, mso-position-*-relative reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-* and w10:wrap (square, tight, through, topAndBottom; none or absent is in front of the text, or behind it at a negative z-index). Shapes without position:absolute stay inline. Saving writes the boxes as DrawingML anchors with the same placement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241) The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was unaffected because it uses the system font fallback. - Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd. - With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no Jpan or Hans font, so an outdated pin can't ship silently again. - When no embedded face covers the interface language, the UI font families get an installed CJK font (desktop only): a short per-platform list of files in the system font folders (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi), header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK collection), so egui never sees a file it can't parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add a System interface theme that follows the OS appearance (#115) The interface theme is now a three-way choice: Use system setting, Light or Dark, in File > Options > General and in a new Interface Theme menu in View > Dark Mode. System follows the light/dark appearance egui reports (winit on macOS, Windows and Linux; prefers-color-scheme on the web) at launch and live, and falls back to light when the OS reports none. egui's own theme is now pinned to ours, so an OS change no longer swaps in egui's unstyled style. Saved preferences migrate: the old "dark" switch becomes Dark or Light and is never written again. Light stays the default for new installs. New ui.theme command (light/dark/system); ui.dark still sets Light/Dark. The dark page (View > Switch Modes) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #248: read the installed CJK UI font only when CJK text is shown A build without craft-fonts read a large system CJK font on the first frame even for an English interface. Now it is loaded only for a CJK interface language, or once File > Options shows the language list (names in their own scripts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
f4a694ecf0
commit
18d3a39278
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
@ -136,7 +136,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
@ -200,7 +200,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
|
|
@ -296,7 +296,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
@ -340,7 +340,7 @@ jobs:
|
|||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
|
|
|
|||
|
|
@ -59,6 +59,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
|||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese UI and document fallback |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
|
||||
//! budget. It only reads the local checkout: no network.
|
||||
//! budget. With `CRAFT_FONTS_REQUIRED` (releases), a native build also fails when the checkout
|
||||
//! has no font for a CJK interface language (Japanese, Simplified Chinese), as an outdated pin
|
||||
//! once did (#241). It only reads the local checkout: no network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -12,7 +14,17 @@ fn main() {
|
|||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Ok((entries, covered)) => {
|
||||
src.push_str(&entries);
|
||||
// Each CJK interface language needs its own face: without the Chinese one, Chinese
|
||||
// menus fall back to the Japanese face and show boxes for simplified hanzi (#241).
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() && !wasm {
|
||||
for s in REQUIRED_SCRIPTS.iter().filter(|s| !covered.iter().any(|c| c == *s)) {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: no {s} font in fonts/manifest.txt (craft-fonts too old?)", dir.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
|
|
@ -26,13 +38,18 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
/// Scripts a release build (`CRAFT_FONTS_REQUIRED`, native) must embed a font for: the CJK
|
||||
/// interface languages' (Japanese, Simplified Chinese).
|
||||
const REQUIRED_SCRIPTS: &[&str] = &["Jpan", "Hans"];
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line, and the scripts the embedded fonts cover.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut covered = Vec::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
|
|
@ -43,6 +60,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
|||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
covered.extend(scripts.split(',').map(|s| s.trim().to_string()));
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
|
|
@ -51,5 +69,5 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
|||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, covered))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
|||
|
||||
/// A face's family and style names (the default instance's style for a variable font), and its
|
||||
/// legacy family name (name ID 1) when it has one.
|
||||
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option<String>)> {
|
||||
pub(crate) fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String, Option<String>)> {
|
||||
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
|
||||
let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
|
||||
Some((family, style, name(f, &[StringId::FAMILY_NAME])))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
mod fontdb;
|
||||
pub mod math;
|
||||
mod ui_fallback;
|
||||
pub mod word;
|
||||
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs};
|
||||
|
|
@ -17,6 +18,7 @@ use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
|||
pub use kurbo::BezPath;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{SystemUiFont, system_cjk_ui_font, ui_needs_system_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
//! An installed CJK font for the interface when no embedded craft-fonts face covers the interface
|
||||
//! language (#241): a build without craft-fonts, or one whose craft-fonts checkout lacks the
|
||||
//! Chinese face, would otherwise draw Chinese menus as boxes. Native only; wasm has no system
|
||||
//! fonts to read.
|
||||
//!
|
||||
//! It looks for a short list of well-known files in the platform's font folders
|
||||
//! ([`system_font_dirs`](crate::system_font_dirs)) rather than waiting for the full system font
|
||||
//! scan, since the interface fonts are installed on the first frame.
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::raw::FileRef;
|
||||
|
||||
use crate::CraftFont;
|
||||
|
||||
/// An installed font file chosen for the interface.
|
||||
pub struct SystemUiFont {
|
||||
/// The face's family name, e.g. `Microsoft YaHei`.
|
||||
pub family: String,
|
||||
/// The whole font file.
|
||||
pub bytes: Vec<u8>,
|
||||
/// The face's index in the file (0 unless it is a collection).
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
/// Do the embedded interface faces (`ui_cjk_fonts`) leave the interface without CJK glyphs it
|
||||
/// needs? A Chinese interface (`prefer_hans`) needs a Chinese (`Hans`) face: the Japanese one
|
||||
/// lacks the simplified-only hanzi (页, 选, 删 …). Any other interface still needs some CJK face,
|
||||
/// for the language names in the Options menu and CJK file names.
|
||||
pub fn ui_needs_system_cjk(prefer_hans: bool, embedded: &[&CraftFont]) -> bool {
|
||||
if prefer_hans { !embedded.iter().any(|f| f.scripts.contains(&"Hans")) } else { embedded.is_empty() }
|
||||
}
|
||||
|
||||
/// Files larger than this are skipped (the big system CJK collections are around 20–80 MB).
|
||||
const MAX_FILE: u64 = 128 << 20;
|
||||
/// Faces looked at in one collection.
|
||||
const MAX_FACES: u32 = 64;
|
||||
|
||||
/// A character only a font for the language has: a simplified-only hanzi, or kana.
|
||||
fn probe(hans: bool) -> char {
|
||||
if hans { '页' } else { 'の' }
|
||||
}
|
||||
|
||||
/// Font files to look for, relative to a font folder, best first. Chinese faces cover kana too,
|
||||
/// so the Japanese list ends with the Chinese one.
|
||||
fn candidates(hans: bool) -> Vec<&'static str> {
|
||||
let (zh, ja): (&[&str], &[&str]) = if cfg!(windows) {
|
||||
(&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"])
|
||||
} else if cfg!(target_os = "macos") {
|
||||
(&["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"], &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"])
|
||||
} else {
|
||||
(
|
||||
&[
|
||||
"noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"noto-cjk/NotoSansCJKsc-Regular.otf",
|
||||
"opentype/noto/NotoSansCJKsc-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansCN-Regular.otf",
|
||||
"wenquanyi/wqy-microhei/wqy-microhei.ttc",
|
||||
"truetype/wqy/wqy-microhei.ttc",
|
||||
"wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
|
||||
"truetype/wqy/wqy-zenhei.ttc",
|
||||
"truetype/droid/DroidSansFallbackFull.ttf",
|
||||
],
|
||||
&["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"],
|
||||
)
|
||||
};
|
||||
if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() }
|
||||
}
|
||||
|
||||
/// The installed CJK font for a Chinese (`hans`) or other interface, if one is found. Reads at
|
||||
/// most one font file per candidate; never panics on damaged files.
|
||||
pub fn system_cjk_ui_font(hans: bool) -> Option<SystemUiFont> {
|
||||
find_ui_font(&crate::system_font_dirs(), &candidates(hans), probe(hans), hans)
|
||||
}
|
||||
|
||||
/// The first of `names` (tried in order, each in every folder of `dirs`) that is a font file
|
||||
/// with a face mapping `probe`.
|
||||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], probe: char, hans: bool) -> Option<SystemUiFont> {
|
||||
names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))).find_map(|path| {
|
||||
let size = std::fs::metadata(&path).ok().filter(|m| m.is_file())?.len();
|
||||
if size > MAX_FILE {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let (index, family) = pick_face(&bytes, probe, hans)?;
|
||||
log::debug!("interface CJK fallback: {family} ({})", path.display());
|
||||
Some(SystemUiFont { family, bytes, index })
|
||||
})
|
||||
}
|
||||
|
||||
/// The face of a font file (or collection) to use: one that maps `probe`, preferring a Regular
|
||||
/// style and, for Chinese, a Simplified Chinese family (`… SC`, `… GB`, as in a pan-CJK
|
||||
/// collection). `None` if the header isn't a font's or no face qualifies.
|
||||
pub(crate) fn pick_face(data: &[u8], probe: char, hans: bool) -> Option<(u32, String)> {
|
||||
let magic = data.get(..4)?;
|
||||
if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) {
|
||||
return None;
|
||||
}
|
||||
let count = match FileRef::new(data).ok()? {
|
||||
FileRef::Font(_) => 1,
|
||||
FileRef::Collection(c) => c.len().min(MAX_FACES),
|
||||
};
|
||||
let mut best: Option<((bool, bool), u32, String)> = None;
|
||||
for i in 0..count {
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
if f.charmap().map(probe).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue };
|
||||
let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei");
|
||||
let score = (style.eq_ignore_ascii_case("Regular"), regional);
|
||||
if best.as_ref().is_none_or(|(s, ..)| score > *s) {
|
||||
best = Some((score, i, family));
|
||||
}
|
||||
}
|
||||
best.map(|(_, i, family)| (i, family))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn needs_system_cjk_only_when_no_embedded_face_covers_the_language() {
|
||||
let jp = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] };
|
||||
let sc = CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] };
|
||||
// #241: the release embedded only the Japanese faces, and the Chinese interface fell back to them.
|
||||
assert!(ui_needs_system_cjk(true, &[&jp]));
|
||||
assert!(!ui_needs_system_cjk(true, &[&sc, &jp]));
|
||||
assert!(!ui_needs_system_cjk(false, &[&jp]));
|
||||
assert!(ui_needs_system_cjk(false, &[]));
|
||||
assert!(ui_needs_system_cjk(true, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_first_valid_font_covering_the_probe() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-uifallback-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
let sans = crate::bundled()[0];
|
||||
// Missing, damaged and non-font files are skipped without panicking.
|
||||
std::fs::write(dir.join("Deng.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("msyh.ttc"), b"<html>not a font</html>").unwrap();
|
||||
std::fs::write(dir.join("sub/good.ttf"), sans).unwrap();
|
||||
let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"];
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, 'A', true).unwrap();
|
||||
assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len()));
|
||||
// No candidate maps a CJK character: nothing is chosen.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, '页', true).is_none());
|
||||
assert!(pick_face(b"OTT", 'A', false).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -389,6 +389,8 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
|
|||
/// File ▸ Options ▸ Interface language: follow the system (the default) or pick one (#8).
|
||||
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
// The language names are in their own scripts: fonts for them load from the next frame.
|
||||
app.want_system_cjk = true;
|
||||
let system = crate::i18n::system_lang();
|
||||
let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]);
|
||||
let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() };
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ impl Lang {
|
|||
self.0.prefer_hans
|
||||
}
|
||||
|
||||
/// Is the interface written in CJK script (its own name is), so it needs a CJK face?
|
||||
pub fn uses_cjk(self) -> bool {
|
||||
self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
|
||||
}
|
||||
|
||||
/// A language by its exact code (any case).
|
||||
pub fn from_code(code: &str) -> Option<Lang> {
|
||||
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
|
||||
|
|
|
|||
|
|
@ -272,3 +272,24 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks()
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_interface_fonts_load_and_cover_simplified_hanzi() {
|
||||
// #241: without an embedded Chinese face the interface adds an installed CJK font; egui must
|
||||
// accept it (it panics on font data it can't parse) and draw simplified-only hanzi with it.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(true, true));
|
||||
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
|
||||
let covered = ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "删除页选"));
|
||||
let embedded = !wordcraft_fonts::ui_needs_system_cjk(true, &wordcraft_fonts::ui_cjk_fonts(true));
|
||||
if embedded || wordcraft_fonts::system_cjk_ui_font(true).is_some() {
|
||||
assert!(covered, "a Chinese face is available but the interface lacks simplified hanzi");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_cjk_languages_ask_for_an_installed_cjk_font() {
|
||||
// #241 follow-up: an English interface must not read a large system CJK font on startup.
|
||||
let cjk: Vec<&str> = Lang::all().filter(|l| l.uses_cjk()).map(Lang::code).collect();
|
||||
assert_eq!(cjk, ["zh-hans", "zh-hant", "ja"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ pub struct WordApp {
|
|||
styled: bool,
|
||||
/// The CJK face order the installed UI fonts use (Chinese first, or Japanese first).
|
||||
fonts_hans: bool,
|
||||
/// Whether the installed UI fonts include an installed CJK fallback font (#241).
|
||||
fonts_system_cjk: bool,
|
||||
/// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in
|
||||
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
|
||||
pub(crate) want_system_cjk: bool,
|
||||
fonts_frames: u32,
|
||||
applied_dark: Option<bool>,
|
||||
pub frame_ms: f64,
|
||||
|
|
@ -210,6 +215,8 @@ impl WordApp {
|
|||
keytip_rects: Vec::new(),
|
||||
styled: false,
|
||||
fonts_hans: false,
|
||||
fonts_system_cjk: false,
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_dark: None,
|
||||
frame_ms: 0.0,
|
||||
|
|
@ -649,9 +656,13 @@ impl WordApp {
|
|||
let lang = i18n::Lang::from_pref(&self.ui.language);
|
||||
i18n::set_current(lang);
|
||||
// Chinese text wants the Chinese face before the Japanese one (one glyph style per line).
|
||||
if !self.styled || lang.prefers_hans() != self.fonts_hans {
|
||||
theme::install_fonts_for(ctx, lang.prefers_hans());
|
||||
// An installed CJK font is read only once CJK text is shown (#241): never for an English
|
||||
// interface that doesn't open the language list.
|
||||
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
|
||||
if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk {
|
||||
theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk);
|
||||
self.fonts_hans = lang.prefers_hans();
|
||||
self.fonts_system_cjk = system_cjk;
|
||||
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
|
||||
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
|
|
|
|||
|
|
@ -204,11 +204,19 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||
/// [`install_fonts`] with the CJK fallback order for the interface language: the Chinese face
|
||||
/// first for Chinese (#8), the Japanese one otherwise. The new fonts apply from the next frame.
|
||||
pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) {
|
||||
ctx.set_fonts(font_definitions(prefer_hans));
|
||||
install_fonts_with(ctx, prefer_hans, false);
|
||||
}
|
||||
|
||||
/// The interface fonts; see [`install_fonts_for`].
|
||||
pub fn font_definitions(prefer_hans: bool) -> FontDefinitions {
|
||||
/// [`install_fonts_for`], also adding an installed CJK font when `system_cjk` is set and no
|
||||
/// embedded face covers the need (#241). Reading that font is a large file read, so callers ask
|
||||
/// for it only when CJK text is on screen: a CJK interface language, or the language names in
|
||||
/// Options.
|
||||
pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) {
|
||||
ctx.set_fonts(font_definitions(prefer_hans, system_cjk));
|
||||
}
|
||||
|
||||
/// The interface fonts; see [`install_fonts_with`].
|
||||
pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes)));
|
||||
|
|
@ -227,20 +235,44 @@ pub fn font_definitions(prefer_hans: bool) -> FontDefinitions {
|
|||
}
|
||||
fonts.families.insert(FontFamily::Name("medium".into()), vec!["InterMedium".into(), "Inter".into(), "SourceSans".into()]);
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["InterSemiBold".into(), "Inter".into(), "SourceSans".into()]);
|
||||
for f in wordcraft_fonts::ui_cjk_fonts(prefer_hans) {
|
||||
// The same static bytes the document fonts use: one copy in the binary.
|
||||
let name = format!("{} {}", f.family, f.style);
|
||||
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes)));
|
||||
let add_cjk = |fonts: &mut FontDefinitions, name: String, data: FontData| {
|
||||
fonts.font_data.insert(name.clone(), Arc::new(data));
|
||||
for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
|
||||
if let Some(v) = fonts.families.get_mut(&fam) {
|
||||
v.push(name.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
let cjk = wordcraft_fonts::ui_cjk_fonts(prefer_hans);
|
||||
for f in &cjk {
|
||||
// The same static bytes the document fonts use: one copy in the binary.
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
// No embedded face covers the interface language (a build without craft-fonts, or without
|
||||
// its Chinese face, #241): an installed CJK font, so the menus don't show boxes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if system_cjk
|
||||
&& wordcraft_fonts::ui_needs_system_cjk(prefer_hans, &cjk)
|
||||
&& let Some(f) = system_cjk_font(prefer_hans)
|
||||
{
|
||||
let mut data = FontData::from_static(&f.bytes);
|
||||
data.index = f.index;
|
||||
add_cjk(&mut fonts, format!("system {}", f.family), data);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = system_cjk;
|
||||
// Symbols and emoji fall back to egui's defaults (kept in the families).
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The installed CJK interface font for a Chinese (`hans`) or other interface, read once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_cjk_font(hans: bool) -> Option<&'static wordcraft_fonts::SystemUiFont> {
|
||||
static ZH: std::sync::OnceLock<Option<wordcraft_fonts::SystemUiFont>> = std::sync::OnceLock::new();
|
||||
static OTHER: std::sync::OnceLock<Option<wordcraft_fonts::SystemUiFont>> = std::sync::OnceLock::new();
|
||||
(if hans { &ZH } else { &OTHER }).get_or_init(|| wordcraft_fonts::system_cjk_ui_font(hans)).as_ref()
|
||||
}
|
||||
|
||||
pub fn medium(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("medium".into()))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue