diff --git a/crates/office2pdf/src/ir/style.rs b/crates/office2pdf/src/ir/style.rs index 7e5db9bc..e88ea154 100644 --- a/crates/office2pdf/src/ir/style.rs +++ b/crates/office2pdf/src/ir/style.rs @@ -26,6 +26,12 @@ pub struct NamedStyle { #[derive(Debug, Clone, Default)] pub struct ParagraphStyle { pub alignment: Option, + /// Word's `w:wordWrap`: whether a Hangul line breaks between eojeol + /// (`Some(true)`) or at any syllable (`Some(false)`). `None` leaves the + /// choice to the renderer's default, which is word-level for a + /// non-justified paragraph. The property overrides the style chain, so it + /// cannot be inferred from `pStyle` (issue #730). + pub word_wrap: Option, pub indent_left: Option, pub indent_right: Option, pub indent_first_line: Option, @@ -282,6 +288,9 @@ impl ParagraphStyle { if other.alignment.is_some() { self.alignment = other.alignment; } + if other.word_wrap.is_some() { + self.word_wrap = other.word_wrap; + } if other.indent_left.is_some() { self.indent_left = other.indent_left; } diff --git a/crates/office2pdf/src/ir/style_tests.rs b/crates/office2pdf/src/ir/style_tests.rs index fc03fa77..3960aa0c 100644 --- a/crates/office2pdf/src/ir/style_tests.rs +++ b/crates/office2pdf/src/ir/style_tests.rs @@ -183,6 +183,7 @@ fn text_style_merge_from_into_default_target() { fn paragraph_style_merge_from_all_none_source_preserves_target() { let mut target = ParagraphStyle { alignment: Some(Alignment::Center), + word_wrap: None, indent_left: Some(10.0), indent_right: Some(5.0), indent_first_line: Some(20.0), @@ -233,6 +234,7 @@ fn paragraph_style_merge_from_all_some_source_overwrites_target() { }; let source = ParagraphStyle { alignment: Some(Alignment::Right), + word_wrap: Some(false), indent_left: Some(20.0), indent_right: Some(15.0), indent_first_line: Some(30.0), diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 03feef2e..7b6f09ad 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -576,7 +576,10 @@ fn convert_paragraph_element( .style .space_after .get_or_insert(WORD_COMPATIBLE_PARAGRAPH_SPACE_AFTER_PT); - TaggedElement::ListParagraph { info, paragraph } + TaggedElement::ListParagraph { + info, + paragraph: Box::new(paragraph), + } } else { TaggedElement::Plain(vec![]) } diff --git a/crates/office2pdf/src/parser/docx_layout_rtl_tests.rs b/crates/office2pdf/src/parser/docx_layout_rtl_tests.rs index ba8256f2..2c66123e 100644 --- a/crates/office2pdf/src/parser/docx_layout_rtl_tests.rs +++ b/crates/office2pdf/src/parser/docx_layout_rtl_tests.rs @@ -711,3 +711,48 @@ fn test_parse_docx_text_after_run_page_break_still_renders() { other => panic!("expected the trailing text paragraph, got {other:?}"), } } + +// ── w:wordWrap (issue #730) ──────────────────────────────────────────── + +/// The property reaches the IR from `docx-rs`, with `0` and `1` distinct from +/// each other and from an absent element. +#[test] +fn test_word_wrap_reaches_the_paragraph_style() { + let off = extract_paragraph_style(&docx_rs::ParagraphProperty::new().word_wrap(false)); + assert_eq!(off.word_wrap, Some(false)); + + let on = extract_paragraph_style(&docx_rs::ParagraphProperty::new().word_wrap(true)); + assert_eq!(on.word_wrap, Some(true)); + + let absent = extract_paragraph_style(&docx_rs::ParagraphProperty::new()); + assert_eq!(absent.word_wrap, None); +} + +/// Measured on Word: a paragraph's own `w:wordWrap` beats the one its style +/// carries. A `ListParagraph` with `w:val="0"` breaks mid-eojeol even though +/// the style alone keeps eojeol whole. +#[test] +fn test_explicit_word_wrap_overrides_the_style_chain() { + let explicit_prop = docx_rs::ParagraphProperty::new().word_wrap(false); + let explicit = extract_paragraph_style(&explicit_prop); + let style = ResolvedStyle { + text: TextStyle::default(), + paragraph: ParagraphStyle { + word_wrap: Some(true), + ..ParagraphStyle::default() + }, + paragraph_tab_overrides: None, + heading_level: None, + }; + + let merged = merge_paragraph_style(&explicit, None, Some(&style)); + assert_eq!( + merged.word_wrap, + Some(false), + "the paragraph's own value wins" + ); + + // And the style still supplies the value when the paragraph says nothing. + let inherited = merge_paragraph_style(&ParagraphStyle::default(), None, Some(&style)); + assert_eq!(inherited.word_wrap, Some(true)); +} diff --git a/crates/office2pdf/src/parser/docx_lists.rs b/crates/office2pdf/src/parser/docx_lists.rs index 948e2012..c903db0c 100644 --- a/crates/office2pdf/src/parser/docx_lists.rs +++ b/crates/office2pdf/src/parser/docx_lists.rs @@ -279,7 +279,14 @@ pub(super) enum TaggedElement { /// A regular block (non-list paragraph, table, image, page break, etc.) Plain(Vec), /// A list paragraph with its numbering info and the paragraph IR. - ListParagraph { info: NumInfo, paragraph: Paragraph }, + /// + /// Boxed because a `Paragraph` is an order of magnitude larger than the + /// `Plain` variant's vector, and every element of the stream would + /// otherwise be sized for the larger one. + ListParagraph { + info: NumInfo, + paragraph: Box, + }, } /// A list item paired with the `numId` of the paragraph it came from, so a @@ -465,7 +472,7 @@ pub(super) fn group_into_lists( footnote: None, }, ); - result.push(Block::Paragraph(paragraph)); + result.push(Block::Paragraph(*paragraph)); continue; } @@ -474,7 +481,7 @@ pub(super) fn group_into_lists( resolved_level.map(|level| &level.paragraph_style), ); let mut item = ListItem { - content: vec![paragraph], + content: vec![*paragraph], level: info.level, start_at: None, }; diff --git a/crates/office2pdf/src/parser/docx_styles.rs b/crates/office2pdf/src/parser/docx_styles.rs index 1232abe8..bfa85b2e 100644 --- a/crates/office2pdf/src/parser/docx_styles.rs +++ b/crates/office2pdf/src/parser/docx_styles.rs @@ -393,6 +393,12 @@ pub(super) fn merge_paragraph_style( alignment: explicit .alignment .or(style_paragraph.and_then(|style| style.alignment)), + // Measured on Word: a paragraph's own w:wordWrap beats the one its + // style carries — a ListParagraph with w:val="0" breaks mid-eojeol + // although the style alone would not (issue #730). + word_wrap: explicit + .word_wrap + .or(style_paragraph.and_then(|style| style.word_wrap)), indent_left: explicit .indent_left .or(style_paragraph.and_then(|style| style.indent_left)), diff --git a/crates/office2pdf/src/parser/docx_text.rs b/crates/office2pdf/src/parser/docx_text.rs index 0a9e78a6..65264213 100644 --- a/crates/office2pdf/src/parser/docx_text.rs +++ b/crates/office2pdf/src/parser/docx_text.rs @@ -36,6 +36,7 @@ pub(super) fn extract_paragraph_style(prop: &docx_rs::ParagraphProperty) -> Para ParagraphStyle { alignment, + word_wrap: prop.word_wrap, indent_left, indent_right, indent_first_line, diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 02a4bb2e..f9146156 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -154,11 +154,11 @@ struct GenCtx { /// `EojeolWrap::Syllable` because that path resolves neither the frame's /// fixed text edges nor the box's inner measure — see the note there. /// - /// The flag is unconditional for a flow page: `w:wordWrap`, which is how - /// a document asks Word for character-level Hangul breaking, is not - /// parsed, so a paragraph declaring `w:val="0"` still gets the word-level - /// rule. That is issue #730 — it needs a `docx-rs` field before the value - /// can reach the IR. + /// The flag is the flow page's *default*, not the last word. A paragraph + /// carrying `w:wordWrap w:val="0"` — how a document asks Word for + /// character-level Hangul breaking — overrides it, and + /// [`paragraph_eojeol_wrap`] checks that before anything here (issue + /// #730). breaks_hangul_at_eojeol: bool, /// The width one line of the current container has, in points, before a /// paragraph's own indents are taken off it: a flow page's text width, diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index 1e545973..27736d6f 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -1114,6 +1114,12 @@ pub(super) fn paragraph_eojeol_wrap( line_box_em: Option<(f64, f64)>, container_measure_pt: Option, ) -> EojeolWrap { + // `w:wordWrap w:val="0"` asks for character-level breaking outright, and + // it wins over the style chain, so it is checked before anything the + // paragraph inherits (issue #730). + if style.word_wrap == Some(false) { + return EojeolWrap::Syllable; + } if !breaks_hangul_at_eojeol || matches!(style.alignment, Some(Alignment::Justify)) { return EojeolWrap::Syllable; } diff --git a/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs b/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs index b2ad6433..4897b0ce 100644 --- a/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs @@ -772,6 +772,45 @@ fn a_docx_paragraph_keeps_each_hangul_eojeol_whole() { ); } +/// `w:wordWrap w:val="0"` asks Word for character-level breaking of Hangul, +/// and it overrides the style chain. A paragraph that says so must not get the +/// eojeol frames #626 gives every other non-justified paragraph (issue #730). +#[test] +fn a_docx_paragraph_asking_for_character_breaking_gets_no_eojeol_frames() { + let mut paragraph = korean_paragraph(EOJEOL_SENTENCE, None, None); + if let Block::Paragraph(ref mut p) = paragraph { + p.style.word_wrap = Some(false); + } + let doc = make_doc(vec![make_flow_page(vec![paragraph])]); + let result = generate_typst(&doc).unwrap().source; + + assert!( + result.contains(EOJEOL_SENTENCE), + "the text stays one run so Typst may break inside an eojeol: {result}" + ); + assert!( + !result.contains("#box["), + "no eojeol frame may be emitted when wordWrap is off: {result}" + ); +} + +/// Triangulation: `w:val="1"` is the word-level setting, so it must keep the +/// frames rather than being treated as "the property is present, back off". +#[test] +fn a_docx_paragraph_asking_for_word_breaking_keeps_its_eojeol_frames() { + let mut paragraph = korean_paragraph(EOJEOL_SENTENCE, None, None); + if let Block::Paragraph(ref mut p) = paragraph { + p.style.word_wrap = Some(true); + } + let doc = make_doc(vec![make_flow_page(vec![paragraph])]); + let result = generate_typst(&doc).unwrap().source; + + assert!( + result.contains("본 #box[계약은] #box[갑과] #box[을이]"), + "wordWrap=1 keeps each eojeol whole: {result}" + ); +} + #[test] fn a_justified_docx_paragraph_keeps_syllable_breaking() { let doc = make_doc(vec![make_flow_page(vec![korean_paragraph(