Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/office2pdf/src/ir/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ pub struct NamedStyle {
#[derive(Debug, Clone, Default)]
pub struct ParagraphStyle {
pub alignment: Option<Alignment>,
/// 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<bool>,
pub indent_left: Option<f64>,
pub indent_right: Option<f64>,
pub indent_first_line: Option<f64>,
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions crates/office2pdf/src/ir/style_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion crates/office2pdf/src/parser/docx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![])
}
Expand Down
45 changes: 45 additions & 0 deletions crates/office2pdf/src/parser/docx_layout_rtl_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
13 changes: 10 additions & 3 deletions crates/office2pdf/src/parser/docx_lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,14 @@ pub(super) enum TaggedElement {
/// A regular block (non-list paragraph, table, image, page break, etc.)
Plain(Vec<Block>),
/// 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<Paragraph>,
},
}

/// A list item paired with the `numId` of the paragraph it came from, so a
Expand Down Expand Up @@ -465,7 +472,7 @@ pub(super) fn group_into_lists(
footnote: None,
},
);
result.push(Block::Paragraph(paragraph));
result.push(Block::Paragraph(*paragraph));
continue;
}

Expand All @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions crates/office2pdf/src/parser/docx_styles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/docx_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions crates/office2pdf/src/render/typst_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions crates/office2pdf/src/render/typst_gen_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,12 @@ pub(super) fn paragraph_eojeol_wrap(
line_box_em: Option<(f64, f64)>,
container_measure_pt: Option<f64>,
) -> 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;
}
Expand Down
39 changes: 39 additions & 0 deletions crates/office2pdf/src/render/typst_gen_text_pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading