feat(fonts): switch the text engine from CXF to LibreCAD LFF fonts

Replace the small QCAD CXF stroke-font set with the full LibreCAD LFF font
library (46 fonts) and a new LFF parser/engine in src/scene/lff.rs. The
parser reads the `[hex] char` glyph blocks, tessellates `,A<bulge>` arcs, and
resolves `C<hex>` glyph references. Per LibreCAD's RS_Polyline convention a
vertex's bulge curves the segment ending at that vertex — getting this wrong
turned round letters like O into sideways capsules.

Font name resolution mirrors LibreCAD: match by file stem, map common
AutoCAD/DXF SHX names (txt, isocp, complex, italic, gothic*, gost, …) to the
nearest LFF family, and fall back to `standard`. A missing glyph in the chosen
family falls back to the broad `unicode` font so accented/Cyrillic/Greek text
still renders. Cap height stays 9 units, so the text scale is unchanged.

All text consumers move from cxf:: to lff::; complex linetypes still render
embedded text but no longer draw embedded shapes (LibreCAD ships no shape
file). The CXF module and every *.cxf asset are removed, and the MText / text
style font pickers list the LFF families.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-05-31 03:31:20 +03:00
1 parent 32136dd9dd
commit c1b33677e2
98 files changed
+886126 -47228

No files matched your search

