mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
7e38081b54
commit
a10e43cb2a
5 files changed
+163
-28
No files matched your search
Generated
+1
@@ -705,6 +705,7 @@ dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-ui",
|
||||
|
||||
+79
-9
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user