CJK text: big-font cells in SHX text, CJK interface fonts in every language (#861)

Drawing (#674): in stroke-font (SHX) text, East Asian wide characters
from a fallback font fill a square cell from the baseline to the text
height, as a big font's do, instead of being scaled by the fallback
font's Latin cap height (about 1.3-1.5x too large and too widely spaced,
so CJK labels overran their space). A line no longer ends with the
stroke font's letter gap after a fallback glyph.

Interface (#513): a CJK font is installed after the Latin one in every
interface language, not only Chinese, so Japanese or Chinese file,
layout and layer names stop showing as boxes. A Japanese font comes
first unless the interface is Chinese; a craft-fonts face embedded in
the build comes last, for systems (and the web build) without one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
aldozarandClaude Opus 5.5 authored and GitHub committed 2026-10-13 04:38:03 -04:00
1 parent 7e38081b54
commit a10e43cb2a
5 files changed
+163 -28

No files matched your search

Generated
+1
View File
@@ -705,6 +705,7 @@ dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-ui",
+79 -9
View File
@@ -89,6 +89,42 @@ pub fn char_advance(c: char) -> f64 {
}
}
/// Whether `c` is an East Asian wide character (CJK ideographs and punctuation, kana, Hangul,
/// fullwidth forms): drawn in a square cell, like a big font's characters.
pub fn is_wide(c: char) -> bool {
matches!(u32::from(c),
0x1100..=0x115F
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x20000..=0x3FFFD)
}
/// Where a CJK font's ideographic em box starts below the baseline, in em: the common value
/// (fonts put the box from -0.12 em to 0.88 em).
const IDEOGRAPHIC_DESCENT: f64 = 0.12;
/// How the stroke font draws a fallback glyph of `c` at height `h`: its origin's height above
/// the baseline and the scale of its font units (at 1000 per em). Wide characters fill a square
/// cell from the baseline to the text height, as a big font's do in AutoCAD-style SHX text,
/// so CJK text keeps the size and spacing its drawing was laid out with; other characters are
/// sized like TrueType text (cap height = text height).
fn stroke_fallback_metrics(g: &ttf::GlyphOutline, c: char, h: f64) -> (f64, f64) {
if is_wide(c) { (IDEOGRAPHIC_DESCENT * h, h / 1000.0) } else { (0.0, h / g.cap) }
}
/// Whether the stroke font draws `c` from a fallback font.
fn draws_fallback(c: char) -> bool {
stroke::glyph(c).is_none() && !c.is_whitespace() && ttf::fallback_glyph(c).is_some()
}
/// Advance of `c` in the stroke font at height `h` and width factor `wf`; characters the
/// stroke font lacks take a fallback font's advance when one has them.
fn stroke_advance(c: char, h: f64, wf: f64) -> f64 {
@@ -99,7 +135,7 @@ fn stroke_advance(c: char, h: f64, wf: f64) -> f64 {
&& !c.is_whitespace()
&& let Some(g) = ttf::fallback_glyph(c)
{
return g.advance * h / g.cap * wf;
return g.advance * stroke_fallback_metrics(&g, c, h).1 * wf;
}
char_advance(c) * h * wf
}
@@ -116,6 +152,7 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh
let mut x = 0.0;
let mut under_start: Option<f64> = None;
let mut over_start: Option<f64> = None;
let mut gap = stroke::GAP * sc * wf;
if bidi::has_rtl(s)
&& let Some(sh) = complex::shape(None, s, h, wf, oblique)
{
@@ -126,15 +163,18 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh
continue;
}
let mut adv = char_advance(c) * h * wf;
gap = stroke::GAP * sc * wf;
match stroke::glyph(c) {
Some((_, spec)) => draw_stroke_glyph(&mut out, spec, x, sc, wf, shear),
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
Some(g) => {
let contours = ttf::place_glyph(&g, x, h, wf, shear);
let (y, scale) = stroke_fallback_metrics(&g, c, h);
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph(&g, c, x, h, wf, shear));
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
adv = g.advance * h / g.cap * wf;
adv = g.advance * scale * wf;
gap = 0.0;
}
None => missing_glyph_box(&mut out, x, h, sc * wf),
},
@@ -159,7 +199,9 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh
}
x += adv;
}
let end = (x - stroke::GAP * sc * wf).max(0.0);
// The stroke font's gap after the last character isn't part of the line; a fallback glyph
// has none.
let end = (x - gap).max(0.0);
if let Some(sx) = under_start {
out.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(end, -h * 0.2)]);
}
@@ -209,8 +251,13 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
return sh.width;
}
}
let n: f64 = decode_controls(s).iter().map(|(c, _, _)| stroke_advance(*c, height, width_factor)).sum();
(n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0)
let chars = decode_controls(s);
let n: f64 = chars.iter().map(|(c, _, _)| stroke_advance(*c, height, width_factor)).sum();
// The stroke font's gap after the last character isn't part of the line; a fallback glyph
// has none.
let last = chars.iter().rev().map(|(c, _, _)| *c).find(|c| !bidi::is_format_control(*c));
let gap = if last.is_some_and(draws_fallback) { 0.0 } else { stroke::GAP / stroke::CAP * height * width_factor };
(n - gap).max(0.0)
}
/// Width of the gap MTEXT leaves between two words. Words are measured without their trailing
@@ -679,8 +726,31 @@ mod ttf_tests {
let sh = crate::shape_stroke("AB\u{4E2D}\u{6587}", 1.0, 1.0, 0.0);
assert_eq!(sh.glyphs.len(), 2, "two fallback glyphs");
assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes");
let maxy = sh.glyphs.iter().flatten().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
assert!(maxy > 0.8 && maxy < 1.8, "sized like TrueType text (cap height = text height), got {maxy}");
assert!((crate::line_width("AB\u{4E2D}\u{6587}", 1.0, 1.0) - sh.width).abs() < 1e-9);
}
/// #674: CJK characters in stroke-font (SHX) text fill a square cell from the baseline to the
/// text height, as a big font's do, instead of being sized by the fallback font's Latin cap
/// height (which made them about 1.4 times too large and too widely spaced).
#[test]
fn cjk_in_stroke_text_fills_a_square_cell_like_a_big_font() {
if crate::ttf::fallback_for('\u{4E2D}').is_none() {
return;
}
let (h, wf) = (2.5, 0.8);
let one = crate::shape_stroke("\u{4E2D}", h, wf, 0.0);
let four = crate::shape_stroke("\u{4E2D}\u{6587}\u{5B57}\u{4F53}", h, wf, 0.0);
let pts: Vec<_> = four.glyphs.iter().flatten().flatten().copied().collect();
let (miny, maxy) = pts.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert!(miny > -0.1 * h && maxy < 1.05 * h && maxy > 0.75 * h, "glyphs span {miny}..{maxy} for height {h}");
let cell = h * wf;
assert!(
(one.width - cell).abs() < 0.05 * cell && (four.width - 4.0 * cell).abs() < 0.2 * cell,
"widths {} and {}, cell {cell}",
one.width,
four.width
);
assert!((crate::line_width("\u{4E2D}\u{6587}\u{5B57}\u{4F53}", h, wf) - four.width).abs() < 1e-9);
assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A'));
}
}
+16 -6
View File
@@ -743,16 +743,21 @@ fn glyph_or_fallback(face: &Face, c: char) -> Arc<GlyphOutline> {
/// The [`crate::TextGlyph`] of a glyph placed like [`place_glyph`] does (its outline index is
/// set when it is added). `None` for the missing-glyph shape.
pub(crate) fn text_glyph(g: &GlyphOutline, c: char, x: f64, h: f64, wf: f64, shear: f64) -> Option<crate::TextGlyph> {
// Font units at 1000 per em scale by h / cap.
text_glyph_at(g, c, Vec2::new(x, 0.0), h / g.cap, wf, shear)
}
/// [`text_glyph`] with the glyph's origin at `origin` and font units scaled by `scale`.
pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64, wf: f64, shear: f64) -> Option<crate::TextGlyph> {
let gid = u16::try_from(g.gid).ok().filter(|_| g.mapped)?;
let face = g.face.clone()?;
// Font units at 1000 per em scale by h / cap.
let em = 1000.0 * h / g.cap;
let em = 1000.0 * scale;
(em.is_finite() && em > 0.0).then(|| crate::TextGlyph {
face,
gid,
ch: c,
outline: 0,
origin: Vec2::new(x, 0.0),
origin,
x: Vec2::new(em * wf, 0.0),
y: Vec2::new(em * shear, em),
})
@@ -760,14 +765,19 @@ pub(crate) fn text_glyph(g: &GlyphOutline, c: char, x: f64, h: f64, wf: f64, she
/// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`.
pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec<Vec<Vec2>> {
let scale = h / g.cap;
place_glyph_at(g, Vec2::new(x, 0.0), h / g.cap, wf, shear)
}
/// Contours of a glyph with its origin at `origin` and font units scaled by `scale`; the shear
/// slants about the baseline (y = 0).
pub(crate) fn place_glyph_at(g: &GlyphOutline, origin: Vec2, scale: f64, wf: f64, shear: f64) -> Vec<Vec<Vec2>> {
g.contours
.iter()
.map(|ct| {
ct.iter()
.map(|p| {
let y = p.y * scale;
Vec2::new(x + p.x * scale * wf + y * shear, y)
let y = origin.y + p.y * scale;
Vec2::new(origin.x + p.x * scale * wf + y * shear, y)
})
.collect()
})
+1
View File
@@ -16,6 +16,7 @@ cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-engine = { workspace = true }
cadcraft-fonts = { workspace = true }
egui = { workspace = true }
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
egui-wgpu = { workspace = true }
+66 -13
View File
@@ -301,8 +301,17 @@ pub fn mono() -> FontId {
FontId::new(12.0, FontFamily::Monospace)
}
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's.
pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's;
/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface
/// language: file, layout and layer names can be Japanese or Chinese whatever the interface
/// shows (#513). With `chinese` (a Chinese interface) the Chinese font comes first, else a
/// Japanese one, so names take their own glyph forms. A font embedded from craft-fonts comes
/// last, for systems without any (and the web build, which has no system fonts).
pub fn install_fonts(ctx: &egui::Context, chinese: bool) {
let mut fonts = egui::FontDefinitions::default();
let mut added: Vec<(String, std::sync::Arc<egui::FontData>)> = Vec::new();
#[cfg(target_arch = "wasm32")]
let _ = chinese;
#[cfg(not(target_arch = "wasm32"))]
{
let candidates: &[&str] = if cfg!(target_os = "macos") {
@@ -317,7 +326,6 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
]
};
let mut fonts = egui::FontDefinitions::default();
for path in candidates {
if let Ok(bytes) = std::fs::read(path) {
fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
@@ -327,19 +335,36 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
break;
}
}
// Chinese: an installed CJK font after the Latin ones, so Latin text keeps its look.
if let Some(bytes) = cjk.then(cjk_font).flatten().and_then(|p| read_capped(p, MAX_UI_FONT_BYTES)) {
fonts.font_data.insert("system-cjk".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
for family in [FontFamily::Proportional, FontFamily::Monospace] {
if let Some(f) = fonts.families.get_mut(&family) {
f.push("system-cjk".into());
}
let mut paths: Vec<&std::path::Path> = Vec::new();
if !chinese {
paths.extend(japanese_font());
}
paths.extend(cjk_font());
paths.dedup();
added.extend(
paths
.into_iter()
.filter_map(|p| read_capped(p, MAX_UI_FONT_BYTES))
.enumerate()
.map(|(i, bytes)| (format!("system-cjk-{i}"), std::sync::Arc::new(egui::FontData::from_owned(bytes)))),
);
}
let mut embedded: Vec<_> = cadcraft_fonts::CRAFT_FONTS
.iter()
.filter(|f| f.style == "Regular" && f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant")))
.collect();
// A sans-serif face reads best in the interface.
embedded.sort_by_key(|f| !f.family.contains("Gothic") && !f.family.contains("Sans"));
added.extend(embedded.first().map(|f| (format!("craft-{}", f.stem()), std::sync::Arc::new(egui::FontData::from_static(f.bytes)))));
for (name, data) in added {
fonts.font_data.insert(name.clone(), data);
for family in [FontFamily::Proportional, FontFamily::Monospace] {
if let Some(f) = fonts.families.get_mut(&family) {
f.push(name.clone());
}
}
ctx.set_fonts(fonts);
}
#[cfg(target_arch = "wasm32")]
let _ = (ctx, cjk);
ctx.set_fonts(fonts);
}
/// Largest interface font file read (CJK collections are 15–60 MB).
@@ -388,6 +413,34 @@ pub fn cjk_font() -> Option<&'static std::path::Path> {
None
}
/// An installed Japanese font, for Japanese names in a non-Chinese interface. Checked once.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn japanese_font() -> Option<&'static std::path::Path> {
#[cfg(not(target_arch = "wasm32"))]
{
static FOUND: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
FOUND
.get_or_init(|| {
let candidates: &[&str] = if cfg!(target_os = "macos") {
&["/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"]
} else if cfg!(windows) {
&["C:\\Windows\\Fonts\\YuGothR.ttc", "C:\\Windows\\Fonts\\meiryo.ttc", "C:\\Windows\\Fonts\\msgothic.ttc"]
} else {
&[
"/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf",
"/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
"/usr/share/fonts/ipa-pgothic/ipagp.ttf",
"/usr/share/fonts/vl-gothic/VL-PGothic-Regular.ttf",
]
};
candidates.iter().map(std::path::PathBuf::from).find(|p| p.is_file())
})
.as_deref()
}
#[cfg(target_arch = "wasm32")]
None
}
/// Whether the interface can show `language`: Chinese needs an installed CJK font.
pub fn can_show(language: &str) -> bool {
!language.starts_with("zh") || cjk_font().is_some()