feat(fonts): complex-linetype shapes via LFF + Turkish glyph fallback

Restore complex-linetype embedded shapes: ltypeshp is converted from the old
CXF shape file to LFF (assets/fonts/ltypeshp.lff), with the bulge moved to the
LibreCAD preceding-segment convention. The LFF parser now keeps named shapes
(blocks whose label is a word, e.g. BOX/CIRC1/ZIG) in a separate map since
their codepoints collide, exposed via lff::shape(); complex_lt::emit_shape
draws them again at the pen position.

Missing glyphs now fall back through the chosen family -> unicode -> iso3098,
so Turkish letters (Ğ ğ Ş ş İ) render in any font (dotless ı is the lone gap
iso3098 lacks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-31 09:53:16 +03:00
commit 50115c55d8
3 changed files with 167 additions and 35 deletions

45
assets/fonts/ltypeshp.lff Normal file
View file

@ -0,0 +1,45 @@
# Format: LibreCAD Font 1
# Name: ltypeshp
# LetterSpacing: 3
# WordSpacing: 6.75
# LineSpacingFactor: 1
[0042] BAT
0,0;-1,2;3,2,A-1;2,0
[0042] BOX
2,1;2,-1;0,-1;0,1;2,1
[0043] CIRC1
1,1;1,-1,A1;1,1,A1
[0054] TRACK1
0,-1;0,1
[005a] ZIG
0,0;1,1;2,0
[0099] PT1
0,0;0.577,1;1.154,0
[009A] PT2
0,0;0.577,-1;1.154,0
[009B] PLANT1
0,0;1.82,1.14;2.27,-0.71;3,0
[009C] PLANT2
0,0;1.41,1.61;2.63,-0.59;3.15,0
[009C] PLANT3
0,0;2.42,0.53;3.05,1.24;4.23,0
[009D] PLANT4
0,0;1.76,-0.55;3.64,1;4.63,0
[009E] PLANT5
0,0;0.42,-0.4;3.45,-0.81;2.5,0
[009F] PLANT6
0,0;2.58,-0.5;3.81,1.37;4.42,0

View file

@ -291,16 +291,44 @@ fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [
]
}
/// Embedded SHAPE elements in complex linetypes are no longer supported:
/// the LFF font set ships no shape file (matching LibreCAD). Shape elements
/// are skipped; text-in-linetype still renders via the LFF fonts.
/// Transform a named linetype shape (from the converted `ltypeshp` LFF font)
/// into world-space strokes at the pen position.
fn emit_shape(
_name: &str,
_insert: [f32; 3],
_fwd: [f32; 3],
_perp: [f32; 3],
_scale: f32,
_rot_deg: f32,
name: &str,
insert: [f32; 3],
fwd: [f32; 3],
perp: [f32; 3],
scale: f32,
rot_deg: f32,
) -> Vec<Vec<[f32; 3]>> {
Vec::new()
let shape = match lff::shape(name) {
Some(s) => s,
None => return vec![],
};
let rot_r = rot_deg.to_radians();
let (cos_r, sin_r) = (rot_r.cos(), rot_r.sin());
shape
.strokes
.iter()
.map(|stroke| {
stroke
.iter()
.map(|&[lx, ly]| {
let scaled_x = lx * scale;
let scaled_y = ly * scale;
// Rotate in the shape's local frame, then place along the
// line tangent (fwd) and perpendicular (perp).
let along_fwd = cos_r * scaled_x - sin_r * scaled_y;
let along_perp = sin_r * scaled_x + cos_r * scaled_y;
[
insert[0] + fwd[0] * along_fwd + perp[0] * along_perp,
insert[1] + fwd[1] * along_fwd + perp[1] * along_perp,
insert[2] + fwd[2] * along_fwd + perp[2] * along_perp,
]
})
.collect()
})
.collect()
}

View file

