Fields: exact field boxes for rotated, multi-line and shaped text

Layout glyph cells carry their unrotated cell, so a field box in rotated
MTEXT is placed exactly; multi-line attributes box each line of a field
from their own layout cells and single-line attributes split on \P per
line; shaped words (TrueType fonts, right-to-left and complex scripts)
take the shaper's glyph cells for their characters instead of evenly
divided slots. The field background is also drawn for text rendered as
shaped strokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-10-06 13:24:52 +03:00
1 parent 383eba14e5
commit 3830a69ca7
4 files changed
+273 -75

No files matched your search

+99 -7
View File
@@ -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<Vec<(f32, f32)>> {
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;