+30 -34
View File
@@ -223,7 +223,7 @@ pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
let is_sp = |c: char| c == ' ' || c == '\u{00A0}';
let mut result: Vec<(usize, usize)> = Vec::new();
let mut para: Vec<(usize, usize, char)> = Vec::new();
let mut flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| {
let flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| {
let s = para.iter().position(|t| !is_sp(t.2)).unwrap_or(para.len());
let e = para.iter().rposition(|t| !is_sp(t.2)).map(|i| i + 1).unwrap_or(s);
for t in &para[s..e] {
@@ -305,22 +305,6 @@ pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
result
}
/// Prefix/suffix inline codes for a character-level toggle. `Uppercase` /
/// `Lowercase` return `None` (they transform the selected text instead).
pub fn fmt_wrap(kind: MTextFmt) -> Option<(&'static str, &'static str)> {
match kind {
// Bold / italic ride on a font run; the renderer reads the `b`/`i`
// flags of a `\f...;` code. Use a neutral family so any styled font
// still picks up the flag.
MTextFmt::Bold => Some(("\\fArial|b1;", "\\fArial|b0;")),
MTextFmt::Italic => Some(("\\fArial|i1;", "\\fArial|i0;")),
MTextFmt::Underline => Some(("\\L", "\\l")),
MTextFmt::Overline => Some(("\\O", "\\o")),
MTextFmt::Strike => Some(("\\K", "\\k")),
MTextFmt::Uppercase | MTextFmt::Lowercase => None,
}
}
// ── App-side editor driver ──────────────────────────────────────────────────
use crate::scene::tessellate;
@@ -432,26 +416,38 @@ impl super::OpenCADStudio {
}
/// Apply a character-format toggle to the preview selection (preferred) or
/// the Edit-box selection.
/// the Edit-box selection. The stroke-font renderer has no true bold /
/// italic, so Bold switches the run to the heavier Gothic stroke font and
/// Italic applies an oblique slant — both produce a visible effect.
pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) {
let applied = match kind {
MTextFmt::Uppercase => self.mtext_splice_sel("", "", Some(true)),
MTextFmt::Lowercase => self.mtext_splice_sel("", "", Some(false)),
_ => {
let (pre, suf) = fmt_wrap(kind).unwrap();
self.mtext_splice_sel(pre, suf, None)
}
// Font to restore to after a Bold run (the current global font).
let restore = self
.mtext_editor
.as_ref()
.map(|e| {
if e.font.trim().is_empty() {
"Standard".to_string()
} else {
e.font.clone()
}
})
.unwrap_or_else(|| "Standard".to_string());
let (pre, suf, case): (String, String, Option<bool>) = match kind {
MTextFmt::Bold => ("\\fGothic;".into(), format!("\\f{restore};"), None),
MTextFmt::Italic => ("\\Q15;".into(), "\\Q0;".into(), None),
MTextFmt::Underline => ("\\L".into(), "\\l".into(), None),
MTextFmt::Overline => ("\\O".into(), "\\o".into(), None),
MTextFmt::Strike => ("\\K".into(), "\\k".into(), None),
MTextFmt::Uppercase => (String::new(), String::new(), Some(true)),
MTextFmt::Lowercase => (String::new(), String::new(), Some(false)),
};
if !applied {
if !self.mtext_splice_sel(&pre, &suf, case) {
if let Some(ed) = self.mtext_editor.as_mut() {
let sel = ed.content.selection();
let text = match kind {
MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(),
MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(),
_ => {
let (pre, suf) = fmt_wrap(kind).unwrap();
format!("{pre}{}{suf}", sel.as_deref().unwrap_or(""))
}
let sel = ed.content.selection().unwrap_or_default();
let text = match case {
Some(true) => sel.to_uppercase(),
Some(false) => sel.to_lowercase(),
None => format!("{pre}{sel}{suf}"),
};
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
}
+30 -20
View File
@@ -1323,12 +1323,18 @@ fn position_canvas_overlay<'a>(
// ── In-place MText editor overlay ───────────────────────────────────────────
const MTEXT_FONTS: [&str; 5] = [
// Stroke-font families the renderer ships (LibreCAD LFF; see scene/lff.rs).
const MTEXT_FONTS: [&str; 10] = [
"[Style default]",
"Arial",
"Times New Roman",
"Courier New",
"ISOCPEUR",
"Standard",
"ISO",
"Simplex",
"RomanS",
"RomanD",
"ItalicC",
"ScriptS",
"GothGBT",
"Cursive",
];
/// (label, ACI). 256 = ByLayer.
const MTEXT_COLORS: [(&str, u16); 8] = [
@@ -1348,8 +1354,8 @@ const MTEXT_COLORS: [(&str, u16); 8] = [
const MTEXT_PREVIEW_PAD: f32 = 12.0;
struct MTextPreview {
/// Disconnected polylines as (x, y) world points (NaN-split already done).
segments: Vec<Vec<(f32, f32)>>,
/// Disconnected polylines as (x, y) world points + colour (NaN-split done).
segments: Vec<(Vec<(f32, f32)>, Color)>,
/// Per-visible-character boxes (world frame) for click-to-select.
boxes: Vec<crate::entities::text_support::GlyphBox>,
/// Current selection as a visible-char range.
@@ -1477,7 +1483,7 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
}
}
}
for seg in &self.segments {
for (seg, col) in &self.segments {
if seg.len() < 2 {
continue;
}
@@ -1487,31 +1493,35 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
p.line_to(map(x, y));
}
});
frame.stroke(
&path,
Stroke::default()
.with_color(Color { r: 0.93, g: 0.93, b: 0.93, a: 1.0 })
.with_width(1.4),
);
frame.stroke(&path, Stroke::default().with_color(*col).with_width(1.4));
}
vec![frame.into_geometry()]
}
}
/// Split every preview WireModel into finite (x, y) polyline runs.
fn mtext_preview_segments(ed: &super::mtext_editor::MTextEditorState) -> Vec<Vec<(f32, f32)>> {
let mut out: Vec<Vec<(f32, f32)>> = Vec::new();
/// Split every preview WireModel into finite (x, y) polyline runs, each
/// carrying its wire's colour so inline `\C` / the colour dropdown shows.
fn mtext_preview_segments(
ed: &super::mtext_editor::MTextEditorState,
) -> Vec<(Vec<(f32, f32)>, Color)> {
let mut out: Vec<(Vec<(f32, f32)>, Color)> = Vec::new();
for w in &ed.preview_wires {
let col = Color {
r: w.color[0],
g: w.color[1],
b: w.color[2],
a: 1.0,
};
let mut run: Vec<(f32, f32)> = Vec::new();
for p in &w.points {
if p[0].is_finite() && p[1].is_finite() {
run.push((p[0], p[1]));
} else if !run.is_empty() {
out.push(std::mem::take(&mut run));
out.push((std::mem::take(&mut run), col));
}
}
if !run.is_empty() {
out.push(run);
out.push((run, col));
}
}
out
@@ -1690,7 +1700,7 @@ fn mtext_editor_overlay<'a>(
let body: Element<'a, Message> = if ed.show_preview {
let segments = mtext_preview_segments(ed);
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for seg in &segments {
for (seg, _) in &segments {
for &(x, y) in seg {
minx = minx.min(x);
miny = miny.min(y);
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckC
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::wire_model::SnapHint;
use crate::scene::{cxf, transform};
use crate::scene::{lff, transform};
// ── Shared helpers ────────────────────────────────────────────────────────────
@@ -273,7 +273,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
anchor_f64[0] - (anchor_local_x as f64 * cos_r - local_y_for_line as f64 * sin_r),
anchor_f64[1] - (anchor_local_x as f64 * sin_r + local_y_for_line as f64 * cos_r),
];
let strokes = cxf::tessellate_text_ex(
let strokes = lff::tessellate_text_ex(
[0.0, 0.0],
input.height as f32,
rotation,
+2 -2
View File
@@ -8,7 +8,7 @@ use crate::entities::text_support::{
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible};
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
use crate::scene::cxf;
use crate::scene::lff;
use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::wire_model::SnapHint;
@@ -131,7 +131,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
anchor_f64[1] - (anchor_local_x as f64 * sin_r + anchor_local_y as f64 * cos_r),
];
// Strokes are in glyph-local space (origin = [0,0]).
let strokes = cxf::tessellate_text_ex(
let strokes = lff::tessellate_text_ex(
[0.0, 0.0],
t.height as f32,
rotation,
+8 -8
View File
@@ -2,7 +2,7 @@ use acadrust::types::aci_table::aci_to_rgb;
use acadrust::{CadDocument, EntityType};
use crate::scene::acad_to_truck::TextStroke;
use crate::scene::cxf;
use crate::scene::lff;
pub struct ResolvedTextStyle {
pub font_name: String,
@@ -65,7 +65,7 @@ pub fn text_local_bounds(
return None;
}
let font = cxf::get_font(font_name);
let font = lff::get_font(font_name);
let scale = height / 9.0;
let wf = width_factor.abs().clamp(0.01, 100.0);
let ob = oblique_angle.tan();
@@ -846,7 +846,7 @@ pub fn measure_word(
) -> f32 {
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
let font = cxf::get_font(font_name);
let font = lff::get_font(font_name);
let mut w = 0.0_f32;
for ch in text.chars() {
w += match font.glyph(ch) {
@@ -860,7 +860,7 @@ pub fn measure_word(
pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
cxf::get_font(font_name).word_spacing * scale
lff::get_font(font_name).word_spacing * scale
}
pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
@@ -991,7 +991,7 @@ pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
}
}
/// Wrap a run's glyph text with MTEXT decoration markers so cxf's
/// Wrap a run's glyph text with MTEXT decoration markers so lff's
/// `tessellate_text_run` emits the underline / overline / strikethrough
/// strokes for us — keeps decoration geometry in one place rather than
/// duplicating the y-position constants.
@@ -1115,7 +1115,7 @@ pub struct MTextLayout {
/// text block.
pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
let base_font_name = opts.style.font_name.clone();
let base_font = cxf::get_font(&base_font_name);
let base_font = lff::get_font(&base_font_name);
let base_wf_abs = opts.style.width_factor.max(0.01);
let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs };
let base_oblique = opts.style.oblique_angle;
@@ -1353,7 +1353,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
ins_x + (line_base_x + world_dx) as f64,
ins_y + (line_base_y + world_dy) as f64,
];
let strokes = cxf::tessellate_text_run(
let strokes = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
@@ -1372,7 +1372,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
// Per-character boxes, advancing exactly as
// `measure_word` does so they track the glyphs.
let scale = run_scale(&atom.state, entity_h, base_wf);
let font = cxf::get_font(font_name);
let font = lff::get_font(font_name);
let mut cx = cursor_x;
for ch in text.chars() {
let adv = match font.glyph(ch) {
+2 -2
View File
@@ -7,7 +7,7 @@ use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckC
use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject};
use crate::scene::object::{GripApply, GripDef, PropSection};
use crate::scene::wire_model::SnapHint;
use crate::scene::{cxf, transform};
use crate::scene::{lff, transform};
// ── GDT text parser ───────────────────────────────────────────────────────────
@@ -207,7 +207,7 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec<Vec<[f32; 2]>> {
let tx = cell_x + (cw - text_w) * 0.5;
// Tessellate text in local frame then transform
let local_strokes =
cxf::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, "txt", cell);
lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, "txt", cell);
for polyline in local_strokes {
let transformed: Vec<[f32; 2]> = polyline
.into_iter()
+3 -2
View File
@@ -22,9 +22,10 @@ pub enum LtSegment {
Space(f32),
/// Draw a dot (zero-length element, rendered as a tiny mark).
Dot,
/// Draw a CXF shape at the current pen position.
/// Draw a shape at the current pen position. Embedded SHAPE elements are
/// no longer rendered (the LFF font set ships no shape file).
Shape {
/// Shape name — looked up in `scene::cxf`.
/// Shape name (retained for round-tripping; not drawn).
name: String,
/// X offset along the linetype direction (can be negative).
x: f32,
+12 -45
View File
@@ -8,7 +8,7 @@
use crate::entities::text_support::resolve_dxf_special_chars;
use crate::linetypes::{ComplexLt, LtSegment};
use crate::scene::cxf;
use crate::scene::lff;
use crate::scene::wire_model::WireModel;
// ── Public entry point ────────────────────────────────────────────────────
@@ -155,7 +155,7 @@ pub fn apply_along(
let insert = offset_pt(insert, fwd, perp, *x, *y);
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
let resolved = resolve_dxf_special_chars(text);
let text_strokes = cxf::tessellate_text_ex(
let text_strokes = lff::tessellate_text_ex(
[insert[0], insert[1]],
*tx_scale,
fwd_angle,
@@ -291,49 +291,16 @@ fn offset_pt(pt: [f32; 3], fwd: [f32; 3], perp: [f32; 3], dx: f32, dy: f32) -> [
]
}
/// Transform a CXF shape into world-space strokes.
/// 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.
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]>> {
// cxf::get() now returns Option<&CxfGlyph> — same .strokes field.
let shape = match cxf::get(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());
let rx = cos_r;
let ry = sin_r;
let sx = -sin_r;
let sy = cos_r;
shape
.strokes
.iter()
.map(|stroke| {
stroke
.iter()
.map(|&[lx, ly]| {
let scaled_x = lx * scale;
let scaled_y = ly * scale;
let along_fwd = rx * scaled_x + sx * scaled_y;
let along_perp = ry * scaled_x + sy * 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()
Vec::new()
}
-807
View File
@@ -1,807 +0,0 @@
//! CXF vector font and shape parser.
//!
//! Handles both linetype shape files (ltypeshp.cxf) and full font files
//! (Standard.cxf, NormalLatin2.cxf, etc.) with a single parser.
//!
//! Supported CXF geometry commands:
//! L x0,y0,x1,y1 — single line segment
//! PL x,y,b,... — open polyline (x, y, bulge triplets)
//! PLC x,y,b,... — closed polyline
//! A cx,cy,r,start,end — arc CCW, angles in degrees
//! AR cx,cy,r,start,end — arc CW, angles in degrees
//!
//! Entry type decision rule:
//! [XXXX] LABEL
//! - If LABEL is exactly one character AND equals char::from_u32(hex)
//! -> stored as a Unicode glyph (font use-case, e.g. [0041] A)
//! - Otherwise -> stored as a named shape (ltypeshp, e.g. [0042] BAT)
//!
//! Glyph coordinate space:
//! Origin = left baseline corner.
//! Cap height = 9 glyph units.
//! Scale factor = text_height / 9.0.
use std::collections::{HashMap, HashSet};
use std::f32::consts::TAU;
use std::sync::{Mutex, OnceLock};
// ── Embedded assets ───────────────────────────────────────────────────────
const SRC_LTYPESHP: &str = include_str!("../../assets/fonts/ltypeshp.cxf");
const SRC_STANDARD: &str = include_str!("../../assets/fonts/Standard.cxf");
const SRC_NORMAL: &str = include_str!("../../assets/fonts/NormalLatin2.cxf");
const SRC_COURIER: &str = include_str!("../../assets/fonts/CourierCad.cxf");
const SRC_SANS: &str = include_str!("../../assets/fonts/SansNS.cxf");
const SRC_ITALIC: &str = include_str!("../../assets/fonts/ItalicT.cxf");
const SRC_GOTHIC: &str = include_str!("../../assets/fonts/GothITT.cxf");
const SRC_CURSIVE: &str = include_str!("../../assets/fonts/Cursive.cxf");
const SRC_SCRIPTC: &str = include_str!("../../assets/fonts/ScriptS.cxf");
// ── Public types ──────────────────────────────────────────────────────────
/// One glyph or shape: a list of open 2-D polyline strokes in local units.
#[derive(Clone, Default)]
pub struct CxfGlyph {
/// Each stroke is a list of [x, y] points forming a connected polyline.
pub strokes: Vec<Vec<[f32; 2]>>,
/// Advance width in glyph units (rightmost X of all strokes).
pub advance: f32,
}
/// A parsed CXF file.
#[derive(Clone)]
pub struct CxfFile {
/// Font/file name from the `# Name:` header.
pub name: String,
/// Extra space between characters in glyph units (`# LetterSpacing:`).
pub letter_spacing: f32,
/// Space character width in glyph units (`# WordSpacing:`).
pub word_spacing: f32,
/// Line height multiplier relative to text height (`# LineSpacingFactor:`).
pub line_spacing: f32,
/// Named shapes keyed by uppercase name — used for ltypeshp.
shapes: HashMap<String, CxfGlyph>,
/// Unicode glyphs keyed by character — used for font files.
glyphs: HashMap<char, CxfGlyph>,
}
impl CxfFile {
/// Look up a linetype shape by name (case-insensitive).
pub fn shape(&self, name: &str) -> Option<&CxfGlyph> {
self.shapes.get(&name.to_ascii_uppercase())
}
/// Look up a font glyph by Unicode character.
pub fn glyph(&self, c: char) -> Option<&CxfGlyph> {
self.glyphs.get(&c)
}
}
// ── Static registries ─────────────────────────────────────────────────────
static SHAPES: OnceLock<CxfFile> = OnceLock::new();
static FONTS: OnceLock<HashMap<String, CxfFile>> = OnceLock::new();
/// Dedup set so each (font, char) pair warns at most once per process run.
static WARNED_GLYPHS: OnceLock<Mutex<HashSet<(String, char)>>> = OnceLock::new();
fn warn_missing_glyph(font_name: &str, ch: char) {
if ch.is_ascii() {
return;
}
let set = WARNED_GLYPHS.get_or_init(|| Mutex::new(HashSet::new()));
if let Ok(mut guard) = set.lock() {
if guard.insert((font_name.to_string(), ch)) {
eprintln!(
"cxf: glyph U+{:04X} ('{}') not found in font '{font_name}'",
ch as u32, ch
);
}
}
}
fn shapes_file() -> &'static CxfFile {
SHAPES.get_or_init(|| parse(SRC_LTYPESHP))
}
fn fonts_map() -> &'static HashMap<String, CxfFile> {
FONTS.get_or_init(|| {
let mut map = HashMap::new();
let mut insert = |src: &str, aliases: &[&str]| {
let f = parse(src);
map.insert(f.name.to_ascii_uppercase(), f.clone());
for alias in aliases {
map.insert(alias.to_ascii_uppercase(), f.clone());
}
};
insert(SRC_STANDARD, &["STANDARD", "TXT", "TXT.SHX", "SIMPLEX"]);
insert(
SRC_NORMAL,
&["NORMAL", "NORMALLATIN2", "ROMANS", "ROMANS.SHX"],
);
insert(SRC_COURIER, &["COURIER", "COURIERCAD", "COURIERCAD"]);
insert(SRC_SANS, &["SANS", "SANSNS"]);
insert(SRC_ITALIC, &["ITALIC", "ITALICT"]);
insert(SRC_GOTHIC, &["GOTHIC", "GOTHITT"]);
insert(SRC_CURSIVE, &["CURSIVE"]);
insert(SRC_SCRIPTC, &["SCRIPTS", "SCRIPTC", "SCRIPT"]);
map
})
}
// ── Public API — linetype shapes ──────────────────────────────────────────
/// Look up a linetype shape by name (case-insensitive). Used by `complex_lt.rs`.
pub fn get(name: &str) -> Option<&'static CxfGlyph> {
shapes_file().shape(name)
}
// ── Public API — text tessellation ────────────────────────────────────────
/// Return a font by name (case-insensitive).
/// Falls back to Standard → Normal → any available font.
pub fn get_font(name: &str) -> &'static CxfFile {
let map = fonts_map();
let key = name.to_ascii_uppercase();
map.get(&key)
.or_else(|| map.get("STANDARD"))
.or_else(|| map.get("NORMAL"))
.or_else(|| map.values().next())
.expect("at least one CXF font must be embedded")
}
/// Tessellate a text string into world-space 2-D strokes.
///
/// - `origin` — insertion point [world_x, world_y]
/// - `height` — text height in world units (cap height = 9 glyph units)
/// - `rotation` — rotation angle in radians (CCW positive)
/// - `font_name` — e.g. "Standard", "Romans", "Courier" (case-insensitive)
/// - `text` — the string to render
///
/// Returns one Vec<[f32;2]> per stroke. Strokes from different glyphs are
/// separate entries.
#[allow(dead_code)]
pub fn tessellate_text(
origin: [f32; 2],
height: f32,
rotation: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let font = get_font(font_name);
let scale = height / 9.0; // CXF cap height = 9 glyph units
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] {
let lx = (cursor_x + gx) * scale;
let ly = gy * scale;
[
origin[0] + lx * cos_r - ly * sin_r,
origin[1] + lx * sin_r + ly * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
for ch in text.chars() {
if ch == ' ' {
cursor_x += font.word_spacing;
continue;
}
match font.glyph(ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
if stroke.len() < 2 {
continue;
}
out.push(
stroke
.iter()
.map(|&[gx, gy]| xform(gx, gy, cursor_x))
.collect(),
);
}
cursor_x += glyph.advance + font.letter_spacing;
}
None => {
warn_missing_glyph(font_name, ch);
cursor_x += 6.0 + font.letter_spacing;
}
}
}
out
}
pub fn tessellate_text_ex(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
tessellate_text_run(
origin,
height,
rotation,
width_factor,
oblique_angle,
1.0,
font_name,
text,
)
}
/// Like [`tessellate_text_ex`] but takes a `tracking` multiplier applied to
/// the per-glyph `letter_spacing` advance. `tracking = 1.0` keeps the legacy
/// behaviour; MTEXT's `\T<v>;` inline code routes here so per-run character
/// spacing scales independently of the width factor.
#[allow(clippy::too_many_arguments)]
pub fn tessellate_text_run(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let font = get_font(font_name);
let scale = height / 9.0;
// Negative width_factor produces mirrored (backward) text.
let wf = if width_factor < 0.0 {
width_factor.clamp(-100.0, -0.01)
} else {
width_factor.clamp(0.01, 100.0)
};
let ob = oblique_angle.tan();
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
// Transform from glyph-space (cursor_x + gx, gy) to world space.
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
let sx = (cx + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
[
origin[0] + sx * cos_r - sy * sin_r,
origin[1] + sx * sin_r + sy * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
// Decoration toggle state: Some(start_cursor_x) when active.
// Y positions in 9-unit em space (baseline=0, cap=9).
let mut underline: Option<f32> = None;
let mut overline: Option<f32> = None;
let mut strikethrough: Option<f32> = None;
const UNDER_Y: f32 = -1.5;
const OVER_Y: f32 = 10.5;
const STRIKE_Y: f32 = 4.5;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
// MText decoration toggles: \L on, \l off, \O on, \o off, \K on, \k off.
if ch == '\\' {
match chars.peek().copied() {
Some('L') => {
chars.next();
if underline.is_none() {
underline = Some(cursor_x);
}
continue;
}
Some('l') => {
chars.next();
if let Some(s) = underline.take() {
out.push(vec![xform(s, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
}
continue;
}
Some('O') => {
chars.next();
if overline.is_none() {
overline = Some(cursor_x);
}
continue;
}
Some('o') => {
chars.next();
if let Some(s) = overline.take() {
out.push(vec![xform(s, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
}
continue;
}
Some('K') => {
chars.next();
if strikethrough.is_none() {
strikethrough = Some(cursor_x);
}
continue;
}
Some('k') => {
chars.next();
if let Some(s) = strikethrough.take() {
out.push(vec![
xform(s, STRIKE_Y, 0.0),
xform(cursor_x, STRIKE_Y, 0.0),
]);
}
continue;
}
_ => {} // not a decoration code — fall through and render as backslash glyph
}
}
// Resolve DXF %%x special-character sequences inline.
let render_ch: char = if ch == '%' && chars.peek() == Some(&'%') {
chars.next(); // consume second '%'
match chars.peek().map(|c| c.to_ascii_lowercase()) {
Some('d') => {
chars.next();
'°'
}
Some('p') => {
chars.next();
'±'
}
Some('c') => {
chars.next();
'⌀'
}
Some('%') => {
chars.next();
'%'
}
Some('u') => {
chars.next();
underline = match underline.take() {
Some(start) => {
out.push(vec![
xform(start, UNDER_Y, 0.0),
xform(cursor_x, UNDER_Y, 0.0),
]);
None
}
None => Some(cursor_x),
};
continue;
}
Some('o') => {
chars.next();
overline = match overline.take() {
Some(start) => {
out.push(vec![
xform(start, OVER_Y, 0.0),
xform(cursor_x, OVER_Y, 0.0),
]);
None
}
None => Some(cursor_x),
};
continue;
}
Some(d) if d.is_ascii_digit() => {
// %%nnn — 3-digit decimal Unicode scalar
let mut digits = String::with_capacity(3);
for _ in 0..3 {
match chars.peek() {
Some(&c) if c.is_ascii_digit() => {
digits.push(chars.next().unwrap());
}
_ => break,
}
}
if digits.len() == 3 {
if let Ok(n) = digits.parse::<u32>() {
if let Some(c) = char::from_u32(n) {
c
} else {
continue;
}
} else {
continue;
}
} else {
// Partial digit sequence — advance as unknown glyph and move on
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
continue;
}
}
_ => {
continue;
} // unknown %%x — skip silently
}
} else {
ch
};
if render_ch == ' ' {
cursor_x += font.word_spacing;
continue;
}
match font.glyph(render_ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
if stroke.len() < 2 {
continue;
}
out.push(
stroke
.iter()
.map(|&[gx, gy]| xform(gx, gy, cursor_x))
.collect(),
);
}
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, render_ch);
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
}
}
}
// Close any decoration spans that weren't explicitly closed.
if let Some(start) = underline {
out.push(vec![
xform(start, UNDER_Y, 0.0),
xform(cursor_x, UNDER_Y, 0.0),
]);
}
if let Some(start) = overline {
out.push(vec![
xform(start, OVER_Y, 0.0),
xform(cursor_x, OVER_Y, 0.0),
]);
}
if let Some(start) = strikethrough {
out.push(vec![
xform(start, STRIKE_Y, 0.0),
xform(cursor_x, STRIKE_Y, 0.0),
]);
}
out
}
// `measure_text` was removed alongside the legacy MText helpers in
// `text_support`; the run-aware `measure_word` / `measure_space` in
// `text_support` carry per-run state (tracking / width factor / font
// override) and are what every caller now uses.
// ── Parser ────────────────────────────────────────────────────────────────
fn parse(src: &str) -> CxfFile {
let mut file = CxfFile {
name: String::from("Unknown"),
letter_spacing: 3.0,
word_spacing: 6.75,
line_spacing: 1.0,
shapes: HashMap::new(),
glyphs: HashMap::new(),
};
let mut cur_char: Option<char> = None;
let mut cur_name: Option<String> = None;
let mut cur_strokes: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cur_max_x: f32 = 0.0;
for raw in src.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
// ── Header ────────────────────────────────────────────────────────
if line.starts_with('#') {
if let Some(rest) = line.strip_prefix("# Name:") {
file.name = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("# LetterSpacing:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.letter_spacing = v;
}
} else if let Some(rest) = line.strip_prefix("# WordSpacing:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.word_spacing = v;
}
} else if let Some(rest) = line.strip_prefix("# LineSpacingFactor:") {
if let Ok(v) = rest.trim().parse::<f32>() {
file.line_spacing = v;
}
}
continue;
}
// ── Entry header: `[XXXX] LABEL` ─────────────────────────────────
if line.starts_with('[') {
flush_entry(
&mut file,
&mut cur_char,
&mut cur_name,
&mut cur_strokes,
&mut cur_max_x,
);
let hex_end = match line.find(']') {
Some(i) => i,
None => continue,
};
let hex_str = line[1..hex_end].trim();
let label = line[hex_end + 1..].trim();
if let Ok(cp) = u32::from_str_radix(hex_str, 16) {
let cp_char = char::from_u32(cp);
// Glyph: label is exactly the one character that the codepoint maps to.
// Shape: label is a word (different from the codepoint char) or empty
// with a non-printable / non-matching codepoint.
let is_glyph = match cp_char {
Some(ch) => label.chars().count() == 1 && label.chars().next() == Some(ch),
None => false,
};
if is_glyph {
cur_char = cp_char;
cur_name = None;
} else if !label.is_empty() {
cur_name = Some(label.to_string());
cur_char = None;
} else if let Some(ch) = cp_char {
// No label but valid codepoint — treat as glyph.
cur_char = Some(ch);
cur_name = None;
}
}
continue;
}
// ── Geometry (only inside a valid entry) ──────────────────────────
if cur_char.is_none() && cur_name.is_none() {
continue;
}
let up = line.to_ascii_uppercase();
if up.starts_with("PLC ") {
if let Some(s) = parse_triplet_poly(&line[4..], true) {
update_max_x(&s, &mut cur_max_x);
if !s.is_empty() {
cur_strokes.push(s);
}
}
} else if up.starts_with("PL ") {
if let Some(s) = parse_triplet_poly(&line[3..], false) {
update_max_x(&s, &mut cur_max_x);
if !s.is_empty() {
cur_strokes.push(s);
}
}
} else if up.starts_with("AR ") {
if let Some(s) = parse_arc(&line[3..], true) {
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
} else if up.starts_with("A ") {
if let Some(s) = parse_arc(&line[2..], false) {
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
} else if up.starts_with("L ") {
let vals: Vec<f32> = line[2..]
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if vals.len() >= 4 {
let s = vec![[vals[0], vals[1]], [vals[2], vals[3]]];
update_max_x(&s, &mut cur_max_x);
cur_strokes.push(s);
}
}
}
flush_entry(
&mut file,
&mut cur_char,
&mut cur_name,
&mut cur_strokes,
&mut cur_max_x,
);
file
}
fn flush_entry(
file: &mut CxfFile,
cur_char: &mut Option<char>,
cur_name: &mut Option<String>,
cur_strokes: &mut Vec<Vec<[f32; 2]>>,
cur_max_x: &mut f32,
) {
let strokes = std::mem::take(cur_strokes);
let advance = *cur_max_x;
*cur_max_x = 0.0;
let glyph = CxfGlyph { strokes, advance };
if let Some(ch) = cur_char.take() {
file.glyphs.insert(ch, glyph);
} else if let Some(name) = cur_name.take() {
file.shapes.insert(name.to_ascii_uppercase(), glyph);
}
}
fn update_max_x(stroke: &[[f32; 2]], max_x: &mut f32) {
for &[x, _] in stroke {
if x > *max_x {
*max_x = x;
}
}
}
// ── Arc tessellation ──────────────────────────────────────────────────────
/// Parse `cx,cy,radius,start_deg,end_deg` and tessellate into a polyline.
/// `reversed = true` → CW sweep (AR), `false` → CCW (A).
fn parse_arc(data: &str, reversed: bool) -> Option<Vec<[f32; 2]>> {
let vals: Vec<f32> = data
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if vals.len() < 5 {
return None;
}
let (cx, cy, r) = (vals[0], vals[1], vals[2]);
let sa = vals[3].to_radians();
let ea = vals[4].to_radians();
if r <= 0.0 {
return None;
}
let sweep = if reversed {
let mut s = ea - sa;
if s > 0.0 {
s -= TAU;
}
if s.abs() < 1e-6 {
s = -TAU;
}
s
} else {
let mut s = ea - sa;
if s < 0.0 {
s += TAU;
}
if s.abs() < 1e-6 {
s = TAU;
}
s
};
let segs = ((sweep.abs() / TAU) * 64.0).ceil().clamp(4.0, 64.0) as u32;
let mut pts = Vec::with_capacity(segs as usize + 1);
for i in 0..=segs {
let t = sa + sweep * (i as f32 / segs as f32);
pts.push([cx + r * t.cos(), cy + r * t.sin()]);
}
Some(pts)
}
// ── Polyline (bulge) tessellation ─────────────────────────────────────────
/// Parse `(x, y, bulge)` triplets from a PL / PLC command.
fn parse_triplet_poly(data: &str, closed: bool) -> Option<Vec<[f32; 2]>> {
let vals: Vec<f32> = data
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
let n = vals.len();
if n < 6 || n % 3 != 0 {
return None;
}
let triplets: Vec<([f32; 2], f32)> = (0..n)
.step_by(3)
.map(|i| ([vals[i], vals[i + 1]], vals[i + 2]))
.collect();
let tc = triplets.len();
let mut pts: Vec<[f32; 2]> = Vec::with_capacity(tc * 6);
for (idx, &(start, bulge)) in triplets.iter().enumerate() {
let end = if idx + 1 < tc {
triplets[idx + 1].0
} else if closed && tc > 0 {
triplets[0].0
} else {
if pts.last() != Some(&start) {
pts.push(start);
}
break;
};
if pts.last() != Some(&start) {
pts.push(start);
}
if bulge.abs() < 1e-7 {
pts.push(end);
} else {
bulge_arc(&mut pts, start, end, bulge, 16);
}
}
if closed {
if let Some(&first) = pts.first() {
if pts.last() != Some(&first) {
pts.push(first);
}
}
}
Some(pts)
}
/// Approximate a bulge arc from `p0` to `p1`.
/// `bulge = tan(included_angle / 4)`: positive = CCW, negative = CW.
fn bulge_arc(out: &mut Vec<[f32; 2]>, p0: [f32; 2], p1: [f32; 2], bulge: f32, n: usize) {
let (x1, y1) = (p0[0], p0[1]);
let (x2, y2) = (p1[0], p1[1]);
let theta = 4.0 * bulge.atan();
let dx = x2 - x1;
let dy = y2 - y1;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-10 {
out.push(p1);
return;
}
let half_theta = theta * 0.5;
let r = (d * 0.5) / half_theta.sin().abs();
let px = -dy / d;
let py = dx / d;
let d_mc = r * half_theta.cos();
let sign = if bulge > 0.0 { 1.0_f32 } else { -1.0_f32 };
// Center lies to the LEFT of chord for CCW (bulge>0) and to the RIGHT for CW (bulge<0).
// px = -dy/d is the left-perpendicular unit vector of the chord.
// CCW (sign=+1): center = mid + perp * d_mc (left)
// CW (sign=-1): center = mid - perp * d_mc (right)
let cx = (x1 + x2) * 0.5 + sign * px * d_mc;
let cy = (y1 + y2) * 0.5 + sign * py * d_mc;
let a1 = (y1 - cy).atan2(x1 - cx);
let a2 = (y2 - cy).atan2(x2 - cx);
// With the corrected center, raw sweep (a2-a1) is already in the right
// range for most arcs. A single clamp is sufficient — no loop needed.
let mut sweep = a2 - a1;
if bulge > 0.0 {
// CCW: sweep must be in (0, TAU].
if sweep <= 0.0 {
sweep += TAU;
}
} else {
// CW: sweep must be in [-TAU, 0).
if sweep >= 0.0 {
sweep -= TAU;
}
}
if sweep.abs() < 1e-7 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
for i in 1..=n {
let t = i as f32 / n as f32;
let a = a1 + sweep * t;
out.push([cx + r * a.cos(), cy + r * a.sin()]);
}
}
+667
View File
@@ -0,0 +1,667 @@
// LibreCAD LFF stroke-font engine.
//
// Parses LibreCAD Font Format (`.lff`) files and tessellates text into
// world-space 2-D polyline strokes. Replaces the former QCAD CXF engine.
//
// LFF layout:
// - `# Key: value` headers (Name / LetterSpacing / WordSpacing /
// LineSpacingFactor).
// - Glyph blocks `[<hex>] <char>` followed by stroke lines. Each stroke
// line is a `;`-separated polyline of `x,y` vertices; a vertex written
// `x,y,A<bulge>` makes the segment to the NEXT vertex a bulge arc
// (DXF bulge = tan(included_angle / 4)).
// - A line `C<hex>` inside a glyph includes another glyph's strokes
// (used to compose accented characters).
//
// Cap height is 9 glyph units, matching the `height / 9.0` text scale used
// throughout the renderer.
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
// ── Embedded fonts ─────────────────────────────────────────────────────────
/// Every LibreCAD LFF font, keyed by its file stem (lower-case). Registered
/// under the upper-cased stem and the font's `# Name:` header.
const FONTS_SRC: &[(&str, &str)] = &[
("amiri-regular", include_str!("../../assets/fonts/amiri-regular.lff")),
("azomix_i", include_str!("../../assets/fonts/azomix_i.lff")),
("azomix", include_str!("../../assets/fonts/azomix.lff")),
("cursive", include_str!("../../assets/fonts/cursive.lff")),
("cyrillic_ii", include_str!("../../assets/fonts/cyrillic_ii.lff")),
("gothgbt", include_str!("../../assets/fonts/gothgbt.lff")),
("gothgrt", include_str!("../../assets/fonts/gothgrt.lff")),
("gothitt", include_str!("../../assets/fonts/gothitt.lff")),
("greekc", include_str!("../../assets/fonts/greekc.lff")),
("greekcs", include_str!("../../assets/fonts/greekcs.lff")),
("greek_ol", include_str!("../../assets/fonts/greek_ol.lff")),
("greekp", include_str!("../../assets/fonts/greekp.lff")),
("greeks", include_str!("../../assets/fonts/greeks.lff")),
("iso3098_i", include_str!("../../assets/fonts/iso3098_i.lff")),
("iso3098", include_str!("../../assets/fonts/iso3098.lff")),
("iso", include_str!("../../assets/fonts/iso.lff")),
("italicc", include_str!("../../assets/fonts/italicc.lff")),
("italiccs", include_str!("../../assets/fonts/italiccs.lff")),
("italict", include_str!("../../assets/fonts/italict.lff")),
("kochigothic", include_str!("../../assets/fonts/kochigothic.lff")),
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
("kst32b", include_str!("../../assets/fonts/kst32b.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")),
("opengosttypeb-regular", include_str!("../../assets/fonts/opengosttypeb-regular.lff")),
("romanc", include_str!("../../assets/fonts/romanc.lff")),
("romancs", include_str!("../../assets/fonts/romancs.lff")),
("romand", include_str!("../../assets/fonts/romand.lff")),
("romanp", include_str!("../../assets/fonts/romanp.lff")),
("romansi", include_str!("../../assets/fonts/romansi.lff")),
("romans", include_str!("../../assets/fonts/romans.lff")),
("romant", include_str!("../../assets/fonts/romant.lff")),
("scriptc", include_str!("../../assets/fonts/scriptc.lff")),
("scripts", include_str!("../../assets/fonts/scripts.lff")),
("simplex", include_str!("../../assets/fonts/simplex.lff")),
("standard", include_str!("../../assets/fonts/standard.lff")),
("syastro", include_str!("../../assets/fonts/syastro.lff")),
("symap", include_str!("../../assets/fonts/symap.lff")),
("symath", include_str!("../../assets/fonts/symath.lff")),
("symbol", include_str!("../../assets/fonts/symbol.lff")),
("symbol_misc1", include_str!("../../assets/fonts/symbol_misc1.lff")),
("symbol_misc2", include_str!("../../assets/fonts/symbol_misc2.lff")),
("symeteo", include_str!("../../assets/fonts/symeteo.lff")),
("symusic", include_str!("../../assets/fonts/symusic.lff")),
("unicode", include_str!("../../assets/fonts/unicode.lff")),
];
/// AutoCAD / DXF SHX font names → LFF stem. Names that already match a stem
/// (romans, italicc, scripts, symap, …) resolve directly and need no entry.
const ALIASES: &[(&str, &str)] = &[
// AutoCAD/DXF SHX font names → the nearest LFF stem. Names that already
// match a stem (romans, italicc, scripts, iso, symap, …) resolve directly
// and need no entry. Unknown names fall back to `standard` (as LibreCAD's
// `requestFont` does).
("TXT", "standard"),
("MONOTXT", "standard"),
("ISOCP", "iso"),
("ISOCP2", "iso"),
("ISOCP3", "iso"),
("ISOCPEUR", "iso"),
("ISOCT", "iso3098"),
("ISOCT2", "iso3098"),
("ISOCT3", "iso3098"),
("ISOCTEUR", "iso3098"),
("COMPLEX", "romanc"),
("ITALIC", "italicc"),
("GOTHICE", "gothgbt"),
("GOTHICG", "gothgrt"),
("GOTHICI", "gothitt"),
("CYRILLIC", "cyrillic_ii"),
("CYRILTLC", "cyrillic_ii"),
("GREEK", "greekc"),
("GOST", "opengosttypeb-regular"),
("GOSTA", "opengosttypea-regular"),
("GOSTB", "opengosttypeb-regular"),
("BIGFONT", "unicode"),
("EXTFONT", "unicode"),
];
// ── Public types ──────────────────────────────────────────────────────────
/// One glyph: a list of open 2-D polyline strokes in glyph units.
#[derive(Clone, Default)]
pub struct Glyph {
pub strokes: Vec<Vec<[f32; 2]>>,
/// Advance width in glyph units (rightmost X of all strokes).
pub advance: f32,
}
/// A parsed LFF font.
#[derive(Clone)]
pub struct Font {
pub name: String,
pub letter_spacing: f32,
pub word_spacing: f32,
pub line_spacing: f32,
glyphs: HashMap<char, Glyph>,
}
impl Font {
/// Look up a glyph by Unicode character.
pub fn glyph(&self, c: char) -> Option<&Glyph> {
self.glyphs.get(&c)
}
}
// ── Registry ───────────────────────────────────────────────────────────────
static FONTS: OnceLock<HashMap<String, Font>> = OnceLock::new();
static WARNED_GLYPHS: OnceLock<Mutex<HashSet<(String, char)>>> = OnceLock::new();
fn warn_missing_glyph(font_name: &str, ch: char) {
if ch.is_ascii() {
return;
}
let set = WARNED_GLYPHS.get_or_init(|| Mutex::new(HashSet::new()));
if let Ok(mut guard) = set.lock() {
if guard.insert((font_name.to_string(), ch)) {
eprintln!(
"lff: glyph U+{:04X} ('{}') not found in font '{font_name}'",
ch as u32, ch
);
}
}
}
fn fonts_map() -> &'static HashMap<String, Font> {
FONTS.get_or_init(|| {
let mut map = HashMap::new();
// Register every font under its stem and its `# Name:` header.
for (stem, src) in FONTS_SRC {
let f = parse_lff(src);
map.insert(stem.to_ascii_uppercase(), f.clone());
map.entry(f.name.to_ascii_uppercase()).or_insert_with(|| f.clone());
}
// AutoCAD/DXF SHX names → the matching LFF stem.
for (alias, stem) in ALIASES {
if let Some(f) = map.get(&stem.to_ascii_uppercase()).cloned() {
map.insert(alias.to_ascii_uppercase(), f);
}
}
map
})
}
/// Broad-coverage fallback font for glyphs missing from the selected family.
fn unicode_font() -> &'static Font {
let map = fonts_map();
map.get("UNICODE")
.or_else(|| map.get("STANDARD"))
.or_else(|| map.values().next())
.expect("at least one LFF font must be embedded")
}
/// 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 {
let map = fonts_map();
let key = name.trim().to_ascii_uppercase();
map.get(&key)
.or_else(|| {
// Strip a trailing ".SHX" / extension and retry.
key.rsplit_once('.').and_then(|(stem, _)| map.get(stem))
})
// LibreCAD's `requestFont` falls back to `standard` for unknown names.
.or_else(|| map.get("STANDARD"))
.or_else(|| map.get("UNICODE"))
.or_else(|| map.values().next())
.expect("at least one LFF font must be embedded")
}
// ── Text tessellation ───────────────────────────────────────────────────────
/// Tessellate a text run into world-space 2-D strokes. Same semantics as the
/// former CXF engine; the `tracking` multiplier scales `letter_spacing`.
pub fn tessellate_text_ex(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
tessellate_text_run(
origin,
height,
rotation,
width_factor,
oblique_angle,
1.0,
font_name,
text,
)
}
#[allow(clippy::too_many_arguments)]
pub fn tessellate_text_run(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
}
let font = get_font(font_name);
let scale = height / 9.0;
let wf = if width_factor < 0.0 {
width_factor.clamp(-100.0, -0.01)
} else {
width_factor.clamp(0.01, 100.0)
};
let ob = oblique_angle.tan();
let (cos_r, sin_r) = (rotation.cos(), rotation.sin());
let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] {
let sx = (cx + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
[
origin[0] + sx * cos_r - sy * sin_r,
origin[1] + sx * sin_r + sy * cos_r,
]
};
let mut out: Vec<Vec<[f32; 2]>> = Vec::new();
let mut cursor_x: f32 = 0.0;
let mut underline: Option<f32> = None;
let mut overline: Option<f32> = None;
let mut strikethrough: Option<f32> = None;
const UNDER_Y: f32 = -1.5;
const OVER_Y: f32 = 10.5;
const STRIKE_Y: f32 = 4.5;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.peek().copied() {
Some('L') => {
chars.next();
if underline.is_none() {
underline = Some(cursor_x);
}
continue;
}
Some('l') => {
chars.next();
if let Some(s) = underline.take() {
out.push(vec![xform(s, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
}
continue;
}
Some('O') => {
chars.next();
if overline.is_none() {
overline = Some(cursor_x);
}
continue;
}
Some('o') => {
chars.next();
if let Some(s) = overline.take() {
out.push(vec![xform(s, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
}
continue;
}
Some('K') => {
chars.next();
if strikethrough.is_none() {
strikethrough = Some(cursor_x);
}
continue;
}
Some('k') => {
chars.next();
if let Some(s) = strikethrough.take() {
out.push(vec![
xform(s, STRIKE_Y, 0.0),
xform(cursor_x, STRIKE_Y, 0.0),
]);
}
continue;
}
_ => {}
}
}
// DXF %%x special-character sequences.
let render_ch: char = if ch == '%' && chars.peek() == Some(&'%') {
chars.next();
match chars.peek().map(|c| c.to_ascii_lowercase()) {
Some('d') => {
chars.next();
'°'
}
Some('p') => {
chars.next();
'±'
}
Some('c') => {
chars.next();
'⌀'
}
Some('%') => {
chars.next();
'%'
}
Some('u') => {
chars.next();
underline = match underline.take() {
Some(start) => {
out.push(vec![
xform(start, UNDER_Y, 0.0),
xform(cursor_x, UNDER_Y, 0.0),
]);
None
}
None => Some(cursor_x),
};
continue;
}
Some('o') => {
chars.next();
overline = match overline.take() {
Some(start) => {
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
None
}
None => Some(cursor_x),
};
continue;
}
Some(d) if d.is_ascii_digit() => {
let mut digits = String::with_capacity(3);
for _ in 0..3 {
match chars.peek() {
Some(&c) if c.is_ascii_digit() => digits.push(chars.next().unwrap()),
_ => break,
}
}
if digits.len() == 3 {
match digits.parse::<u32>().ok().and_then(char::from_u32) {
Some(c) => c,
None => continue,
}
} else {
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
continue;
}
}
_ => continue,
}
} else {
ch
};
if render_ch == ' ' {
cursor_x += font.word_spacing;
continue;
}
// Selected family first, then the broad Unicode fallback.
let glyph = font
.glyph(render_ch)
.or_else(|| unicode_font().glyph(render_ch));
match glyph {
Some(glyph) => {
for stroke in &glyph.strokes {
if stroke.len() < 2 {
continue;
}
out.push(stroke.iter().map(|&[gx, gy]| xform(gx, gy, cursor_x)).collect());
}
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, render_ch);
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
}
}
}
if let Some(start) = underline {
out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
}
if let Some(start) = overline {
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
}
if let Some(start) = strikethrough {
out.push(vec![xform(start, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]);
}
out
}
// ── Parser ────────────────────────────────────────────────────────────────
/// Intermediate glyph carrying unresolved `C<hex>` references.
#[derive(Default, Clone)]
struct RawGlyph {
strokes: Vec<Vec<[f32; 2]>>,
refs: Vec<char>,
}
fn parse_lff(src: &str) -> Font {
let mut font = Font {
name: String::from("Unknown"),
letter_spacing: 3.0,
word_spacing: 6.75,
line_spacing: 1.0,
glyphs: HashMap::new(),
};
let mut raw: HashMap<char, RawGlyph> = HashMap::new();
let mut cur: Option<char> = 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();
}
};
for line in src.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(rest) = t.strip_prefix('#') {
if let Some(v) = rest.trim().strip_prefix("Name:") {
font.name = v.trim().to_string();
} else if let Some(v) = rest.trim().strip_prefix("LetterSpacing:") {
if let Ok(x) = v.trim().parse() {
font.letter_spacing = x;
}
} else if let Some(v) = rest.trim().strip_prefix("WordSpacing:") {
if let Ok(x) = v.trim().parse() {
font.word_spacing = x;
}
} else if let Some(v) = rest.trim().strip_prefix("LineSpacingFactor:") {
if let Ok(x) = v.trim().parse() {
font.line_spacing = x;
}
}
continue;
}
if t.starts_with('[') {
flush(&mut cur, &mut cur_glyph, &mut raw);
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);
}
}
continue;
}
if cur.is_none() {
continue;
}
// `C<hex>` — reference another glyph's strokes.
if let Some(hex) = t.strip_prefix(['C', 'c']) {
if hex.chars().all(|c| c.is_ascii_hexdigit()) && !hex.is_empty() {
if let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
cur_glyph.refs.push(c);
}
continue;
}
}
// Stroke polyline: `x,y;x,y;x,y,A<bulge>;…`
if let Some(stroke) = parse_stroke_line(t) {
if stroke.len() >= 2 {
cur_glyph.strokes.push(stroke);
}
}
}
flush(&mut cur, &mut cur_glyph, &mut raw);
// Resolve `C<hex>` references. Each pass folds in targets that are
// themselves already reference-free; repeat so ref-to-ref chains settle.
for _ in 0..4 {
let keys: Vec<char> = raw.keys().copied().collect();
let mut changed = false;
for k in keys {
let refs = raw.get(&k).map(|g| g.refs.clone()).unwrap_or_default();
if refs.is_empty() {
continue;
}
let mut add: Vec<Vec<[f32; 2]>> = Vec::new();
let mut remaining: Vec<char> = Vec::new();
for r in refs {
match raw.get(&r) {
Some(rg) if rg.refs.is_empty() => add.extend(rg.strokes.iter().cloned()),
_ => remaining.push(r),
}
}
if let Some(g) = raw.get_mut(&k) {
g.strokes.extend(add);
g.refs = remaining;
changed = true;
}
}
if !changed {
break;
}
}
for (c, g) in raw {
let advance = g
.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,
},
);
}
font
}
/// Parse one LFF stroke line into a polyline, tessellating bulge arcs.
///
/// A vertex's `A<bulge>` describes the arc from the PREVIOUS vertex to this
/// one (LibreCAD's `RS_Polyline::addVertex(v, bulge)` convention), so the
/// bulge curves the segment ending at the vertex that carries it.
fn parse_stroke_line(line: &str) -> Option<Vec<[f32; 2]>> {
let mut pts: Vec<[f32; 2]> = Vec::new();
for tok in line.split(';').filter(|s| !s.trim().is_empty()) {
let parts: Vec<&str> = tok.split(',').map(|s| s.trim()).collect();
if parts.len() < 2 {
continue;
}
let x: f32 = parts[0].parse().ok()?;
let y: f32 = parts[1].parse().ok()?;
let p = [x, y];
let bulge = if parts.len() >= 3 {
parts[2]
.strip_prefix(['A', 'a'])
.and_then(|b| b.parse::<f32>().ok())
.unwrap_or(0.0)
} else {
0.0
};
if pts.is_empty() || bulge.abs() < 1e-6 {
pts.push(p);
} else {
let from = *pts.last().unwrap();
for q in bulge_to_points(from, p, bulge) {
pts.push(q);
}
}
}
if pts.is_empty() {
None
} else {
Some(pts)
}
}
/// Tessellate a DXF bulge arc from `p0` to `p1`. Returns the intermediate
/// points plus the end point (the start point is already in the polyline).
fn bulge_to_points(p0: [f32; 2], p1: [f32; 2], bulge: f32) -> Vec<[f32; 2]> {
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let chord = (dx * dx + dy * dy).sqrt();
if bulge.abs() < 1e-6 || chord < 1e-9 {
return vec![p1];
}
let theta = 4.0 * bulge.atan(); // signed included angle
let radius = (chord * 0.5) / (theta * 0.5).sin(); // signed
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
let ux = dx / chord;
let uy = dy / chord;
// Left normal of the chord.
let nx = -uy;
let ny = ux;
let off = radius * (theta * 0.5).cos();
let cx = mx + nx * off;
let cy = my + ny * off;
let r = radius.abs();
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
let n = ((theta.abs() / (std::f32::consts::FRAC_PI_8)).ceil() as usize).clamp(2, 64);
let mut pts = Vec::with_capacity(n);
for i in 1..=n {
let a = a0 + theta * (i as f32 / n as f32);
pts.push([cx + r * a.cos(), cy + r * a.sin()]);
}
pts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fonts_parse_and_resolve() {
// Straight-stroke glyph.
let a = get_font("Standard").glyph('A').expect("A glyph");
assert!(!a.strokes.is_empty() && a.advance > 0.0);
// Bulge-arc glyph tessellates into a multi-point polyline.
let zero = get_font("Standard").glyph('0').expect("0 glyph");
assert!(zero.strokes.iter().any(|s| s.len() > 4));
// Alias + fallback resolve to a real font.
assert!(get_font("txt").glyph('A').is_some());
assert!(get_font("RomanS").glyph('B').is_some());
// Unicode fallback covers a non-ASCII letter via the renderer path.
let strokes = tessellate_text_run([0.0, 0.0], 2.5, 0.0, 1.0, 0.0, 1.0, "Standard", "Aб");
assert!(!strokes.is_empty());
// The bulge belongs to the segment ENDING at the vertex (LibreCAD
// convention): the standard/iso/unicode 'O' must come out as an
// upright oval (taller than wide), not a sideways capsule.
for name in ["standard", "iso", "unicode", "txt"] {
let o = get_font(name).glyph('O').expect("O glyph");
let (mut minx, mut miny, mut maxx, mut maxy) =
(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for s in &o.strokes {
for &[x, y] in s {
minx = minx.min(x);
miny = miny.min(y);
maxx = maxx.max(x);
maxy = maxy.max(y);
}
}
let (w, h) = (maxx - minx, maxy - miny);
assert!(h > w, "{name} O should be upright (h {h:.1} > w {w:.1})");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ pub mod acad_to_truck;
pub mod block_cache;
mod camera;
pub mod complex_lt;
pub mod cxf;
pub mod lff;
pub mod dispatch;
pub mod grip;
pub mod hatch_model;
+20 -20
View File
@@ -60,26 +60,26 @@ const LIST: Color = Color {
};
const BUILTIN_FONTS: &[&str] = &[
"CourierCad.cxf",
"Cursive.cxf",
"GothGBT.cxf",
"GothGRT.cxf",
"GothITT.cxf",
"GreekC.cxf",
"GreekS.cxf",
"ItalicC.cxf",
"ItalicT.cxf",
"RomanC.cxf",
"RomanD.cxf",
"RomanS.cxf",
"RomanT.cxf",
"SansND.cxf",
"SansNS.cxf",
"ScriptC.cxf",
"ScriptS.cxf",
"Standard.cxf",
"Unicode.cxf",
"SymbolCad.cxf",
"Standard",
"ISO",
"Simplex",
"RomanS",
"RomanD",
"RomanC",
"RomanT",
"ItalicC",
"ItalicT",
"ScriptS",
"ScriptC",
"GothGBT",
"GothGRT",
"GothITT",
"Cursive",
"GreekC",
"Symbol",
"ISO",
"ISO3098",
"Unicode",
];
fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {