From 82be706b94929b4e465cd4fb4d201cf5d66c12e1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 01:21:31 +0700 Subject: [PATCH] Fix #177: DXF Fit and Aligned text span their two points DXF text justified Fit now keeps its height and stretches or squeezes horizontally to run exactly from its first point to its second; Aligned text scales as a whole, height included, to do the same, its width factor kept. Attributes justified the same way follow suit. Without a second point, or with both points the same, the text keeps its size as before. Root cause: the importer took the angle of the two points and anchored the text at the first, but kept the text's height and width factor, ignoring the distance between the points. The text now records that span and, when it becomes a text object, is measured in the font it is set in, then given the horizontal scale (Fit) or size (Aligned) that makes it end at the second point, kept between 1% and 10,000% of its natural length. The import's bounds estimate uses the same scale. Co-Authored-By: Claude Opus 5.5 --- crates/cad/src/import/build.rs | 29 +++++- crates/cad/src/import/entity.rs | 40 +++++++- crates/cad/src/import/tests.rs | 161 ++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 5 deletions(-) diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index dd608545..449b95f7 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAY use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use super::ImportOptions; -use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem}; +use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem}; use super::reader::{Drawing, LayoutDef}; use crate::{CAP_HEIGHT, MAX_NEST}; @@ -244,14 +244,18 @@ impl<'d, 'a> Build<'d, 'a> { Geom::Text(t) => { let lines = t.text.lines().count().max(1) as f64; let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64; - let w = longest * t.height * 0.9 * t.width; + let natural = longest * t.height * 0.9 * t.width; + // Fit and aligned text run their span; aligned text's height scales with it. + let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0); + let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height }; + let w = natural * k; let x0 = match t.justify { vectorcraft_doc::Justify::Center => -w / 2.0, vectorcraft_doc::Justify::Right => -w, _ => 0.0, }; - let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3; - Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height))) + let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3; + Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height))) } Geom::Insert(ins) => { let b = self.block_bounds(&ins.block, depth); @@ -502,6 +506,9 @@ impl<'d, 'a> Build<'d, 'a> { let mut obj = TextObject::point(Point::ZERO, &t.text, style); obj.xf = m * Affine::scale(1.0 / s); obj.para.justify = t.justify; + if let Some(span) = t.span { + fill_span(&mut obj, span, s); + } Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj)))) } @@ -581,6 +588,20 @@ impl<'d, 'a> Build<'d, 'a> { } } +/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span +/// (`s`: points per drawing unit), measured in the font it is set in. +fn fill_span(obj: &mut TextObject, span: Span, s: f64) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj); + let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max); + let Some(k) = span.scale(natural / s) else { return }; + for r in &mut obj.runs { + match span { + Span::Fit(_) => r.style.h_scale *= k, + Span::Aligned(_) => r.style.size *= k, + } + } +} + fn rgb_color([r, g, b]: [u8; 3]) -> Color { Color::rgb8(r, g, b) } diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index fe5b95f0..cebb222d 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -19,6 +19,9 @@ const LINE_SPACING: f64 = 5.0 / 3.0; const MAX_CELLS: i64 = 10_000; /// Art farther than this from the origin (drawing units) is damaged data, left out. const MAX_COORD: f64 = 1e12; +/// How far fit and aligned text may be squeezed or stretched to span their two points: at width +/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows. +const SPAN_SCALE: (f64, f64) = (0.01, 100.0); /// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. pub(crate) fn aci_paper(i: u64) -> [u8; 3] { @@ -121,6 +124,27 @@ pub(crate) struct TextItem { pub family: Option, /// Baseline-to-baseline distance (drawing units), for several lines. pub leading: Option, + /// The distance aligned and fit text run along their baseline. + pub span: Option, +} + +/// How text fills the distance between its two points (drawing units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Span { + /// Stretched or squeezed across, its height kept. + Fit(f64), + /// Scaled as a whole, height included. + Aligned(f64), +} + +impl Span { + /// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when + /// either length is degenerate). + pub fn scale(self, natural: f64) -> Option { + let (Self::Fit(d) | Self::Aligned(d)) = self; + let k = d / natural; + (k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1)) + } } /// A block reference: one transform per copy (an array insert makes several), each from the @@ -608,10 +632,13 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); let (p1, p2) = (g.point(10), g.point_opt(11)); let mut angle = g.num_or(50, 0.0).to_radians(); + let mut span = None; // Aligned and fit text run from the first point to the second. let anchor = match (h, p2) { (3 | 5, Some(p)) => { angle = (p - p1).atan2(); + let d = p1.distance(p); + span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) }); p1 } (0, _) if v == 0 => p1, @@ -636,6 +663,7 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family: None, leading: None, + span, }) } @@ -662,5 +690,15 @@ fn mtext(g: &[Pair<'_>]) -> Option { let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); - Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) }) + Some(TextItem { + text: s, + height, + xf, + width: 1.0, + justify, + style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + family, + leading: Some(leading), + span: None, + }) } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index a2ed7633..0e69cb54 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -397,6 +397,167 @@ fn text_and_multiline_text() { assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}"); } +/// Where the first line's baseline starts and ends, in document coordinates. +fn baseline_ends(t: &TextObject) -> (Point, Point) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &layout.lines[0]; + (t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline)) +} + +#[test] +fn fit_and_aligned_text_span_their_two_points() { + // Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same + // text left-justified for its natural size. + let e = [ + (0, "LINE"), + (62, "1"), + (10, "10"), + (20, "0"), + (11, "10"), + (21, "60"), + (0, "LINE"), + (62, "1"), + (10, "90"), + (20, "0"), + (11, "90"), + (21, "60"), + (0, "TEXT"), + (10, "10"), + (20, "40"), + (40, "5"), + (1, "FIT BETWEEN"), + (72, "5"), + (11, "90"), + (21, "40"), + (0, "TEXT"), + (10, "10"), + (20, "10"), + (40, "5"), + (41, "0.8"), + (1, "ALIGNED"), + (72, "3"), + (11, "90"), + (21, "10"), + (0, "TEXT"), + (10, "10"), + (20, "25"), + (40, "5"), + (1, "FIT BETWEEN"), + (0, "TEXT"), + (10, "10"), + (20, "50"), + (40, "5"), + (41, "0.8"), + (1, "ALIGNED"), + ]; + let o = ImportOptions { center: false, ..ImportOptions::default() }; + let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document; + let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() }; + let (x10, x90) = (bounds(left).center().x, bounds(right).center().x); + for n in [fit, aligned] { + let (a, b) = baseline_ends(text_of(n)); + assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}"); + assert!((a.y - b.y).abs() < 1e-9, "level"); + } + // Fit keeps its height and stretches across. + let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style()); + assert_eq!(st.size, natural.size); + assert!(st.h_scale > 100.0, "{}", st.h_scale); + // Aligned grows as a whole, its width factor kept. + let (a, b) = baseline_ends(text_of(aligned_natural)); + let k = (x90 - x10) / (b.x - a.x); + let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style()); + assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size); + assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9); +} + +#[test] +fn fit_and_aligned_text_without_a_second_point_keep_their_size() { + for h in ["3", "5"] { + let e = [ + (0, "TEXT"), + (10, "10"), + (20, "10"), + (40, "7"), + (1, "Gap"), + (72, h), + (0, "TEXT"), + (10, "10"), + (20, "30"), + (40, "7"), + (1, "Gap"), + (72, h), + (11, "10"), + (21, "30"), + ]; + let doc = open(&entities(&e), &points()).document; + let top = height(&doc); + for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) { + let t = text_of(n); + let st = t.first_style(); + assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale); + assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9); + } + } +} + +#[test] +fn fit_and_aligned_text_scale_within_limits() { + // A span a billion units long and one a millionth of a unit long. + let e = [ + (0, "TEXT"), + (10, "0"), + (20, "0"), + (40, "7"), + (1, "X"), + (72, "5"), + (11, "1e9"), + (21, "0"), + (0, "TEXT"), + (10, "0"), + (20, "10"), + (40, "7"), + (1, "X"), + (72, "3"), + (11, "1e-6"), + (21, "10"), + ]; + let doc = open(&entities(&e), &points()).document; + let [fit, aligned] = &leaves(&doc)[..] else { panic!() }; + let st = text_of(fit).first_style(); + assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale); + let st = text_of(aligned).first_style(); + assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale); +} + +#[test] +fn fit_attributes_span_their_two_points() { + let inserts = [ + (0, "INSERT"), + (66, "1"), + (2, "Door"), + (10, "0"), + (20, "0"), + (0, "ATTRIB"), + (10, "10"), + (20, "20"), + (40, "7"), + (1, "D-01"), + (2, "TAG"), + (70, "0"), + (72, "5"), + (74, "0"), + (11, "90"), + (21, "20"), + (0, "SEQEND"), + ]; + let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document; + let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap(); + let (a, b) = baseline_ends(text_of(&tag)); + assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}"); + assert_eq!(text_of(&tag).first_style().size, 10.0); +} + /// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`. fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec { let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];