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};