@ -46,6 +46,7 @@ const FONTS_SRC: &[(&str, &str)] = &[
("kochigothic", include_str!("../../assets/fonts/kochigothic.lff")),
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
("kst32b", include_str!("../../assets/fonts/kst32b.lff")),
("ltypeshp", include_str!("../../assets/fonts/ltypeshp.lff")),
("lc_opengost-ar", include_str!("../../assets/fonts/lc_opengost-ar.lff")),
("lc_opengost-br", include_str!("../../assets/fonts/lc_opengost-br.lff")),
("opengosttypea-regular", include_str!("../../assets/fonts/opengosttypea-regular.lff")),
@ -122,6 +123,10 @@ pub struct Font {
pub word_spacing: f32,
pub line_spacing: f32,
glyphs: HashMap<char, Glyph>,
/// Named shapes — blocks whose `[hex] LABEL` label is a word rather than
/// the single codepoint character (used by `ltypeshp` for complex
/// linetype shapes, keyed by name since codepoints collide).
shapes: HashMap<String, Glyph>,
}
impl Font {
@ -129,6 +134,15 @@ impl Font {
pub fn glyph(&self, c: char) -> Option<&Glyph> {
self.glyphs.get(&c)
}
/// Look up a named shape (case-insensitive).
pub fn shape(&self, name: &str) -> Option<&Glyph> {
self.shapes.get(&name.to_ascii_uppercase())
}
}
/// Look up a complex-linetype shape by name in the `ltypeshp` font.
pub fn shape(name: &str) -> Option<&'static Glyph> {
fonts_map().get("LTYPESHP").and_then(|f| f.shape(name))
}
// ── Registry ───────────────────────────────────────────────────────────────
@ -179,6 +193,12 @@ fn unicode_font() -> &'static Font {
.expect("at least one LFF font must be embedded")
}
/// Secondary fallback covering Latin-extended / Turkish letters (ğ Ş İ …)
/// that `unicode` lacks.
fn latin_ext_font() -> Option<&'static Font> {
fonts_map().get("ISO3098")
}
/// Return a font by name (case-insensitive). `.shx` suffix is stripped.
/// Falls back to Standard → Unicode → any embedded font.
pub fn get_font(name: &str) -> &'static Font {
@ -390,10 +410,12 @@ pub fn tessellate_text_run(
cursor_x += font.word_spacing;
continue;
}
// Selected family first, then the broad Unicode fallback.
// Selected family first, then the broad Unicode fallback, then
// iso3098 for Latin-extended/Turkish letters Unicode omits.
let glyph = font
.glyph(render_ch)
.or_else(|| unicode_font().glyph(render_ch));
.or_else(|| unicode_font().glyph(render_ch))
.or_else(|| latin_ext_font().and_then(|f| f.glyph(render_ch)));
match glyph {
Some(glyph) => {
for stroke in &glyph.strokes {
@ -440,19 +462,29 @@ fn parse_lff(src: &str) -> Font {
word_spacing: 6.75,
line_spacing: 1.0,
glyphs: HashMap::new(),
shapes: HashMap::new(),
};
let mut raw: HashMap<char, RawGlyph> = HashMap::new();
let mut raw_shapes: HashMap<String, RawGlyph> = HashMap::new();
let mut cur: Option<char> = None;
let mut cur_name: Option<String> = None;
let mut cur_glyph = RawGlyph::default();
let flush = |cur: &mut Option<char>, g: &mut RawGlyph, raw: &mut HashMap<char, RawGlyph>| {
if let Some(c) = cur.take() {
raw.insert(c, std::mem::take(g));
} else {
*g = RawGlyph::default();
}
};
// Route the just-finished block to the glyph map (by char) or, when it
// carried a shape name, to the shape map (by name).
macro_rules! flush {
() => {{
if let Some(c) = cur.take() {
raw.insert(c, std::mem::take(&mut cur_glyph));
} else if let Some(n) = cur_name.take() {
raw_shapes.insert(n, std::mem::take(&mut cur_glyph));
} else {
// Discard any strokes seen before the first header.
let _ = std::mem::take(&mut cur_glyph);
}
}};
}
for line in src.lines() {
let t = line.trim();
@ -478,16 +510,22 @@ fn parse_lff(src: &str) -> Font {
continue;
}
if t.starts_with('[') {
flush(&mut cur, &mut cur_glyph, &mut raw);
flush!();
if let Some(end) = t.find(']') {
let hex = t[1..end].trim();
if let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
cur = Some(c);
let label = t[end + 1..].trim();
let cp = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32);
// A 0/1-char label is a normal glyph (keyed by codepoint); a
// word label (BOX, CIRC1, …) is a named shape.
if label.chars().count() > 1 {
cur_name = Some(label.to_ascii_uppercase());
} else {
cur = cp;
}
}
continue;
}
if cur.is_none() {
if cur.is_none() && cur_name.is_none() {
continue;
}
// `C<hex>` — reference another glyph's strokes.
@ -506,7 +544,7 @@ fn parse_lff(src: &str) -> Font {
}
}
}
flush(&mut cur, &mut cur_glyph, &mut raw);
flush!();
// Resolve `C<hex>` references. Each pass folds in targets that are
// themselves already reference-free; repeat so ref-to-ref chains settle.
@ -537,20 +575,20 @@ fn parse_lff(src: &str) -> Font {
}
}
for (c, g) in raw {
let advance = g
.strokes
let advance_of = |strokes: &[Vec<[f32; 2]>]| -> f32 {
strokes
.iter()
.flat_map(|s| s.iter())
.map(|&[x, _]| x)
.fold(0.0_f32, f32::max);
font.glyphs.insert(
c,
Glyph {
strokes: g.strokes,
advance,
},
);
.fold(0.0_f32, f32::max)
};
for (c, g) in raw {
let advance = advance_of(&g.strokes);
font.glyphs.insert(c, Glyph { strokes: g.strokes, advance });
}
for (n, g) in raw_shapes {
let advance = advance_of(&g.strokes);
font.shapes.insert(n, Glyph { strokes: g.strokes, advance });
}
font
}
@ -662,6 +700,27 @@ mod tests {
let (w, h) = (maxx - minx, maxy - miny);
assert!(h > w, "{name} O should be upright (h {h:.1} > w {w:.1})");
}
// Turkish letters absent from simplex/unicode still render via the
// iso3098 fallback (ı/U+0131 is the one exception iso3098 lacks).
for ch in ['Ğ', 'ş', 'İ', 'Ş', 'ğ'] {
let s = tessellate_text_run(
[0.0, 0.0],
2.5,
0.0,
1.0,
0.0,
1.0,
"simplex",
&ch.to_string(),
);
assert!(!s.is_empty(), "Turkish '{ch}' should render via fallback");
}
// Complex-linetype shapes load by name (codepoints collide, so they
// must be keyed by label).
for sh in ["BOX", "CIRC1", "ZIG", "TRACK1"] {
let g = shape(sh).unwrap_or_else(|| panic!("shape {sh} missing"));
assert!(!g.strokes.is_empty(), "shape {sh} has no strokes");
}
}
}