From 4123bdc09f5fcdd874c0f9ea9ed509e90e232db9 Mon Sep 17 00:00:00 2001 From: entropybeater Date: Wed, 4 Nov 2026 18:09:55 -0700 Subject: [PATCH] docx: keep a paragraph's text alignment (#5498) * docx: keep a paragraph's text alignment w:textAlignment (ECMA-376 17.3.1.39) was not read, so a save dropped it. Word sizes a line by it when a list's bullet is larger than the text: without it, those lines grow and every page after them moves. ParaProps gains text_alignment, read and carried through styles, and written back after w:jc. Kept for the file only; the layout does not use it yet. * contributors: add entropybeater --------- Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com> --- crates/doc/src/props.rs | 4 +- crates/doc/src/props/para_props.rs | 40 +++++++++++++++- crates/docx/src/read/props.rs | 5 +- crates/docx/src/write/props.rs | 3 ++ crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/roundtrip.rs | 3 +- crates/docx/tests/it/text_alignment.rs | 64 ++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 crates/docx/tests/it/text_alignment.rs diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index cf6281d534..a801081845 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -26,7 +26,9 @@ pub use char_props::{ CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign, }; pub use color::{Rgb, TextColor}; -pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame}; +pub use para_props::{ + Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame, +}; pub use table_props::{ CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor, diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index 70e9e00772..e72e3ef94b 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -26,6 +26,40 @@ impl Align { } } +/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`, +/// ECMA-376 §17.3.1.39). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignment { + Auto, + Top, + Center, + Baseline, + Bottom, +} + +impl TextAlignment { + pub fn ooxml(self) -> &'static str { + match self { + TextAlignment::Auto => "auto", + TextAlignment::Top => "top", + TextAlignment::Center => "center", + TextAlignment::Baseline => "baseline", + TextAlignment::Bottom => "bottom", + } + } + pub fn from_ooxml(s: &str) -> Option { + match s { + "auto" => Some(TextAlignment::Auto), + "top" => Some(TextAlignment::Top), + "center" => Some(TextAlignment::Center), + "baseline" => Some(TextAlignment::Baseline), + "bottom" => Some(TextAlignment::Bottom), + _ => None, + } + } +} + /// Line spacing rule. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "rule", content = "value")] @@ -398,6 +432,10 @@ pub struct ParaProps { /// `w:snapToGrid`: lines snap to the section's document grid. Unset = on. #[serde(skip_serializing_if = "Option::is_none")] pub snap_to_grid: Option, + /// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the + /// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_alignment: Option, } impl ParaProps { @@ -418,7 +456,7 @@ impl ParaProps { } fn overlay_script_layout(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); + overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index c7156dbdbb..5aa683f9c0 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use wordcraft_doc::para::cap_alt; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef, - ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, - VertAlign, + ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline, + VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, @@ -343,6 +343,7 @@ impl PropCtx { "w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)), "w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)), "w:snapToGrid" => p.snap_to_grid = Some(on_off(k)), + "w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml), "w:framePr" => { if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") { p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index d1730e0a46..839950701e 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha if let Some(a) = p.align { w.val("w:jc", align_val(a)); } + if let Some(t) = p.text_alignment { + w.val("w:textAlignment", t.ooxml()); + } if let Some(l) = p.outline_level { w.val("w:outlineLvl", &n(l.min(9) as i64)); } diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 71e6851cb7..b6c60dcce3 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -33,6 +33,7 @@ mod section_page_number_type; mod section_text_direction; mod section_valign; mod settings_merge; +mod text_alignment; mod text_boxes; mod toc_ranges; mod undefined_style; diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 205cb93494..98da6a4520 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, - TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, + TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; @@ -221,6 +221,7 @@ fn every_para_prop_round_trips() { num_change: None, fmt_change: None, snap_to_grid: Some(false), + text_alignment: Some(TextAlignment::Top), }; let variants = [ ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() }, diff --git a/crates/docx/tests/it/text_alignment.rs b/crates/docx/tests/it/text_alignment.rs new file mode 100644 index 0000000000..d72d25ff29 --- /dev/null +++ b/crates/docx/tests/it/text_alignment.rs @@ -0,0 +1,64 @@ +//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values +//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`. +//! Word sizes a line by it when a list label is larger than the text, so a save that drops it +//! moves every line after. Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +use wordcraft_doc::props::TextAlignment; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +#[test] +fn text_alignment_is_written_back_as_read() { + let para = |ppr: &str, text: &str| format!(r#"{ppr}{text}"#); + let body = [ + para(r#""#, "A"), + para(r#""#, "B"), + para(r#""#, "C"), + para(r#""#, "D"), + para(r#""#, "E"), + para(r#""#, "F"), + para("", "G"), + ] + .concat(); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let got: Vec> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect(); + let want = vec![ + Some(TextAlignment::Top), + Some(TextAlignment::Center), + Some(TextAlignment::Baseline), + Some(TextAlignment::Bottom), + Some(TextAlignment::Auto), + None, + None, + ]; + assert_eq!(got, want); + let out = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&out); + assert!(xml.contains(r#""#), "{xml}"); + for v in ["center", "baseline", "bottom", "auto"] { + assert_eq!(xml.matches(&format!(r#""#)).count(), 1, "{v}: {xml}"); + } + assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}"); + let back = wordcraft_docx::read(&out).unwrap(); + assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::>(), want); +}