forked from 1b-agent/opencadstudio-upstream
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:
1 parent
32136dd9dd
commit
c1b33677e2
98 files changed
+886126
-47228
No files matched your search
+30
-34
@@ -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 ¶[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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user