From c1d2d8fc0dfadbb36979e235aef50e38d47b9cdc Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sun, 15 Mar 2026 00:04:27 +0900 Subject: [PATCH 1/2] fix: restore pptx mixed-script no-wrap titles Signed-off-by: Yonghye Kwon --- .../typst_gen_fixed_page_textbox_tests.rs | 107 ++++++++++++++++++ .../office2pdf/src/render/typst_gen_text.rs | 52 +++++++-- 2 files changed, 151 insertions(+), 8 deletions(-) diff --git a/crates/office2pdf/src/render/typst_gen_fixed_page_textbox_tests.rs b/crates/office2pdf/src/render/typst_gen_fixed_page_textbox_tests.rs index fca5415..74b9e4b 100644 --- a/crates/office2pdf/src/render/typst_gen_fixed_page_textbox_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_fixed_page_textbox_tests.rs @@ -1039,6 +1039,113 @@ fn test_fixed_page_text_box_no_wrap_keeps_latin_text_extractable() { ); } +#[test] +fn test_fixed_page_text_box_no_wrap_keeps_mixed_script_titles_unbroken() { + let doc = make_doc(vec![make_fixed_page( + 960.0, + 540.0, + vec![FixedElement { + x: 100.0, + y: 120.0, + width: 320.0, + height: 40.0, + kind: FixedElementKind::TextBox(crate::ir::TextBoxData { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + alignment: Some(Alignment::Center), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "III. 기술부문".to_string(), + style: TextStyle { + font_size: Some(28.0), + ..TextStyle::default() + }, + href: None, + footnote: None, + }], + })], + padding: Insets::default(), + vertical_align: crate::ir::TextBoxVerticalAlign::Top, + fill: None, + opacity: None, + stroke: None, + shape_kind: None, + no_wrap: true, + auto_fit: false, + }), + }], + )]); + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("I\u{2060}I\u{2060}I\u{2060}.") + && output + .source + .contains("\u{00A0}\u{2060}기\u{2060}술\u{2060}부\u{2060}문"), + "Expected mixed-script no-wrap title to keep the full heading unbreakable, got:\n{}", + output.source, + ); +} + +#[test] +fn test_fixed_page_text_box_no_wrap_preserves_mixed_script_titles_across_runs() { + let doc = make_doc(vec![make_fixed_page( + 960.0, + 540.0, + vec![FixedElement { + x: 100.0, + y: 120.0, + width: 320.0, + height: 40.0, + kind: FixedElementKind::TextBox(crate::ir::TextBoxData { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + alignment: Some(Alignment::Center), + ..ParagraphStyle::default() + }, + runs: vec![ + Run { + text: "III.".to_string(), + style: TextStyle { + font_size: Some(28.0), + ..TextStyle::default() + }, + href: None, + footnote: None, + }, + Run { + text: " 기술부문".to_string(), + style: TextStyle { + font_size: Some(40.0), + ..TextStyle::default() + }, + href: None, + footnote: None, + }, + ], + })], + padding: Insets::default(), + vertical_align: crate::ir::TextBoxVerticalAlign::Top, + fill: None, + opacity: None, + stroke: None, + shape_kind: None, + no_wrap: true, + auto_fit: false, + }), + }], + )]); + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("I\u{2060}I\u{2060}I\u{2060}.") + && output + .source + .contains("\u{00A0}\u{2060}기\u{2060}술\u{2060}부\u{2060}문"), + "Expected mixed-script no-wrap title to stay unbroken across runs, got:\n{}", + output.source, + ); +} + #[test] fn test_fixed_page_text_box_auto_fit_short_text_uses_scale_to_fit() { let doc = make_doc(vec![make_fixed_page( diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index bb0b6b2..706ca00 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -133,12 +133,23 @@ pub(super) fn generate_runs_with_tabs_no_wrap( runs: &[Run], tab_stops: Option<&[TabStop]>, ) { + let preserve_cjk_no_wrap: bool = runs + .iter() + .filter(|run| run.footnote.is_none()) + .any(|run| run.text.chars().any(is_cjk_like)); + let mut no_wrap_state: NoWrapState = NoWrapState::default(); let transformed_runs: Vec = runs .iter() .map(|run| { let mut transformed_run: Run = run.clone(); if transformed_run.footnote.is_none() { - transformed_run.text = no_wrap_text(&transformed_run.text); + transformed_run.text = no_wrap_text( + &transformed_run.text, + preserve_cjk_no_wrap, + &mut no_wrap_state, + ); + } else { + no_wrap_state = NoWrapState::default(); } transformed_run }) @@ -147,6 +158,12 @@ pub(super) fn generate_runs_with_tabs_no_wrap( generate_runs_with_tabs(out, &transformed_runs, tab_stops); } +#[derive(Clone, Copy, Default)] +struct NoWrapState { + previous_visible_char: Option, + previous_non_breaking_space: bool, +} + /// Emits Typst variable bindings for a non-first tab segment: measurement, /// decimal anchor (if applicable), default remainder, advance, fill, and /// the accumulated prefix content variable. @@ -208,29 +225,48 @@ pub(super) fn generate_runs(out: &mut String, runs: &[Run]) { } } -fn no_wrap_text(text: &str) -> String { +fn no_wrap_text( + text: &str, + preserve_cjk_no_wrap: bool, + state: &mut NoWrapState, +) -> String { + if !preserve_cjk_no_wrap { + return text.to_string(); + } + let mut out: String = String::new(); - let mut previous_visible_char: Option = None; for ch in text.chars() { if matches!(ch, '\t' | PPTX_SOFT_LINE_BREAK_CHAR) { out.push(ch); - previous_visible_char = None; + *state = NoWrapState::default(); + continue; + } + + if ch == ' ' { + out.push('\u{00A0}'); + state.previous_visible_char = None; + state.previous_non_breaking_space = true; continue; } - if previous_visible_char.is_some_and(|prev| needs_cjk_no_wrap_joiner(prev, ch)) { + if state.previous_non_breaking_space + || state + .previous_visible_char + .is_some_and(|prev| needs_no_wrap_joiner(prev, ch)) + { out.push('\u{2060}'); } out.push(ch); - previous_visible_char = (!ch.is_whitespace()).then_some(ch); + state.previous_visible_char = Some(ch); + state.previous_non_breaking_space = false; } out } -fn needs_cjk_no_wrap_joiner(previous: char, current: char) -> bool { - is_cjk_like(previous) && is_cjk_like(current) +fn needs_no_wrap_joiner(previous: char, current: char) -> bool { + !previous.is_whitespace() && !current.is_whitespace() } fn is_cjk_like(ch: char) -> bool { From 7ce1ebb59b591c551f0eb920033aa879e246769b Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sun, 15 Mar 2026 00:08:23 +0900 Subject: [PATCH 2/2] style: format no-wrap regression fix Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/render/typst_gen_text.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index 706ca00..54c851b 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -225,11 +225,7 @@ pub(super) fn generate_runs(out: &mut String, runs: &[Run]) { } } -fn no_wrap_text( - text: &str, - preserve_cjk_no_wrap: bool, - state: &mut NoWrapState, -) -> String { +fn no_wrap_text(text: &str, preserve_cjk_no_wrap: bool, state: &mut NoWrapState) -> String { if !preserve_cjk_no_wrap { return text.to_string(); }