diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 43d87f683..770e66bc2 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -101,7 +101,21 @@ fn mtext_flag_str(f: MTextFlag) -> &'static str { /// Render text strokes for an attribute, honouring alignment, oblique angle, /// width factor, generation flags (backward / upside-down), text-style /// resolution, and basic multiline splitting on `\n` / `\\P`. -fn build_attr_render(mut input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { +/// Laid-out character cells of a multi-line (MTEXT) attribute: the layout's +/// glyph boxes, the point they are relative to and the layout rotation. +pub(crate) type AttrCells = (Vec, [f64; 2], f32); + +fn build_attr_render(input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { + build_attr_layout(input, document, None) +} + +/// [`build_attr_render`], also handing back a multi-line attribute's +/// character cells in `cells` (left `None` for a single-line one). +fn build_attr_layout( + mut input: AttrTextInputs<'_>, + document: &codec::CadDocument, + mut cells: Option<&mut Option>, +) -> RenderEntity { let normal = (input.normal.x, input.normal.y, input.normal.z); let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( ( @@ -248,8 +262,11 @@ fn build_attr_render(mut input: AttrTextInputs<'_>, document: &codec::CadDocumen exact_line_spacing: false, rectangle_height: 0.0, vertical_text: false, - want_glyph_boxes: false, + want_glyph_boxes: cells.is_some(), }); + if let Some(out) = cells.as_deref_mut() { + *out = Some((layout.glyph_boxes.clone(), [anchor_pt.x, anchor_pt.y], rotation)); + } let _ = input.line_count; let _ = input.is_multiline; return RenderEntity { @@ -839,6 +856,35 @@ impl Transformable for AttributeDefinition { // ── AttributeEntity ─────────────────────────────────────────────────────────── +/// The laid-out character cells of a multi-line (MTEXT) attribute, the +/// point they are relative to and the layout rotation; `None` for a +/// single-line attribute. +pub(crate) fn attribute_cells(a: &AttributeEntity, document: &codec::CadDocument) -> Option { + let mut out = None; + build_attr_layout(attr_inputs(a), document, Some(&mut out)); + out +} + +fn attr_inputs(a: &AttributeEntity) -> AttrTextInputs<'_> { + AttrTextInputs { + value: &a.value, + insertion_point: a.insertion_point, + alignment_point: a.alignment_point, + height: a.height, + rotation: a.rotation, + width_factor: a.width_factor, + oblique_angle: a.oblique_angle, + text_style: &a.text_style, + text_generation_flags: a.text_generation_flags, + horizontal_alignment: a.horizontal_alignment, + vertical_alignment: a.vertical_alignment, + normal: a.normal, + mtext_flag: a.mtext_flag, + is_multiline: a.is_multiline, + line_count: a.line_count, + } +} + impl RenderConvertible for AttributeEntity { fn to_render(&self, document: &codec::CadDocument) -> Option { Some(build_attr_render( diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d8aef296d..2c1e36827 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -237,6 +237,42 @@ pub fn text_local_bounds( } } +/// Each character's [left, right] pen cell along a TEXT line (DXF specials +/// already resolved), measured as [`text_local_bounds`] lays the line out: +/// the shaper's glyph cells for a TrueType font or a script that needs +/// shaping, else the stroke font's advances. +pub fn text_char_cells(font_name: &str, text: &str, height: f32, width_factor: f32) -> Vec<(f32, f32)> { + let face = Face::resolve(font_name); + let scale = height / 9.0; + let wf = width_factor.abs().clamp(0.01, 100.0); + let shaping_family = face.ttf_family().or_else(|| { + crate::scene::text::web_font::requires_shaping(text) + .then(|| crate::scene::text::web_font::primary_script().family()) + }); + if let Some(run) = shaping_family.and_then(|family| crate::scene::text::ttf_glyph::shape_run(family, text)) { + return crate::scene::text::ttf_glyph::char_cells(&run, text) + .into_iter() + .map(|(x0, x1)| (x0 * scale * wf, x1 * scale * wf)) + .collect(); + } + let mut cursor = 0.0_f32; + text.chars() + .map(|ch| { + let advance = if ch == ' ' { + face.word_spacing() + } else { + match face.glyph(ch) { + Some(glyph) => glyph.advance + face.spacing_after(ch), + None => 6.0 + face.letter_spacing(), + } + }; + let cell = (cursor * scale * wf, (cursor + advance) * scale * wf); + cursor += advance; + cell + }) + .collect() +} + /// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values: /// - `%%d` / `%%D` → `°` /// - `%%p` / `%%P` → `±` @@ -998,6 +1034,34 @@ pub fn measure_word( w } +/// Per-character cells of a word laid out by the shaper — the visual +/// [left, right] of each character in logical order, from the word start, +/// in drawing units — when the word is shaped (a TrueType font or a script +/// that needs shaping), as [`measure_word`] measures it. A glyph that +/// covers several characters (a ligature) shares its cell among them. +pub fn word_cells( + text: &str, + state: &RunState, + entity_h: f32, + base_wf: f32, + base_font: &str, +) -> Option> { + let scale = run_scale(state, entity_h, base_wf); + let font_name = resolve_font(state, base_font); + let face = Face::resolve(&font_name); + let family = face.ttf_family().or_else(|| { + crate::scene::text::web_font::requires_shaping(text) + .then(|| crate::scene::text::web_font::primary_script().family()) + })?; + let run = crate::scene::text::ttf_glyph::shape_run(family, text)?; + Some( + crate::scene::text::ttf_glyph::char_cells(&run, text) + .into_iter() + .map(|(x0, x1)| (x0 * scale, x1 * scale)) + .collect(), + ) +} + 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); @@ -1356,6 +1420,10 @@ pub struct GlyphBox { pub ymin: f32, pub ymax: f32, pub is_rtl: bool, + /// The same cell unrotated, relative to the insertion point + /// [left, bottom, right, top]: exact for rotated text, where the world + /// box above is only the bounds of two corners. + pub local: [f32; 4], } /// Output of [`layout_mtext`]: stroke groups + the geometry the caller @@ -2098,6 +2166,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: sub.is_rtl, + local: [line_lx + cursor_start, line_ly, line_lx + cursor_start, line_ly + caret_h], }); vis += 1; } @@ -2324,25 +2393,41 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } else { measure_word(text, &atom.state, entity_h, base_wf, &base_font_name) }; + // The shaper's own glyph cells (visual positions, + // per character in logical order); evenly divided + // slots only when the word is not shaped. let slot_w = word_w / count as f32; - for ci in 0..count { - let cx = cursor_x + ci as f32 * slot_w; - let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); - let (bx, by) = to_world(line_base_x, line_base_y, cx + slot_w, ly + run_h); + let cells = word_cells(text, &atom.state, entity_h, base_wf, &base_font_name) + .unwrap_or_else(|| { + (0..count) + .map(|ci| { + let x = (count - 1 - ci) as f32 * slot_w; + (x, x + slot_w) + }) + .collect() + }); + for (ci, (x0, x1)) in cells.into_iter().enumerate() { + let (cx0, cx1) = (cursor_x + x0, cursor_x + x1); + let (ax, ay) = to_world(line_base_x, line_base_y, cx0, ly); + let (bx, by) = to_world(line_base_x, line_base_y, cx1, ly + run_h); glyph_boxes.push(GlyphBox { - vis: atom.char_offset + count - 1 - ci, + vis: atom.char_offset + ci, xmin: ax.min(bx), xmax: ax.max(bx), ymin: ay.min(by), ymax: ay.max(by), is_rtl: true, + local: [line_lx + cx0, line_ly + ly, line_lx + cx1, line_ly + ly + run_h], }); } } else { // Per-character boxes, advancing exactly as // `measure_word` does so they track the glyphs. + // A shaped word (TrueType or a complex script) + // takes the shaper's cells, as it draws. let scale = run_scale(&atom.state, entity_h, base_wf); let face = Face::resolve(&font_name); + let shaped = word_cells(text, &atom.state, entity_h, base_wf, &base_font_name); let mut cx = cursor_x; for (ci, ch) in text.chars().enumerate() { let adv = match face.glyph(ch) { @@ -2351,8 +2436,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } None => (6.0 + face.letter_spacing() * tracking) * scale, }; - let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); - let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h); + let (cx0, cx1) = match shaped.as_ref().and_then(|cells| cells.get(ci)) { + Some(&(x0, x1)) => (cursor_x + x0, cursor_x + x1), + None => (cx, cx + adv), + }; + let (ax, ay) = to_world(line_base_x, line_base_y, cx0, ly); + let (bx, by) = to_world(line_base_x, line_base_y, cx1, ly + run_h); glyph_boxes.push(GlyphBox { vis: atom.char_offset + ci, xmin: ax.min(bx), @@ -2360,6 +2449,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: false, + local: [line_lx + cx0, line_ly + ly, line_lx + cx1, line_ly + ly + run_h], }); cx += adv; } @@ -2487,6 +2577,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin, ymax, is_rtl: false, + local: [line_lx + x0, line_ly + valign_dy, line_lx + x1, line_ly + top], }); } } @@ -2603,6 +2694,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: false, + local: [line_lx + cursor_x, line_ly, line_lx + cursor_x + adv, line_ly + run_h], }); } cursor_x += adv; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index a6ae7f710..d7afd0298 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -85,12 +85,14 @@ const FIELD_BOX_BELOW: f64 = 0.345; const FIELD_BOX_ABOVE: f64 = 1.19; /// FIELDDISPLAY boxes: each field's shown text, one box per line it spans, -/// placed from the text layout itself — MTEXT's per-character layout cells -/// (the editor's glyph boxes) and TEXT / attribute pen advances, the same -/// layout and shaping the glyphs are drawn with — so formatting codes, -/// missing glyphs or shaped scripts before a field do not shift it. A box -/// spans the field's character cells horizontally and FIELD_BOX_BELOW / -/// FIELD_BOX_ABOVE vertically. `None` when the host's fields cannot be +/// placed from the text layout itself, in the text's own (rotated) frame — +/// MTEXT and multi-line attributes from the layout's character cells, TEXT +/// and single-line attributes from each line's character cells (the +/// shaper's glyph cells for TrueType fonts and shaped scripts, else the +/// stroke font's advances) — so formatting codes, missing glyphs, shaped or +/// right-to-left scripts before a field do not shift it. A box spans the +/// field's character cells along the line and FIELD_BOX_BELOW / +/// FIELD_BOX_ABOVE across it. `None` when the host's fields cannot be /// located in its text (the whole text is boxed then). fn field_run_corner_groups( document: &codec::CadDocument, @@ -98,44 +100,61 @@ fn field_run_corner_groups( stroke_groups: &[crate::scene::convert::acad_to_render::TextStroke], anno: f64, ) -> Option> { - use crate::entities::text_support::{mtext_visible_count, resolve_dxf_special_chars, text_local_bounds}; + use crate::entities::text_support::{ + mtext_visible_count, resolve_dxf_special_chars, resolve_text_style, text_char_cells, GlyphBox, + }; let handle = entity.common().handle; - // Lines of a field in a local frame: [left, baseline, right, height]. - let mut lines: Vec<[f64; 4]> = Vec::new(); - let (origin, rotation) = match entity { + // Field lines as (origin, rotation, [left, baseline, right, height]) in + // the origin's frame. + let mut lines: Vec<([f64; 2], f64, [f64; 4])> = Vec::new(); + // Group a field's layout cells by line (a shared baseline). + let from_cells = |lines: &mut Vec<([f64; 2], f64, [f64; 4])>, + cells: &[GlyphBox], + text: &str, + spans: &[std::ops::Range], + origin: [f64; 2], + rotation: f64| + -> Option<()> { + for span in spans { + let first = mtext_visible_count(text.get(..span.start)?); + let last = mtext_visible_count(text.get(..span.end)?); + let mut line: Option<[f64; 4]> = None; + for b in cells.iter().filter(|b| (first..last).contains(&b.vis)) { + let [l, base, r, top] = b.local.map(|v| v as f64 * anno); + let h = top - base; + line = Some(match line { + Some(c) if (c[1] - base).abs() <= 1e-3 * h.max(c[3]) => { + [c[0].min(l), c[1], c[2].max(r), c[3].max(h)] + } + Some(c) => { + lines.push((origin, rotation, c)); + [l, base, r, h] + } + None => [l, base, r, h], + }); + } + lines.extend(line.map(|c| (origin, rotation, c))); + } + Some(()) + }; + match entity { EntityType::MText(m) => { let spans = codec::fields::field_spans(document, handle, &m.value)?; - let mut local = m.clone(); - local.insertion_point = codec::types::Vector3::new(0.0, 0.0, 0.0); - local.rotation = 0.0; - let boxes = crate::entities::mtext::glyph_boxes(&local, document); - for span in spans { - let first = mtext_visible_count(m.value.get(..span.start)?); - let last = mtext_visible_count(m.value.get(..span.end)?); - let mut line: Option<[f64; 4]> = None; - for b in boxes.iter().filter(|b| (first..last).contains(&b.vis)) { - let (l, base, r, h) = (b.xmin as f64, b.ymin as f64, b.xmax as f64, (b.ymax - b.ymin) as f64); - line = Some(match line { - Some(c) if (c[1] - base).abs() <= 1e-3 * h.max(c[3]) => { - [c[0].min(l), c[1], c[2].max(r), c[3].max(h)] - } - Some(c) => { - lines.push(c); - [l, base, r, h] - } - None => [l, base, r, h], - }); - } - lines.extend(line); - } - let rotation = stroke_groups - .iter() - .find_map(|g| g.run.as_ref().map(|run| run.rotation as f64)) - .unwrap_or(m.rotation); - for line in &mut lines { - *line = line.map(|v| v * anno); - } - ([m.insertion_point.x, m.insertion_point.y], rotation) + let cells = crate::entities::mtext::glyph_boxes(m, document); + let flip = if resolve_text_style(&m.style, document).is_upside_down { std::f64::consts::PI } else { 0.0 }; + from_cells( + &mut lines, + &cells, + &m.value, + &spans, + [m.insertion_point.x, m.insertion_point.y], + m.rotation + flip, + )?; + } + EntityType::AttributeEntity(a) if crate::entities::attribute::attribute_cells(a, document).is_some() => { + let spans = codec::fields::field_spans(document, handle, &a.value)?; + let (cells, origin, rotation) = crate::entities::attribute::attribute_cells(a, document)?; + from_cells(&mut lines, &cells, &a.value, &spans, origin, rotation as f64)?; } EntityType::Text(_) | EntityType::AttributeEntity(_) => { let text = match entity { @@ -144,28 +163,38 @@ fn field_run_corner_groups( _ => return None, }; let spans = codec::fields::field_spans(document, handle, text)?; - // One laid-out line: a multi-line attribute keeps the whole box. - let [group] = stroke_groups else { return None }; - let run = group.run.as_ref()?; - let advance = |s: &str| { - text_local_bounds(&run.font, &resolve_dxf_special_chars(s), run.height, run.width_factor, run.oblique) - .map_or(0.0, |b| b.advance as f64) - }; - for span in spans { - let (l, r) = (advance(text.get(..span.start)?), advance(text.get(..span.end)?)); - lines.push([l * anno, 0.0, r * anno, run.height as f64 * anno]); + // One laid-out line per `\P`-separated line, in order. + let mut start = 0; + for (line_text, group) in text.split("\\P").zip(stroke_groups) { + let range = start..start + line_text.len(); + start = range.end + 2; + let run = group.run.as_ref()?; + let resolved = resolve_dxf_special_chars(line_text); + let cells = text_char_cells(&run.font, &resolved, run.height, run.width_factor); + let char_at = |byte: usize| resolve_dxf_special_chars(&line_text[..byte]).chars().count(); + for span in &spans { + let (a, b) = (span.start.max(range.start), span.end.min(range.end)); + if a >= b { + continue; + } + let picked = cells.get(char_at(a - range.start)..char_at(b - range.start)).unwrap_or_default(); + let l = picked.iter().map(|c| c.0).fold(f32::INFINITY, f32::min) as f64; + let r = picked.iter().map(|c| c.1).fold(f32::NEG_INFINITY, f32::max) as f64; + if l.is_finite() && r.is_finite() { + lines.push((group.origin, run.rotation as f64, [l * anno, 0.0, r * anno, run.height as f64 * anno])); + } + } } - (group.origin, run.rotation as f64) } _ => return None, - }; - let (sin_r, cos_r) = rotation.sin_cos(); - let to_world = |x: f64, y: f64| [origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r]; + } Some( lines .into_iter() - .filter(|[l, _, r, _]| r > l) - .map(|[l, base, r, h]| { + .filter(|(_, _, [l, _, r, _])| r > l) + .map(|(origin, rotation, [l, base, r, h])| { + let (sin_r, cos_r) = rotation.sin_cos(); + let to_world = |x: f64, y: f64| [origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r]; let (b, t) = (base - FIELD_BOX_BELOW * h, base + FIELD_BOX_ABOVE * h); [to_world(l, b), to_world(r, b), to_world(r, t), to_world(l, t)] }) @@ -1508,17 +1537,16 @@ pub fn tessellate( // entity still has a hit-test target via snap_pts. With SDF on // this is the normal path (strokes suppressed) and the wire // also carries the glyph quads built above. - if bins.is_empty() { - let mut wires: Vec = Vec::new(); - // FIELDDISPLAY: a field shows on a gray box behind its - // glyphs, on screen only. - if text_aabb != WireModel::UNBOUNDED_AABB - && crate::entities::field::display() + // FIELDDISPLAY: a field shows on a gray box behind its glyphs, + // on screen only — for SDF glyphs and shaped stroke text alike. + let mut field_background: Option = None; + if crate::entities::field::display() && crate::entities::field::hosts_field(document, entity) { let runs = field_run_corner_groups(document, entity, &stroke_groups, anno); let corner_groups = match entity { _ if runs.is_some() => runs.unwrap_or_default(), + _ if text_aabb == WireModel::UNBOUNDED_AABB => Vec::new(), EntityType::MText(m) => { let rotation = stroke_groups .iter() @@ -1550,7 +1578,7 @@ pub fn tessellate( } } if !ft.is_empty() { - wires.push(WireModel { + field_background = Some(WireModel { display_visible: true, plot_visible: false, name: name.clone(), @@ -1565,6 +1593,9 @@ pub fn tessellate( }); } } + if bins.is_empty() { + let mut wires: Vec = Vec::new(); + wires.extend(field_background.take()); // MTEXT background and frame follow the glyph bounds. if text_aabb != WireModel::UNBOUNDED_AABB { if let EntityType::MText(m) = entity { @@ -1801,6 +1832,7 @@ pub fn tessellate( } let mut out: Vec = Vec::new(); + out.extend(field_background); let mut is_first = true; for bin in bins { let wire_color = match bin.color { diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index b9fdd6be1..50ec1ab85 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -577,6 +577,30 @@ pub struct ShapedRun { pub glyphs: Vec, /// Total pen advance of the run (9-unit). pub advance: f32, + /// Every laid-out glyph (blank ones too) as the byte range of the text it + /// shows and its visual [left, right] pen cell, 9-unit. + pub cells: Vec<(usize, usize, f32, f32)>, +} + +/// Each character's visual [left, right] cell (9-unit) in logical order: +/// the cell of the glyph that shows it; a glyph showing several characters +/// shares its cell among them in reading order. +pub fn char_cells(run: &ShapedRun, text: &str) -> Vec<(f32, f32)> { + text.char_indices() + .map(|(byte, _)| { + let Some(&(start, end, x0, x1)) = + run.cells.iter().find(|(start, end, _, _)| (*start..(*end).max(start + 1)).contains(&byte)) + else { + return (0.0, 0.0); + }; + let chars: Vec = text[start..end.min(text.len())].char_indices().map(|(i, _)| start + i).collect(); + let (n, k) = (chars.len().max(1), chars.iter().position(|&b| b == byte).unwrap_or(0)); + let rtl = text[start..end.min(text.len())].chars().any(crate::entities::text_support::is_rtl_char); + let slot = (x1 - x0) / n as f32; + let k = if rtl { n - 1 - k } else { k }; + (x0 + slot * k as f32, x0 + slot * (k + 1) as f32) + }) + .collect() } type ShapeCache = HashMap<(String, String), Option>>; @@ -881,10 +905,12 @@ fn build_shaped(_family: &str, text: &str) -> Option { buffer.shape_until_scroll(&mut font_system, false); let mut glyphs = Vec::new(); + let mut cells = Vec::new(); let mut advance = 0.0_f32; for run in buffer.layout_runs() { advance = advance.max(run.line_w * px_to_9); for glyph in run.glyphs.iter() { + cells.push((glyph.start, glyph.end, glyph.x * px_to_9, (glyph.x + glyph.w) * px_to_9)); let face_index = font_system .db_mut() .face(glyph.font_id) @@ -923,7 +949,7 @@ fn build_shaped(_family: &str, text: &str) -> Option { if advance <= 0.0 && glyphs.is_empty() { None } else { - Some(ShapedRun { glyphs, advance }) + Some(ShapedRun { glyphs, advance, cells }) } } @@ -986,10 +1012,12 @@ fn build_shaped(family: &str, text: &str) -> Option { let px_to_9 = CAP_UNITS * upem_p / (SHAPE_FS * cap_p); let mut glyphs: Vec = Vec::new(); + let mut cells = Vec::new(); let mut advance = 0.0_f32; for run in buf.layout_runs() { advance = advance.max(run.line_w * px_to_9); for g in run.glyphs.iter() { + cells.push((g.start, g.end, g.x * px_to_9, (g.x + g.w) * px_to_9)); let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue; @@ -1028,7 +1056,7 @@ fn build_shaped(family: &str, text: &str) -> Option { if advance <= 0.0 && glyphs.is_empty() { return None; } - Some(ShapedRun { glyphs, advance }) + Some(ShapedRun { glyphs, advance, cells }) } #[cfg(test)]