fix(mtext): wrap between ideographs; paragraph indents and tabs in text-height units

Two layout defects that together broke every numbered note in a Chinese
drawing:

- Words were split on whitespace only, so a Chinese sentence — which has no
  spaces — was one unbreakable word. When it did not fit after "7.<tab>" the
  whole sentence dropped to the next line and still ran past the column.
  Ideographic text may break between any two characters except before a
  closing mark or after an opening one (the basic kinsoku rule); the atom
  splitter now emits those break opportunities.

- `\pxi…,l…,r…,t…;` paragraph indents and tab stops are multiples of the
  text height, and the first-line indent is relative to the left indent
  (a hanging list is `i-1.5,l1.5`). They were applied as raw drawing units,
  i.e. a fraction of a unit, so wrapped lines snapped back to the margin
  and the number/tab column collapsed. Scale them by the entity height and
  resolve the first line from the left indent.

Adds a layout test: a 40-ideograph paragraph in a 30-unit column wraps into
several lines, none wider than the column.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Chris YehandClaude Opus 5 committed 2026-09-21 22:02:13 +08:00
1 parent 24f8e124d5
commit 876513d009
1 file changed
+92 -4
+92 -4
View File
@@ -560,11 +560,18 @@ pub fn adapt_mtext_paragraphs(
}
let (align, indent_first, indent_left, indent_right, tab_stops) =
if has_own_props(props) || carried.is_none() {
// `\pxi…,l…,r…,t…;` values are multiples of the text height,
// not drawing units, and the first-line indent is relative to
// the left indent (a numbered list's hanging indent is
// `i-1.5,l1.5`: number at the margin, wrapped lines 1.5 h in).
// Taken raw they were a fraction of a unit — no indent at all —
// so every wrapped list line snapped back to the margin.
let left = props.left_margin.unwrap_or(0.0) as f32 * entity_height;
let v = (
props.alignment.and_then(map_align),
props.first_line_indent.unwrap_or(0.0) as f32,
props.left_margin.unwrap_or(0.0) as f32,
props.right_margin.unwrap_or(0.0) as f32,
left + props.first_line_indent.unwrap_or(0.0) as f32 * entity_height,
left,
props.right_margin.unwrap_or(0.0) as f32 * entity_height,
props
.tab_stops
.iter()
@@ -577,7 +584,7 @@ pub fn adapt_mtext_paragraphs(
ATab::Decimal(_) => TabKind::Decimal,
};
TabStop {
position: ts.position() as f32,
position: ts.position() as f32 * entity_height,
kind,
}
})
@@ -900,6 +907,20 @@ pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow<
}
}
/// Whether a line may break between `prev` and `next`. Ideographic text has
/// no spaces, so a break is allowed on either side of a full-width character,
/// except before a closing mark (`,。、)」…`) or after an opening one
/// (`(「《…`) — the basic kinsoku rule.
pub(crate) fn cjk_break_between(prev: char, next: char) -> bool {
use crate::scene::text::ttf_glyph::is_full_width;
const CLOSING: &str = ",。、;:?!)」』】〕》〉』﹚﹞.~…‥、〞”’,.;:!?)]}%";
const OPENING: &str = "(「『【〔《〈﹙﹝“‘([{";
if !(is_full_width(prev) || is_full_width(next)) {
return false;
}
!CLOSING.contains(next) && !OPENING.contains(prev)
}
pub fn measure_word(
text: &str,
state: &RunState,
@@ -1404,6 +1425,21 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
doc_char_offset += 1;
word_start = doc_char_offset;
} else {
// CJK text carries no spaces: every ideograph is a
// wrap opportunity (bar the usual "no line starts
// with a closing mark / ends with an opening one"
// rule), so a long Chinese sentence must not stay
// one unbreakable word that overshoots its column.
if let Some(prev) = word.chars().last() {
if cjk_break_between(prev, ch) {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
char_offset: word_start,
});
word_start = doc_char_offset;
}
}
word.push(ch);
doc_char_offset += 1;
}
@@ -2997,6 +3033,58 @@ mod v_anchor_tests {
assert_eq!(reordered6, vec!["שלום", " ", "«x"]);
}
#[test]
fn cjk_paragraph_wraps_between_ideographs() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};
// Breaks are allowed between ideographs but not before a closing mark
// or after an opening one.
assert!(cjk_break_between('工', '程'));
assert!(cjk_break_between('程', 'A'));
assert!(!cjk_break_between('工', ','));
assert!(!cjk_break_between('(', '工'));
assert!(!cjk_break_between('a', 'b'));
let style = ResolvedTextStyle {
font_name: "Standard".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
// 40 ideographs with no spaces in a column ~12 characters wide used
// to stay one unbreakable word: a single line overshooting the box.
let value = "本工程之各種平面圖均須相互配合如有不符或須更正處均按工程慣例施工至完整並請於估價前提出";
let opts = MTextRenderOpts {
columns: Default::default(),
value,
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 30.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout = layout_mtext(&opts);
assert!(
layout.line_count >= 3,
"a 40-ideograph paragraph in a 30-unit column must wrap (got {} line(s))",
layout.line_count
);
assert!(
layout.line_widths.iter().all(|w| *w <= 30.0 + 1e-3),
"no wrapped line may overshoot the column: {:?}",
layout.line_widths
);
}
#[test]
fn test_mtext_arabic_bidi_layout() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};