diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d1f9d2cf7..4405d4d9b 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -655,6 +655,18 @@ pub struct ParaProps { /// First-line indent, points; negative = hanging. #[serde(skip_serializing_if = "Option::is_none")] pub indent_first: Option, + /// 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, + /// 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, + /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub space_before: Option, #[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() } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 10dbebbed..a96f35280 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -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`. diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs index 446a57d49..30b830115 100644 --- a/crates/docbin/src/fmt.rs +++ b/crates/docbin/src/fmt.rs @@ -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 { u16_of(prl).map(|v| v as i16) }; - let i32of = |prl: &Prl| -> Option { - 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)) }); } } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 0feb1292b..52582193d 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -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() { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 32706835a..19e859de1 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -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), diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 26636ab4b..b9e083793 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -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)); } diff --git a/crates/docx/tests/char_units.rs b/crates/docx/tests/char_units.rs new file mode 100644 index 000000000..3aa09e362 --- /dev/null +++ b/crates/docx/tests/char_units.rs @@ -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 { + 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 { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) +} + +fn props(d: &Document) -> Vec { + 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#"一 + 二 + 三 + 四"#; + 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("").unwrap() + 8..xml.find("").unwrap()])).unwrap()); + assert_eq!(again[..3], p[..3]); +} diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index e2c8c4741..7807dd55e 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -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)), diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 618b2272a..e7f58cbce 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -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()); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5bc81a5be..62042a29e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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(); } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 71241a08d..1bb48312f 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -43,6 +43,7 @@ pub fn specs() -> Vec { 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 { 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 { 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()); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1f98d19d0..00eb73885 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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); } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 029fac88f..d67d1e949 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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; } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 8e190be2f..880f6bfd5 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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 { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) }; let levels = bidi_levels(&p.text, rp.bidi); let mut b = diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0f89caae9..736ea3113 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 = (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 { + 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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 99bb70109..89886e3f4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b5a15b4bd..81be4e701 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) |