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")];