Files
wordcraft/crates/docx/tests/char_units.rs
Brandon ThomasandClaude Opus 5.5 681a57fc1a Rendering: kerning and automatic paragraph spacing like Word (#923)
Found by rendering 12 US government Word documents (NIH, EPA, MCC) in
Word for Windows and WordCraft and comparing the pages side by side,
then pinning each rule down with synthetic documents measured in Word.

- Kerning: the shaper kerned every run, so WordCraft's lines held more
  text than Word's and broke differently. Word kerns only a run with
  Kerning for fonts (w:kern) at or above that size; no w:kern and
  w:kern="0" are unkerned. New documents keep kerning from 1 pt, like
  Word's Normal template.
- Automatic paragraph spacing (w:beforeAutospacing/w:afterAutospacing,
  common in text pasted from web pages) was ignored. It is 14 pt at
  any size, none before the document's or a cell's first paragraph,
  none after a cell's last, and the larger of two neighbouring spaces
  counts. Read, written back, and settable through para.spacing
  (beforeAuto/afterAuto); a number, Ctrl+0 and Remove Space After
  replace it as in Word.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:18:05 -04:00

83 lines
4.9 KiB
Rust

//! 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]);
}
/// HTML-style automatic paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`, ECMA-376
/// §17.3.1.33), common in text pasted from web pages: read, "0" switching an inherited one off,
/// and written back.
#[test]
fn automatic_spacing_reads_and_round_trips() {
let body = r#"<w:p><w:pPr><w:spacing w:before="100" w:beforeAutospacing="1" w:after="100" w:afterAutospacing="1"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:spacing w:before="0" w:beforeAutospacing="0" w:after="100" w:afterAutospacing="true"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body)).unwrap();
let p = props(&d);
assert_eq!((p[0].space_before_auto, p[0].space_after_auto, p[0].space_before), (Some(true), Some(true), Some(5.0)));
assert_eq!((p[1].space_before_auto, p[1].space_after_auto), (Some(false), Some(true)));
assert_eq!((p[2].space_before_auto, p[2].space_after_auto), (None, None));
let rp = d.styles.resolve_para(&p[0]);
assert_eq!((rp.space_before, rp.space_after, rp.before_auto), (14.0, 14.0, true), "automatic spacing is 14 pt");
assert_eq!(d.styles.resolve_para(&p[2]).space_after, 5.0);
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:beforeAutospacing="1""#, r#"w:afterAutospacing="1""#, r#"w:beforeAutospacing="0""#] {
assert!(xml.contains(attr), "{attr} missing");
}
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}