From 876513d009f8c7a69d7b42b9649eb29eecda9861 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 22:02:13 +0800 Subject: [PATCH] fix(mtext): wrap between ideographs; paragraph indents and tabs in text-height units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." 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) --- src/entities/text_support.rs | 96 ++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d4adac03f..57e865093 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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};