Skip to content

Commit 3d63ef8

Browse files
Merge pull request #173 from developer0hye/fix/pptx-nowrap-regression
fix: restore pptx mixed-script no-wrap titles
2 parents 937d844 + 7ce1ebb commit 3d63ef8

2 files changed

Lines changed: 147 additions & 8 deletions

File tree

crates/office2pdf/src/render/typst_gen_fixed_page_textbox_tests.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,113 @@ fn test_fixed_page_text_box_no_wrap_keeps_latin_text_extractable() {
10391039
);
10401040
}
10411041

1042+
#[test]
1043+
fn test_fixed_page_text_box_no_wrap_keeps_mixed_script_titles_unbroken() {
1044+
let doc = make_doc(vec![make_fixed_page(
1045+
960.0,
1046+
540.0,
1047+
vec![FixedElement {
1048+
x: 100.0,
1049+
y: 120.0,
1050+
width: 320.0,
1051+
height: 40.0,
1052+
kind: FixedElementKind::TextBox(crate::ir::TextBoxData {
1053+
content: vec![Block::Paragraph(Paragraph {
1054+
style: ParagraphStyle {
1055+
alignment: Some(Alignment::Center),
1056+
..ParagraphStyle::default()
1057+
},
1058+
runs: vec![Run {
1059+
text: "III. 기술부문".to_string(),
1060+
style: TextStyle {
1061+
font_size: Some(28.0),
1062+
..TextStyle::default()
1063+
},
1064+
href: None,
1065+
footnote: None,
1066+
}],
1067+
})],
1068+
padding: Insets::default(),
1069+
vertical_align: crate::ir::TextBoxVerticalAlign::Top,
1070+
fill: None,
1071+
opacity: None,
1072+
stroke: None,
1073+
shape_kind: None,
1074+
no_wrap: true,
1075+
auto_fit: false,
1076+
}),
1077+
}],
1078+
)]);
1079+
let output = generate_typst(&doc).unwrap();
1080+
assert!(
1081+
output.source.contains("I\u{2060}I\u{2060}I\u{2060}.")
1082+
&& output
1083+
.source
1084+
.contains("\u{00A0}\u{2060}\u{2060}\u{2060}\u{2060}문"),
1085+
"Expected mixed-script no-wrap title to keep the full heading unbreakable, got:\n{}",
1086+
output.source,
1087+
);
1088+
}
1089+
1090+
#[test]
1091+
fn test_fixed_page_text_box_no_wrap_preserves_mixed_script_titles_across_runs() {
1092+
let doc = make_doc(vec![make_fixed_page(
1093+
960.0,
1094+
540.0,
1095+
vec![FixedElement {
1096+
x: 100.0,
1097+
y: 120.0,
1098+
width: 320.0,
1099+
height: 40.0,
1100+
kind: FixedElementKind::TextBox(crate::ir::TextBoxData {
1101+
content: vec![Block::Paragraph(Paragraph {
1102+
style: ParagraphStyle {
1103+
alignment: Some(Alignment::Center),
1104+
..ParagraphStyle::default()
1105+
},
1106+
runs: vec![
1107+
Run {
1108+
text: "III.".to_string(),
1109+
style: TextStyle {
1110+
font_size: Some(28.0),
1111+
..TextStyle::default()
1112+
},
1113+
href: None,
1114+
footnote: None,
1115+
},
1116+
Run {
1117+
text: " 기술부문".to_string(),
1118+
style: TextStyle {
1119+
font_size: Some(40.0),
1120+
..TextStyle::default()
1121+
},
1122+
href: None,
1123+
footnote: None,
1124+
},
1125+
],
1126+
})],
1127+
padding: Insets::default(),
1128+
vertical_align: crate::ir::TextBoxVerticalAlign::Top,
1129+
fill: None,
1130+
opacity: None,
1131+
stroke: None,
1132+
shape_kind: None,
1133+
no_wrap: true,
1134+
auto_fit: false,
1135+
}),
1136+
}],
1137+
)]);
1138+
let output = generate_typst(&doc).unwrap();
1139+
assert!(
1140+
output.source.contains("I\u{2060}I\u{2060}I\u{2060}.")
1141+
&& output
1142+
.source
1143+
.contains("\u{00A0}\u{2060}\u{2060}\u{2060}\u{2060}문"),
1144+
"Expected mixed-script no-wrap title to stay unbroken across runs, got:\n{}",
1145+
output.source,
1146+
);
1147+
}
1148+
10421149
#[test]
10431150
fn test_fixed_page_text_box_auto_fit_short_text_uses_scale_to_fit() {
10441151
let doc = make_doc(vec![make_fixed_page(

crates/office2pdf/src/render/typst_gen_text.rs

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,23 @@ pub(super) fn generate_runs_with_tabs_no_wrap(
133133
runs: &[Run],
134134
tab_stops: Option<&[TabStop]>,
135135
) {
136+
let preserve_cjk_no_wrap: bool = runs
137+
.iter()
138+
.filter(|run| run.footnote.is_none())
139+
.any(|run| run.text.chars().any(is_cjk_like));
140+
let mut no_wrap_state: NoWrapState = NoWrapState::default();
136141
let transformed_runs: Vec<Run> = runs
137142
.iter()
138143
.map(|run| {
139144
let mut transformed_run: Run = run.clone();
140145
if transformed_run.footnote.is_none() {
141-
transformed_run.text = no_wrap_text(&transformed_run.text);
146+
transformed_run.text = no_wrap_text(
147+
&transformed_run.text,
148+
preserve_cjk_no_wrap,
149+
&mut no_wrap_state,
150+
);
151+
} else {
152+
no_wrap_state = NoWrapState::default();
142153
}
143154
transformed_run
144155
})
@@ -147,6 +158,12 @@ pub(super) fn generate_runs_with_tabs_no_wrap(
147158
generate_runs_with_tabs(out, &transformed_runs, tab_stops);
148159
}
149160

161+
#[derive(Clone, Copy, Default)]
162+
struct NoWrapState {
163+
previous_visible_char: Option<char>,
164+
previous_non_breaking_space: bool,
165+
}
166+
150167
/// Emits Typst variable bindings for a non-first tab segment: measurement,
151168
/// decimal anchor (if applicable), default remainder, advance, fill, and
152169
/// the accumulated prefix content variable.
@@ -208,29 +225,44 @@ pub(super) fn generate_runs(out: &mut String, runs: &[Run]) {
208225
}
209226
}
210227

211-
fn no_wrap_text(text: &str) -> String {
228+
fn no_wrap_text(text: &str, preserve_cjk_no_wrap: bool, state: &mut NoWrapState) -> String {
229+
if !preserve_cjk_no_wrap {
230+
return text.to_string();
231+
}
232+
212233
let mut out: String = String::new();
213-
let mut previous_visible_char: Option<char> = None;
214234

215235
for ch in text.chars() {
216236
if matches!(ch, '\t' | PPTX_SOFT_LINE_BREAK_CHAR) {
217237
out.push(ch);
218-
previous_visible_char = None;
238+
*state = NoWrapState::default();
239+
continue;
240+
}
241+
242+
if ch == ' ' {
243+
out.push('\u{00A0}');
244+
state.previous_visible_char = None;
245+
state.previous_non_breaking_space = true;
219246
continue;
220247
}
221248

222-
if previous_visible_char.is_some_and(|prev| needs_cjk_no_wrap_joiner(prev, ch)) {
249+
if state.previous_non_breaking_space
250+
|| state
251+
.previous_visible_char
252+
.is_some_and(|prev| needs_no_wrap_joiner(prev, ch))
253+
{
223254
out.push('\u{2060}');
224255
}
225256
out.push(ch);
226-
previous_visible_char = (!ch.is_whitespace()).then_some(ch);
257+
state.previous_visible_char = Some(ch);
258+
state.previous_non_breaking_space = false;
227259
}
228260

229261
out
230262
}
231263

232-
fn needs_cjk_no_wrap_joiner(previous: char, current: char) -> bool {
233-
is_cjk_like(previous) && is_cjk_like(current)
264+
fn needs_no_wrap_joiner(previous: char, current: char) -> bool {
265+
!previous.is_whitespace() && !current.is_whitespace()
234266
}
235267

236268
fn is_cjk_like(ch: char) -> bool {

0 commit comments

Comments
 (0)