Chinese documents: character-unit indents, .doc indents and line spacing, floating tables, blank pages (#534) (#550)

- Indents in characters (w:firstLineChars, w:hangingChars, w:leftChars,
  w:rightChars; sprmPDxc* in .doc) are kept in the model, read and written,
  and laid out in the paragraph mark's font size, winning over the points
  as in Word. Indent commands cancel an inherited character indent.
- .doc: sprmPDxaLeft1 has a two-byte operand (was read as four, so every
  first-line indent was lost); sprmPDxaLeft1_80 is read; LSPD line spacing
  is decoded as signed dyaLine + fMultLinespace (exact 20pt read as 799pt,
  a page per empty paragraph).
- A floating table placed on the page or margin that would cover the text
  already on the page and doesn't fit below it moves to the next page.
- A line holding only a page break stays at the bottom of a full page
  instead of leaving a blank page.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 21:49:25 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent dab5092e83
commit a608332646
17 changed files with 314 additions and 30 deletions
+22 -1
View File
@@ -655,6 +655,18 @@ pub struct ParaProps {
/// First-line indent, points; negative = hanging.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first: Option<f32>,
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left_chars: Option<f32>,
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right_chars: Option<f32>,
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
/// `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first_chars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -731,7 +743,7 @@ pub struct ParaProps {
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
@@ -743,6 +755,15 @@ impl ParaProps {
pub fn is_empty(&self) -> bool {
*self == ParaProps::default()
}
/// After setting indents in points: keep the indents in characters (left, right, first line)
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
if on {
*chars = Some(0.0);
}
}
}
/// Just the formatting: without the tracked changes.
pub fn formatting(&self) -> ParaProps {
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
+28 -1
View File
@@ -165,6 +165,10 @@ pub struct ResolvedPara {
pub auto_space_dn: bool,
/// Lines snap to the document grid, when the section has one.
pub snap_to_grid: bool,
/// Indents in characters (left, right, first line; 0 = in points), which win over the
/// point values; see [`ResolvedPara::set_char_unit`].
#[serde(default)]
pub indent_chars: [f32; 3],
}
const MAX_INDENT: f32 = 1584.0; // 22"
@@ -176,6 +180,18 @@ impl ResolvedPara {
pub fn visual_indents(&self) -> (f32, f32) {
if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) }
}
/// Turn the indents given in characters into points, one character being `size` points: the
/// size of the paragraph mark's font (as Word measures `w:firstLineChars` and friends).
pub fn set_char_unit(&mut self, size: f32) {
let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 };
let [l, r, f] = self.indent_chars;
for (chars, pts) in [(l, &mut self.indent_left), (r, &mut self.indent_right), (f, &mut self.indent_first)] {
if chars != 0.0 {
*pts = (chars * size).clamp(-MAX_INDENT, MAX_INDENT);
}
}
}
}
impl ResolvedPara {
@@ -219,10 +235,15 @@ impl ResolvedPara {
auto_space_de: p.auto_space_de.unwrap_or(true),
auto_space_dn: p.auto_space_dn.unwrap_or(true),
snap_to_grid: p.snap_to_grid.unwrap_or(true),
indent_chars: [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]
.map(|c| c.filter(|c| c.is_finite()).unwrap_or(0.0).clamp(-MAX_CHARS, MAX_CHARS)),
}
}
}
/// The largest indent in characters (Word allows indents up to 22").
const MAX_CHARS: f32 = 1584.0;
impl StyleSheet {
/// Paragraph properties after defaults and the style chain (without direct formatting).
pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) {
@@ -272,7 +293,13 @@ impl StyleSheet {
direct.tabs = Some(merged);
}
base.overlay(&direct);
ResolvedPara::from(&base, &style)
let mut rp = ResolvedPara::from(&base, &style);
// Indents in characters: measured in the style's font size here; layout measures them in
// the paragraph mark's (`set_char_unit`), which direct formatting may change.
if rp.indent_chars.iter().any(|c| *c != 0.0) {
rp.set_char_unit(self.resolve_char(Some(&style), &CharProps::default()).size);
}
rp
}
/// Resolve character formatting for a run in a paragraph of style `para_style`.
+30 -15
View File
@@ -30,6 +30,11 @@ const P_DXA_LEFT80: u16 = 0x840F;
const P_DXA_RIGHT: u16 = 0x845D;
const P_DXA_RIGHT80: u16 = 0x840E;
const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1
const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80
// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1).
const P_DXC_RIGHT: u16 = 0x4455;
const P_DXC_LEFT: u16 = 0x4456;
const P_DXC_FIRST: u16 = 0x4457;
const P_DYA_BEFORE: u16 = 0xA413;
const P_DYA_AFTER: u16 = 0xA414;
const P_DYA_LINE: u16 = 0x6412;
@@ -186,12 +191,6 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0);
let s16 = |prl: &Prl| -> Option<i16> { u16_of(prl).map(|v| v as i16) };
let i32of = |prl: &Prl| -> Option<i32> {
match prl.operand {
[b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])),
_ => None,
}
};
match prl.op {
P_JC | P_JC80 => {
p.align = match prl.operand.first().copied().unwrap_or(0) {
@@ -212,9 +211,20 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
p.indent_right = Some(tw(v as i32));
}
}
P_DXA_FIRST => {
if let Some(v) = i32of(prl) {
p.indent_first = Some(tw(v));
// A two-byte operand, like the other indents (spra 4).
P_DXA_FIRST | P_DXA_FIRST80 => {
if let Some(v) = s16(prl) {
p.indent_first = Some(tw(v as i32));
}
}
P_DXC_RIGHT | P_DXC_LEFT | P_DXC_FIRST => {
if let Some(v) = s16(prl) {
let chars = Some(f32::from(v) / 100.0);
match prl.op {
P_DXC_RIGHT => p.indent_right_chars = chars,
P_DXC_LEFT => p.indent_left_chars = chars,
_ => p.indent_first_chars = chars,
}
}
}
P_DYA_BEFORE => {
@@ -227,13 +237,18 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0));
}
}
// LSPD ([MS-DOC] §2.9.150): a signed dyaLine, then fMultLinespace. In lines (240ths) when
// fMultLinespace is 1; else twips, at least when positive and exactly when negative.
P_DYA_LINE => {
if let Some(v) = u16_of(prl) {
let mult = (v & 0x3FFF) as f32;
p.line_spacing = Some(match v & 0xC000 {
0x0000 => LineSpacing::Multiple((mult / 240.0).clamp(0.1, 20.0)),
0x8000 => LineSpacing::AtLeast((mult / 20.0).clamp(0.0, 1584.0)),
_ => LineSpacing::Exactly((mult / 20.0).clamp(0.0, 1584.0)),
if let Some(v) = s16(prl) {
let mult = prl.operand.get(2..4).is_some_and(|b| b == [1, 0]);
let v = f32::from(v);
p.line_spacing = Some(if mult {
LineSpacing::Multiple((v.abs() / 240.0).clamp(0.06, 132.0))
} else if v < 0.0 {
LineSpacing::Exactly((-v / 20.0).clamp(0.7, 1584.0))
} else {
LineSpacing::AtLeast((v / 20.0).clamp(0.0, 1584.0))
});
}
}
+28
View File
@@ -673,6 +673,34 @@ fn paragraph_props_and_heading_style() {
assert_eq!(p1.props.style.as_deref(), Some("Normal"));
}
/// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand
/// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a
/// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as
/// 799pt, a page per empty paragraph.
#[test]
fn first_line_indent_chars_and_exact_line_spacing() {
let lspd = |dya: i16, mult: u16| [dya.to_le_bytes(), mult.to_le_bytes()].concat();
let (exact, multiple) = (lspd(-400, 0), lspd(360, 1));
let spec = RichSpec {
text: "indented\rexact\rold\r",
styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, grpprl(&[(0x8460, &480i16.to_le_bytes()), (0x4457, &200i16.to_le_bytes()), (0x6412, &multiple)])),
(9, 0, grpprl(&[(0x6412, &exact)])),
(15, 0, grpprl(&[(0x8411, &(-360i16).to_le_bytes())])),
],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone();
let p0 = para(0);
assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0)));
assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5)));
assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0)));
assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent
}
/// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles.
#[test]
fn corpus_files_open() {
+13
View File
@@ -213,6 +213,19 @@ impl PropCtx {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
let chars = |a: &str| k.attr(a).and_then(int).map(|v| v.clamp(-158_400, 158_400) as f32 / 100.0);
if let Some(v) = chars("w:leftChars").or_else(|| chars("w:startChars")) {
p.indent_left_chars = Some(v);
}
if let Some(v) = chars("w:rightChars").or_else(|| chars("w:endChars")) {
p.indent_right_chars = Some(v);
}
match (chars("w:hangingChars"), chars("w:firstLineChars")) {
(Some(h), f) if h != 0.0 || f.is_none() => p.indent_first_chars = Some(-h),
(_, Some(f)) => p.indent_first_chars = Some(f),
_ => {}
}
}
"w:jc" => p.align = k.attr("w:val").map(align),
"w:outlineLvl" => p.outline_level = k.attr("w:val").and_then(u32_of).map(|v| v.min(9) as u8),
+14 -1
View File
@@ -289,17 +289,30 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
}
w.empty("w:spacing", &a);
}
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() {
let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars];
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) {
let l = p.indent_left.map(twips);
let r = p.indent_right.map(twips);
let f = p.indent_first.map(|v| (v < 0.0, twips(v.abs())));
// Character units in hundredths of a character.
let hundredths = |v: f32| ((v.clamp(-1584.0, 1584.0) * 100.0).round() as i64).to_string();
let [lc, rc, fc] = chars.map(|c| c.map(|v| (v < 0.0, hundredths(v.abs()), hundredths(v))));
let mut a: Vec<(&str, &str)> = Vec::new();
if let Some((_, _, v)) = &lc {
a.push(("w:leftChars", v));
}
if let Some(l) = &l {
a.push(("w:left", l));
}
if let Some((_, _, v)) = &rc {
a.push(("w:rightChars", v));
}
if let Some(r) = &r {
a.push(("w:right", r));
}
if let Some((hang, v, _)) = &fc {
a.push((if *hang { "w:hangingChars" } else { "w:firstLineChars" }, v));
}
if let Some((hang, v)) = &f {
a.push((if *hang { "w:hanging" } else { "w:firstLine" }, v));
}
+55
View File
@@ -0,0 +1,55 @@
//! Indents in character units (#534), common in Chinese documents: ECMA-376 Part 1 §17.3.1.12
//! `w:ind` `w:firstLineChars`, `w:hangingChars`, `w:leftChars` and `w:rightChars`, in hundredths
//! of a character. Fixtures are hand-written.
use std::io::{Read, Write};
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(d: &Document) -> Vec<ParaProps> {
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn character_indents_read_and_round_trip() {
let body = r#"<w:p><w:pPr><w:ind w:firstLineChars="200" w:firstLine="420"/></w:pPr><w:r><w:t>一</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:leftChars="150" w:left="315" w:rightChars="50" w:right="105" w:hangingChars="100" w:hanging="210"/></w:pPr><w:r><w:t>二</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="0" w:firstLine="240"/></w:pPr><w:r><w:t>三</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="99999999" w:leftChars="junk"/></w:pPr><w:r><w:t>四</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body)).unwrap();
let p = props(&d);
assert_eq!((p[0].indent_first_chars, p[0].indent_first), (Some(2.0), Some(21.0)));
assert_eq!((p[1].indent_left_chars, p[1].indent_right_chars, p[1].indent_first_chars), (Some(1.5), Some(0.5), Some(-1.0)));
// Zero cancels an inherited value; the points apply.
assert_eq!((p[2].indent_first_chars, p[2].indent_first), (Some(0.0), Some(12.0)));
// Hostile values are clamped or dropped.
assert_eq!((p[3].indent_first_chars, p[3].indent_left_chars), (Some(1584.0), None));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
for attr in [r#"w:firstLineChars="200""#, r#"w:leftChars="150""#, r#"w:rightChars="50""#, r#"w:hangingChars="100""#, r#"w:firstLineChars="0""#] {
assert!(xml.contains(attr), "{attr} missing: {xml}");
}
let again = props(&wordcraft_docx::read(&docx(&xml[xml.find("<w:body>").unwrap() + 8..xml.find("</w:body>").unwrap()])).unwrap());
assert_eq!(again[..3], p[..3]);
}
+3
View File
@@ -136,6 +136,9 @@ fn every_para_prop_round_trips() {
indent_left: Some(36.0),
indent_right: Some(18.0),
indent_first: Some(-18.0),
indent_left_chars: Some(1.5),
indent_right_chars: Some(0.0),
indent_first_chars: Some(-1.0),
space_before: Some(6.0),
space_after: Some(12.0),
line_spacing: Some(LineSpacing::Multiple(1.5)),
+2
View File
@@ -220,6 +220,8 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
pp.numbering = Some(NumRef { num, level });
pp.indent_left = None;
pp.indent_first = None;
pp.indent_left_chars = None;
pp.indent_first_chars = None;
if pp.style.is_none() || pp.style.as_deref() == Some("Normal") {
pp.style = Some("ListParagraph".into());
}
+2
View File
@@ -291,6 +291,8 @@ pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {
}
para.props.indent_left = None;
para.props.indent_first = None;
para.props.indent_left_chars = None;
para.props.indent_first_chars = None;
para.touch();
}
+15 -2
View File
@@ -43,6 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
let l = p.indent_left.unwrap_or(0.0) + 36.0;
p.indent_left = Some(l);
p.indent_first = Some(-36.0);
p.cancel_char_indents(true, false, true);
})
})
.key("Mod+T"),
@@ -50,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
fmt(s, &|p| {
p.indent_left = Some((p.indent_left.unwrap_or(0.0) - 36.0).max(0.0));
p.indent_first = Some(0.0);
p.cancel_char_indents(true, false, true);
})
})
.key("Mod+Shift+T"),
@@ -65,6 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(x) = f {
p.indent_first = Some(x.clamp(-1584.0, 1584.0));
}
p.cancel_char_indents(l.is_some(), r.is_some(), f.is_some());
})
})
.params(r#"{"left"?: pt, "right"?: pt, "firstLine"?: pt (negative = hanging)}"#),
@@ -319,7 +322,10 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
None => para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab),
None => {
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
para.props.cancel_char_indents(true, false, false);
}
}
para.touch();
}
@@ -338,7 +344,10 @@ pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }),
None => para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)),
None => {
para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0));
para.props.cancel_char_indents(true, false, false);
}
}
para.touch();
}
@@ -443,6 +452,8 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
p.numbering = Some(NumRef { num: 0, level: 0 });
p.indent_left = None;
p.indent_first = None;
p.indent_left_chars = None;
p.indent_first_chars = None;
if p.style.as_deref() == Some("ListParagraph") {
p.style = None;
}
@@ -466,6 +477,8 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
p.numbering = Some(NumRef { num, level });
p.indent_left = None;
p.indent_first = None;
p.indent_left_chars = None;
p.indent_first_chars = None;
if p.style.is_none() || p.style.as_deref() == Some("Normal") {
p.style = Some("ListParagraph".into());
}
+2 -1
View File
@@ -520,9 +520,10 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
super::leave_list(s.doc.para_mut(f.story, &f.path)?);
return sel_result(s);
}
if para.props.indent_first.is_some_and(|x| x > 0.0) {
if para.props.indent_first.is_some_and(|x| x > 0.0) || para.props.indent_first_chars.is_some_and(|x| x > 0.0) {
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(0.0);
p.props.cancel_char_indents(false, false, true);
p.touch();
return sel_result(s);
}
+18 -2
View File
@@ -2452,7 +2452,14 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
if bottom > limit + 0.01 && l1 > l0 && !split {
break;
}
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split {
// A line holding nothing but a page or column break stays at the bottom of the page
// even when it doesn't fit: it only ends the page, and moving it would leave a blank
// page behind it (Word does the same).
let only_break = matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak)
&& p.text
.get(l.start..l.stop)
.is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK)));
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break {
break;
}
new_notes.extend(line_notes);
@@ -2794,7 +2801,16 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table
};
let x = float_table_x(pb, w, f, legacy);
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables);
if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph {
// Like Word, a table that doesn't fit below the text before it moves to the next page with
// its anchor: one placed relative to the paragraph when it would run off the page, one
// placed on the page or margin when it would also cover the text already on the page.
let moves = if f.v_rel == Anchor::Paragraph {
r.bottom() > pb.bottom + 0.01
} else {
let covers_text = r.y < pb.y - 0.01 && r.bottom() > pb.top + 0.01 && r.x < pb.col_x() + pb.col_w() && r.right() > pb.col_x();
covers_text && pb.y + h > pb.bottom + 0.01
};
if attempt == 0 && moves && !pb.at_top() {
pb.advance(block, body_top);
continue;
}
+8 -4
View File
@@ -952,17 +952,21 @@ pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool)
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let doc = env.doc;
let mut rp = doc.styles.resolve_para_in(&p.props, env.table.map(|t| &t.para));
let para_style = p.props.style.as_deref();
let table_chr = env.table.map(|t| &t.chr);
// Indents in characters are measured in the paragraph mark's font size.
if rp.indent_chars.iter().any(|c| *c != 0.0) {
rp.set_char_unit(doc.styles.resolve_char_in(para_style, table_chr, &p.mark).size);
}
// List level indents apply unless the paragraph sets its own.
if let Some((_, lvl)) = &env.label {
if p.props.indent_left.is_none() {
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
rp.indent_left = lvl.indent;
}
if p.props.indent_first.is_none() {
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
rp.indent_first = -lvl.hanging;
}
}
let para_style = p.props.style.as_deref();
let table_chr = env.table.map(|t| &t.chr);
let resolve = |c: &CharProps| -> Arc<ResolvedChar> { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) };
let levels = bidi_levels(&p.text, rp.bidi);
let mut b =
+68
View File
@@ -2441,6 +2441,74 @@ fn a_floating_table_taller_than_a_page_runs_across_pages() {
assert_eq!(rows, 80);
}
/// #534: a table placed on the page (`w:tblpPr w:vertAnchor="page"`) whose anchor comes after a
/// page full of text goes to the next page with it, like Word, instead of covering that text.
#[test]
fn a_page_anchored_floating_table_after_a_full_page_moves_to_the_next() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let text: Vec<String> = (0..24).map(|i| format!("Paragraph {i} before the table.")).collect();
let mut d = Document::from_text(&(text.join("\n") + "\nAfter the table."));
let mut t = Table::new(15, 1, 300.0);
for (i, r) in t.rows.iter_mut().enumerate() {
r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row {i}"), Default::default()))];
}
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 70.0, y: 71.0, ..Default::default() });
d.insert_block(StoryRef::Body, &Path::top(24), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let page_of = |want: &dyn Fn(&Path) -> bool| -> Vec<usize> {
l.pages
.iter()
.enumerate()
.flat_map(|(i, p)| p.items.iter().map(move |it| (i, it)))
.filter_map(|(i, it)| if let Placed::Lines { story: StoryRef::Body, path, .. } = it { want(path).then_some(i) } else { None })
.collect()
};
// The text before the table fills page 1; the table and the text after it are on page 2.
assert_eq!(page_of(&|p| p.0 == [23]), vec![0]);
let rows = page_of(&|p| p.0.first() == Some(&24) && p.0.len() > 1);
assert!(rows.len() == 15 && rows.iter().all(|pg| *pg == 1), "table rows on pages {rows:?}");
assert_eq!(page_of(&|p| p.0 == [25]), vec![1]);
}
/// #534: a paragraph that starts with a page break right where the page is full keeps the break
/// on that page: no blank page between the two tables around it.
#[test]
fn a_page_break_at_the_bottom_of_a_full_page_leaves_no_blank_page() {
use wordcraft_doc::props::LineSpacing;
let props = ParaProps { line_spacing: Some(LineSpacing::Exactly(24.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let para = |text: &str| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
p.props = props.clone();
wordcraft_doc::para_block(p)
};
let mut d = Document::from_text("");
// 27 lines of 24pt fill the 648pt of text on a Letter page.
d.body = (0..27).map(|i| para(&format!("Line {i}"))).collect();
d.body.push(para("\u{000C}Next page"));
let l = lay(&d);
assert_eq!(l.pages.len(), 2, "pages: {}", l.pages.len());
let last_on_2 = l.pages[1].items.iter().any(|it| matches!(it, Placed::Lines { path, .. } if path.0 == [27]));
assert!(last_on_2, "the text after the break is on page 2");
}
/// #534: indents in characters (`w:firstLineChars`) are measured in the paragraph mark's font
/// size and win over the indent in points.
#[test]
fn character_indents_use_the_paragraph_mark_size() {
let mut p = wordcraft_doc::Paragraph::with_text(&"字".repeat(80), Default::default());
p.props.indent_first = Some(5.0);
p.props.indent_first_chars = Some(2.0);
p.props.indent_left_chars = Some(1.0);
p.mark.size = Some(20.0);
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap();
assert!((para.lines[0].left - 60.0).abs() < 0.01, "first line at {}", para.lines[0].left);
assert!((para.lines[1].left - 20.0).abs() < 0.01, "second line at {}", para.lines[1].left);
}
#[test]
fn no_line_break_right_after_a_slash() {
// Word keeps "and/or" and web addresses whole on a line when they fit.
+2 -1
View File
@@ -46,7 +46,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Feature | Read | Write | Notes |
|---|---|---|---|
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534) |
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
| Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) |
@@ -91,6 +91,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |
| 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) |
| 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) |
| 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) |
+4 -2
View File
@@ -23,8 +23,9 @@ font can't match Word's metrics.
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 |
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines | — |
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534) | — |
| Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 |
| Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization |
| Drop caps | ✅ | 2026-10-06 | — |
@@ -37,7 +38,7 @@ font can't match Word's metrics.
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 |
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — |
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 |
| Floating tables (`w:tblpPr`) | 🟡 | #137; overlap rules untested | 2–3 |
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 |
| Nested tables | 🟡 | render; polish missing | 2–4 |
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — |
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — |
@@ -73,6 +74,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |
| 2026-10-11 | trivial | Mirror indents (`w:mirrorIndents`): left/right indents swap on even pages; suppress line numbers inherited from styles (#512) |