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 <[email protected]>
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);