Merge pull request #1418 from Chrisyeh1983/fix/viewport-wide-polyline-plot-width

fix: wide-polyline plot width through viewports; CJK text (big fonts, fallback size, atlas growth, MTEXT wrap/indents); compressed PDF
This commit is contained in:
Hakan Seven committed 2026-09-21 22:49:31 +03:00
commit 697ea4984b
12 files changed
+658 -42

No files matched your search

+12 -1
View File
@@ -1566,5 +1566,16 @@ impl Scene {
self.camera_generation += 1;
}
pub fn update(&mut self, _dt: Duration) {}
pub fn update(&mut self, _dt: Duration) {
// The glyph atlas grew or re-baked since this scene last built its
// caches (a CJK sheet set bakes thousands of tiles on first sight):
// every quad laid out before that samples the wrong tile — scrambled
// text in the affected blocks / viewports. One structural bump
// re-lays-out all text against the settled atlas.
let atlas_gen = crate::scene::text::sdf_atlas::generation();
if self.last_atlas_generation.get() != atlas_gen {
self.last_atlas_generation.set(atlas_gen);
self.bump_geometry();
}
}
}
+35
View File
@@ -2357,6 +2357,11 @@ pub struct Scene {
/// (re)built, otherwise the held value. `build_primitive` reads it right
/// after the call to gate GPU wire re-upload. 0 = none yet.
pub(crate) last_model_wire_gen: std::cell::Cell<u64>,
/// SDF glyph-atlas generation this scene's caches were built against.
/// When the atlas grows or re-bakes, every cached glyph quad (resident
/// sets, block caches, projected viewport copies) addresses the wrong
/// tile; `update` compares against the live generation and rebuilds.
pub(crate) last_atlas_generation: std::cell::Cell<u64>,
/// Interaction-LOD state: `camera_generation` seen on the previous frame and
/// the wall time it last changed. Used to detect "the view is actively being
/// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be
@@ -2651,6 +2656,7 @@ impl Scene {
last_tess_ms: std::cell::Cell::new(0.0),
last_tess_wires: std::cell::Cell::new(0),
last_model_wire_gen: std::cell::Cell::new(0),
last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()),
nav_last_gen: std::cell::Cell::new(0),
nav_changed_at: std::cell::Cell::new(None),
nav_perf_pending: std::cell::Cell::new(None),
@@ -6120,6 +6126,7 @@ impl Scene {
// Build once: full tessellation, no cull, no zoom LOD — the resident
// set is zoom-independent (GPU analytical circles/arcs/ellipses).
let t_tess = iced::time::Instant::now();
let mut atlas_gen = crate::scene::text::sdf_atlas::generation();
let mut wires = self.wires_for_block_culled(
block,
None,
@@ -6130,6 +6137,34 @@ impl Scene {
all_visible,
style_viewport,
);
// Baking a drawing's glyphs for the first time can grow the SDF atlas
// part-way through this pass (a CJK sheet set bakes thousands of
// tiles). Text tessellated before the growth carries UVs that now
// address the wrong tiles — visible as scrambled glyphs — and this
// set is cached by geometry epoch, so nothing would ever re-lay it
// out. The memo guard already folds the generation in, so rebuilding
// once the atlas has settled re-tessellates only the stale text.
for _ in 0..3 {
let now = crate::scene::text::sdf_atlas::generation();
if now == atlas_gen {
break;
}
atlas_gen = now;
// Block definitions tessellated earlier in this pass hold the
// stale quads too, and their cache is keyed by block epoch (which
// has not moved), so drop them or the rebuild would reuse them.
self.block_defn_cache.borrow_mut().clear();
wires = self.wires_for_block_culled(
block,
None,
None,
frozen_layers,
anno_scale_override,
annotation_scale_handle,
all_visible,
style_viewport,
);
}
let perf = crate::perf::enabled();
let t_post = perf.then(iced::time::Instant::now);
// Synthesized nonprint markers (geo-location daisy) live in model space
+16
View File
@@ -321,6 +321,22 @@ impl Scene {
}
let mut out = wire.clone();
out.points = clipped;
// A wide polyline's band width is a geometric width in model
// units; the points were just projected into paper units, so
// the band (and any per-point taper) must follow the same
// scale or the PDF / print exporter strokes a 15-unit bus bar
// as 15 mm of paper. Negative values are fixed pixel widths
// and zero means "use the lineweight" — both stay as they are.
if out.world_width > 0.0 {
out.world_width = wire.world_width * scale;
if wire.taper_widths.len() == out.points.len() {
out.taper_widths = wire.taper_widths.iter().map(|w| w * scale).collect();
} else {
// Clipping changed the vertex count, so the per-point
// widths no longer line up; fall back to a constant band.
out.taper_widths.clear();
}
}
if let Some(source_length) = source_length {
for station in &mut clipped_stations {
*station *= scale;
+62 -2
View File
@@ -47,12 +47,37 @@ pub enum Face {
/// Space advance (9-unit).
word: f32,
},
/// A face paired with an Asian big font (`chineset.shx`, `hztxt.shx`, …):
/// double-byte characters come from the big font, everything else from
/// `primary` — exactly how a STYLE with a "Big Font" behaves in AutoCAD.
WithBig {
primary: Box<Face>,
/// Absolute path of the `AutoCAD-86 bigfont 1.0` file.
big: String,
},
}
/// Separator between a style's primary font name and its big-font path in
/// the single `font_name` string the text pipeline passes around (see
/// [`Face::with_big_font`]); a control character no font name contains.
pub const BIG_FONT_SEP: char = '\u{1}';
impl Face {
/// Resolve a style's font name to a concrete face. Embedded stroke fonts
/// take priority; only otherwise-unknown names try the system fonts.
pub fn resolve(font_name: &str) -> Face {
// `primary<SEP>big` pairs a big font with the primary face.
if let Some((primary, big)) = font_name.split_once(BIG_FONT_SEP) {
let face = Face::resolve(primary);
return if !big.is_empty() && shx::is_bigfont(big) {
Face::WithBig {
primary: Box::new(face),
big: big.to_string(),
}
} else {
face
};
}
// A resolvable on-disk .SHX font renders its real stroke glyphs; the
// LFF substitutes only cover names we can't load.
if font_name.to_ascii_lowercase().ends_with(".shx")
@@ -87,6 +112,13 @@ impl Face {
Face::Lff(lff::get_font(font_name))
}
/// The `font_name` string that pairs `primary` with the big font at
/// `big_path` for [`Face::resolve`]. Every text entry point takes a font
/// name, so the pairing rides along in that string.
pub fn with_big_font(primary: &str, big_path: &str) -> String {
format!("{primary}{BIG_FONT_SEP}{big_path}")
}
/// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it
/// lacks falls back to a system TrueType font chosen by cosmic-text (filled
/// outline, so covers scripts no stroke font provides). The TTF path mirrors
@@ -103,6 +135,14 @@ impl Face {
Face::Shx { path, .. } => shx::font_glyph(path, ch as u16)
.map(GlyphRef::Owned)
.or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)),
Face::WithBig { primary, big } => {
if ttf_glyph::is_full_width(ch) {
if let Some(g) = shx::bigfont_glyph(big, ch) {
return Some(GlyphRef::Owned(g));
}
}
primary.glyph(ch)
}
}
}
@@ -114,6 +154,7 @@ impl Face {
Face::Ttf { .. } => 0.0,
// SHX advances already include the trailing gap by design.
Face::Shx { .. } => 0.0,
Face::WithBig { primary, .. } => primary.letter_spacing(),
}
}
@@ -122,6 +163,7 @@ impl Face {
match self {
Face::Lff(f) => f.word_spacing,
Face::Ttf { word, .. } | Face::Shx { word, .. } => *word,
Face::WithBig { primary, .. } => primary.word_spacing(),
}
}
@@ -132,6 +174,7 @@ impl Face {
match self {
Face::Ttf { family, .. } => Some(family),
Face::Lff(_) | Face::Shx { .. } => None,
Face::WithBig { primary, .. } => primary.ttf_family(),
}
}
@@ -142,6 +185,7 @@ impl Face {
Face::Lff(f) => f.line_spacing,
Face::Ttf { .. } => 1.0,
Face::Shx { line, .. } => *line,
Face::WithBig { primary, .. } => primary.line_spacing(),
}
}
}
@@ -236,11 +280,27 @@ mod tests {
// they should resolve to Ttf. If they aren't installed, Lff fallback is accepted.
eprintln!("Font {} resolved to Lff (probably not installed)", test_name);
}
Face::Shx { .. } => {
// A system TTF name never resolves to an SHX shape font.
Face::Shx { .. } | Face::WithBig { .. } => {
// A system TTF name never resolves to an SHX shape font
// (nor gains a big font without the pairing separator).
eprintln!("Font {} resolved to Shx (unexpected)", test_name);
}
}
}
}
#[test]
fn big_font_pairing_routes_ideographs_and_leaves_latin_alone() {
// Without a real big-font file the pairing is dropped: the primary
// face is used as-is.
assert!(matches!(
Face::resolve(&Face::with_big_font("txt", "C:/nowhere/chineset.shx")),
Face::Lff(_)
));
// A stroke face still renders Latin from its own glyphs when paired.
let face = Face::resolve("txt");
assert!(face.glyph('A').is_some());
}
}
+249 -21
View File
@@ -50,7 +50,20 @@ const DIRS: [[f64; 2]; 16] = [
[1.0, -0.5],
];
/// Which `AutoCAD-86` container a file is; decides how glyphs are keyed and
/// how a `7` (subshape) opcode is encoded.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ShxKind {
/// `shapes 1.0/1.1`: keyed by 8-bit code (or shape number).
Shapes,
/// `unifont 1.0`: keyed by Unicode code point.
Unifont,
/// `bigfont 1.0`: keyed by a double-byte legacy code (Big5, GBK, …).
Bigfont,
}
struct ShxFile {
kind: ShxKind,
/// shape number → (name, spec bytes)
shapes: HashMap<u16, (String, Vec<u8>)>,
}
@@ -81,39 +94,158 @@ fn load_file(path: &str) -> Option<Arc<ShxFile>> {
fn parse_file(path: &str) -> Option<ShxFile> {
let bytes = std::fs::read(path).ok()?;
// Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts.
// Header line: `AutoCAD-86 <kind> 1.x` + CR LF SUB.
let head_end = bytes.iter().position(|&b| b == 0x1A)?;
let header = String::from_utf8_lossy(&bytes[..head_end]);
if !header.contains("shapes") {
let kind = if header.contains("shapes") {
ShxKind::Shapes
} else if header.contains("unifont") {
ShxKind::Unifont
} else if header.contains("bigfont") {
ShxKind::Bigfont
} else {
return None;
}
};
let mut p = head_end + 1;
let rd_u16 = |p: &mut usize| -> Option<u16> {
let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]);
*p += 2;
Some(v)
};
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
let mut shapes = HashMap::new();
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
// Name = leading NUL-terminated ASCII, spec follows.
let rd_u32 = |p: &mut usize| -> Option<u32> {
let v = u32::from_le_bytes([
*bytes.get(*p)?,
*bytes.get(*p + 1)?,
*bytes.get(*p + 2)?,
*bytes.get(*p + 3)?,
]);
*p += 4;
Some(v)
};
// Glyph data = leading NUL-terminated ASCII name, spec follows.
let split_named = |blob: &[u8]| -> (String, Vec<u8>) {
let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0);
let name = String::from_utf8_lossy(&blob[..name_end]).into_owned();
let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec();
shapes.insert(num, (name, spec));
(name, spec)
};
let mut shapes = HashMap::new();
match kind {
ShxKind::Shapes => {
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
shapes.insert(num, split_named(blob));
}
}
ShxKind::Unifont => {
// u32 glyph count (including the font header), then the header
// entry (u16 length + name NUL above below modes encoding
// embedded 0), then `count - 1` × (u16 code, u16 length, data).
let count = rd_u32(&mut p)? as usize;
let head_len = rd_u16(&mut p)? as usize;
let end = p.checked_add(head_len)?.min(bytes.len());
shapes.insert(0, split_named(&bytes[p..end]));
p = end;
for _ in 1..count {
let Some(code) = rd_u16(&mut p) else { break };
let Some(len) = rd_u16(&mut p) else { break };
let end = p.checked_add(len as usize)?.min(bytes.len());
shapes.insert(code, split_named(&bytes[p..end]));
p = end;
}
}
ShxKind::Bigfont => {
// u16 bytes per table entry (8), u16 table entries, u16 range
// count, ranges × (u16 lo, u16 hi) of lead bytes, then the entry
// table — a sparse hash table of (u16 code, u16 length, u32 file
// offset); empty slots are all zero. Shape 0 (name NUL above
// below modes) is the font header.
let entry_size = rd_u16(&mut p)? as usize;
let entries = rd_u16(&mut p)? as usize;
let ranges = rd_u16(&mut p)? as usize;
p = p.checked_add(ranges * 4)?;
if entry_size < 8 {
return None;
}
for i in 0..entries {
let mut q = p.checked_add(i * entry_size)?;
let Some(code) = rd_u16(&mut q) else { break };
let Some(len) = rd_u16(&mut q) else { break };
let Some(off) = rd_u32(&mut q) else { break };
if off == 0 && len == 0 {
continue;
}
let start = off as usize;
let end = start.saturating_add(len as usize).min(bytes.len());
if start >= end {
continue;
}
shapes.insert(code, split_named(&bytes[start..end]));
}
}
}
Some(ShxFile { shapes })
Some(ShxFile { kind, shapes })
}
/// True when the file is an Asian big font (`AutoCAD-86 bigfont 1.0`).
pub fn is_bigfont(path: &str) -> bool {
load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont)
}
/// The double-byte legacy encodings a big font may be keyed by, most likely
/// first for this file name. The font itself does not say; try them in turn
/// and take the first code the font actually has.
fn bigfont_encodings(path: &str) -> [&'static encoding_rs::Encoding; 4] {
use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS};
let name = path
.rsplit(['/', '\\'])
.next()
.unwrap_or(path)
.to_ascii_lowercase();
if name.contains("gb") || name.contains("hz") || name.contains("china") {
[GBK, BIG5, SHIFT_JIS, EUC_KR]
} else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") {
[SHIFT_JIS, BIG5, GBK, EUC_KR]
} else if name.contains("whg") || name.contains("kor") || name.contains("han") {
[EUC_KR, BIG5, GBK, SHIFT_JIS]
} else {
// `chineset.shx` and most Taiwanese fonts.
[BIG5, GBK, SHIFT_JIS, EUC_KR]
}
}
/// Look up `ch` in a big font: encode it to the font's double-byte code and
/// fetch that shape, normalised like [`font_glyph`]. `None` when the file is
/// not a big font or has no glyph for the character.
pub fn bigfont_glyph(path: &str, ch: char) -> Option<Arc<crate::scene::text::lff::Glyph>> {
let file = load_file(path)?;
if file.kind != ShxKind::Bigfont {
return None;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
for enc in bigfont_encodings(path) {
let (bytes, _, unmappable) = enc.encode(s);
if unmappable || bytes.len() != 2 {
continue;
}
let code = u16::from_be_bytes([bytes[0], bytes[1]]);
if file.shapes.contains_key(&code) {
return font_glyph(path, code);
}
}
None
}
/// Tessellate the shape named `name` (case-insensitive) from the SHX at
@@ -410,8 +542,16 @@ fn interpret(
}
}
7 => {
let sub = *b.get(i).unwrap_or(&0) as u16;
let mut sub = *b.get(i).unwrap_or(&0) as u16;
i += 1;
// A big font references subshapes by a two-byte code:
// `7, 0, hi, lo`.
if file.kind == ShxKind::Bigfont && sub == 0 {
let hi = *b.get(i).unwrap_or(&0) as u16;
let lo = *b.get(i + 1).unwrap_or(&0) as u16;
i += 2;
sub = (hi << 8) | lo;
}
if !skipping && sub != 0 && sub != shape_number {
interpret(file, sub, st, cur, out, depth + 1);
}
@@ -510,3 +650,91 @@ fn interpret(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Shape bytecode for a glyph: a horizontal 6-unit stroke, then end.
/// (`0x60` = length 6, direction 0.)
const BAR: [u8; 2] = [0x60, 0x00];
fn tmp(name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("ocs-shx-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// A one-glyph `bigfont 1.0` keyed by the Big5 code of `中` (0xA4A4),
/// laid out like `chineset.shx`: header, 8-byte entries in a sparse
/// table, font header shape 0 (`name NUL above below modes`).
fn synthetic_bigfont() -> Vec<u8> {
let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec();
let entries: u16 = 4;
b.extend_from_slice(&8u16.to_le_bytes());
b.extend_from_slice(&entries.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes()); // one lead-byte range
b.extend_from_slice(&0x80u16.to_le_bytes());
b.extend_from_slice(&0xFFu16.to_le_bytes());
let table_at = b.len();
b.resize(table_at + 8 * entries as usize, 0);
let head_data: Vec<u8> = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat();
let glyph_data: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
let head_off = b.len() as u32;
b.extend_from_slice(&head_data);
let glyph_off = b.len() as u32;
b.extend_from_slice(&glyph_data);
// Slot 0 = shape 0; slot 2 = the glyph (slots 1 and 3 stay empty).
let mut entry = |slot: usize, code: u16, len: u16, off: u32| {
let at = table_at + slot * 8;
b[at..at + 2].copy_from_slice(&code.to_le_bytes());
b[at + 2..at + 4].copy_from_slice(&len.to_le_bytes());
b[at + 4..at + 8].copy_from_slice(&off.to_le_bytes());
};
entry(0, 0, head_data.len() as u16, head_off);
entry(2, 0xA4A4, glyph_data.len() as u16, glyph_off);
b
}
/// A `unifont 1.0` with the font header and one glyph for `A` (U+0041).
fn synthetic_unifont() -> Vec<u8> {
let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
b.extend_from_slice(&2u32.to_le_bytes()); // header + 1 glyph
let head: Vec<u8> = [b"UNI\x00".as_slice(), &[6u8, 2, 2, 0, 0, 0]].concat();
b.extend_from_slice(&(head.len() as u16).to_le_bytes());
b.extend_from_slice(&head);
let glyph: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
b.extend_from_slice(&0x41u16.to_le_bytes());
b.extend_from_slice(&(glyph.len() as u16).to_le_bytes());
b.extend_from_slice(&glyph);
b
}
#[test]
fn bigfont_glyph_is_found_by_big5_code_and_scaled_to_cap_height() {
let path = tmp("cjk.shx", &synthetic_bigfont());
assert!(is_bigfont(&path));
let (above, below) = font_metrics(&path).expect("font header");
assert_eq!((above, below), (48.0, 0.0));
// `中` → Big5 A4A4 → the glyph; `A` is not in a big font.
let g = bigfont_glyph(&path, '中').expect("glyph for 中");
// 6 font units at above = 48 → 6 * 9 / 48 = 1.125 text units.
assert!((g.advance - 1.125).abs() < 1e-4, "advance {}", g.advance);
assert_eq!(g.strokes.len(), 1);
assert!(bigfont_glyph(&path, 'A').is_none());
assert!(bigfont_glyph(&path, '國').is_none(), "unknown code must miss, not alias");
}
#[test]
fn unifont_glyph_is_keyed_by_unicode() {
let path = tmp("uni.shx", &synthetic_unifont());
assert!(!is_bigfont(&path));
assert_eq!(font_metrics(&path), Some((6.0, 2.0)));
let g = font_glyph(&path, 'A' as u16).expect("glyph for A");
assert!((g.advance - 9.0).abs() < 1e-4, "6 units at above 6 → 9: {}", g.advance);
assert!(font_glyph(&path, 'B' as u16).is_none());
}
}
+79 -1
View File
@@ -176,6 +176,42 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
built
}
/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text
/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …)
/// sizes its ideographs — a CJK glyph is as tall as the text height and one
/// text height wide — so a TrueType glyph standing in for a missing big-font
/// glyph must use the same box, not the Latin cap height. Cap-height scaling
/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK
/// fonts), so every line of substituted Chinese text ran past its frame.
fn em_scale(face: &ttf_parser::Face) -> f32 {
CAP_UNITS / face.units_per_em().max(1) as f32
}
/// Ideographic / full-width characters: the ones a big font would supply and
/// that sit on an em box rather than the Latin cap height.
pub(crate) fn is_full_width(ch: char) -> bool {
matches!(
ch as u32,
0x1100..=0x11FF // Hangul Jamo
| 0x2E80..=0x2FDF // CJK / Kangxi radicals
| 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation
| 0x3040..=0x30FF // Hiragana, Katakana
| 0x3100..=0x312F // Bopomofo
| 0x3130..=0x318F // Hangul compatibility Jamo
| 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext.
| 0x3200..=0x33FF // enclosed CJK, CJK compatibility
| 0x3400..=0x4DBF // CJK ext. A
| 0x4E00..=0x9FFF // CJK unified ideographs
| 0xA960..=0xA97F // Hangul Jamo ext. A
| 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B
| 0xF900..=0xFAFF // CJK compatibility ideographs
| 0xFE30..=0xFE4F // CJK compatibility forms
| 0xFF01..=0xFF60 // full-width ASCII variants
| 0xFFE0..=0xFFE6 // full-width symbols
| 0x20000..=0x3FFFF // CJK ext. B–H
)
}
/// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes
/// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em.
fn cap_scale(face: &ttf_parser::Face) -> f32 {
@@ -379,7 +415,16 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let k = cap_scale(&face);
// A fallback glyph stands in for a stroke / SHX font that lacks
// the character. For an ideograph that font would have been a
// big font, whose glyphs fill the text height — so size the
// substitute by its em box. Everything else keeps the cap-height
// normalisation that lines it up with the Latin stroke glyphs.
let k = if is_full_width(ch) {
em_scale(&face)
} else {
cap_scale(&face)
};
let gid = ttf_parser::GlyphId(g.glyph_id);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
@@ -609,6 +654,39 @@ mod tests {
assert!(!run.glyphs.is_empty());
}
#[test]
fn fallback_ideograph_fills_the_text_height_like_a_big_font() {
// An SHX big font draws an ideograph one text height tall and one
// text height wide. The TrueType stand-in must match that box (9
// units), not the ~12–14 units the Latin cap-height normalisation
// gives a CJK em square — that overrun pushed every substituted
// Chinese line past its frame. Tolerated when the machine has no
// CJK font at all.
let Some(g) = fallback_glyph('中') else {
eprintln!("no CJK system font; skipping");
return;
};
assert!(
(g.advance - CAP_UNITS).abs() < 0.6,
"ideograph advance must be about one text height: got {}",
g.advance
);
let ink_h = g
.strokes
.iter()
.flatten()
.map(|p| p[1])
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
assert!(
ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05,
"ideograph ink must fit the text height: {:?}",
ink_h
);
// Latin fallback keeps the cap-height convention (shares a baseline
// with the stroke glyphs around it).
assert!(!is_full_width('A') && is_full_width('中') && is_full_width(','));
}
#[test]
fn shape_run_positions_glyphs() {
let fams = sysfont::families();