Import/export fixes: TXT encodings and list labels, RTF Unicode, ODT styles and cell colours, HTML borders and pre spaces, LaTeX alignment groups (#433–#440) (#482)

- TXT: the engine opens and saves .txt through wordcraft-formats (BOM/unmarked
  UTF-16 sniffing, Windows-1252 fallback; list labels and a tab on export)
  instead of its own narrower decoder and body plain text.
- RTF: \uN and \ucN are handled in document info, bookmark names and field
  instructions, not only in body text.
- ODT: common paragraph styles (and common parents of automatic styles) give
  their text formatting, except the style that sets the paragraph kind;
  table-cell-properties fo:background-color becomes cell shading.
- HTML: tables without style or visible borders export without cell borders;
  white-space:pre paragraphs keep leading/trailing spaces.
- LaTeX: brace groups restore the paragraph alignment set inside them.

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 17:15:08 +02:00 committed by GitHub
parent 5078d9dea5
commit 1581f30176
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 220 additions and 29 deletions

View File

@ -94,7 +94,8 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
return Ok((doc, true));
}
let mut doc = match ext.as_str() {
"txt" | "text" | "" => Document::from_text(&decode_text(bytes)),
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
"txt" | "text" | "" => wordcraft_formats::txt::import(bytes),
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
other => match crate::io_ext::open(other, bytes) {
Some(r) => r?,
@ -109,7 +110,8 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
let ext = ext_of(name);
match ext.as_str() {
"txt" | "text" => Ok(doc.plain_text(wordcraft_doc::StoryRef::Body).replace('\n', "\r\n").into_bytes()),
// List paragraphs get their label and a tab, as in Word's plain-text save.
"txt" | "text" => Ok(wordcraft_formats::txt::export(doc)),
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
other => match crate::io_ext::save(other, doc) {
Some(r) => r,
@ -278,19 +280,8 @@ pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// UTF-8 (with or without BOM), UTF-16 with BOM, else Latin-1.
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else
/// Windows-1252 (see [`wordcraft_formats::txt::decode`]).
pub fn decode_text(b: &[u8]) -> String {
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
if b.len() >= 2 && (b[0] == 0xFF && b[1] == 0xFE || b[0] == 0xFE && b[1] == 0xFF) {
let le = b[0] == 0xFF;
let units: Vec<u16> =
b[2..].as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
return String::from_utf16_lossy(&units);
}
match std::str::from_utf8(b) {
Ok(s) => s.to_string(),
Err(_) => b.iter().map(|c| *c as char).collect(),
}
wordcraft_formats::txt::decode(b)
}

View File

@ -25,6 +25,28 @@ fn doc_extension_dispatches_to_docbin() {
}
}
#[test]
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
// #433: the TXT open path sniffs encodings like the formats importer.
let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body);
assert_eq!(open(b"\x80"), "€");
assert_eq!(open(b"A\0B\0"), "AB");
assert_eq!(open(b"\0A\0B"), "AB");
assert_eq!(open("€".as_bytes()), "€");
assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB");
assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe");
assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €");
}
#[test]
fn txt_save_writes_list_labels() {
// #434: list paragraphs keep their bullet or number and a tab in plain text.
let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes());
let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap();
assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta");
}
#[test]
fn every_command_has_unique_id() {
let reg = cmd::registry();

View File

@ -383,6 +383,10 @@ struct Builder<'r> {
para: Option<Para>,
/// The current paragraph continues a list item (joins the previous paragraph).
para_cont: bool,
/// Whether the current paragraph's first / last text was read with preserved white space
/// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends.
pre_lead: Option<bool>,
pre_tail: bool,
meta: Meta,
title: Option<String>,
}
@ -575,6 +579,8 @@ impl<'r> Builder<'r> {
stack: Vec::new(),
para: None,
para_cont: false,
pre_lead: None,
pre_tail: false,
meta: Meta::default(),
title: None,
}
@ -643,8 +649,17 @@ impl<'r> Builder<'r> {
fn flush(&mut self) {
let Some(mut p) = self.para.take() else { return };
let (pre_lead, pre_tail) = (self.pre_lead.take().unwrap_or(false), std::mem::take(&mut self.pre_tail));
if p.kind != Kind::Code {
p.trim();
// Preserved white space keeps its spaces and tabs at the edges; line breaks there
// (source formatting) still go.
const ALL: &[char] = &[' ', '\t', '\r', '\n'];
const BREAKS: &[char] = &['\r', '\n'];
p.trim_with(if pre_lead { BREAKS } else { ALL }, if pre_tail { BREAKS } else { ALL });
if p.text().trim().is_empty() {
// Only white space (indentation between blocks): nothing to keep.
p.trim();
}
}
if p.inlines.len() == 1 && p.text() == "\u{00A0}" {
// `<p>&nbsp;</p>`: an empty paragraph on purpose.
@ -712,6 +727,7 @@ impl<'r> Builder<'r> {
return;
}
if self.preserve() {
self.note_edge(true);
self.start_para().push_text(t, &f);
return;
}
@ -736,9 +752,18 @@ impl<'r> Builder<'r> {
if s.is_empty() {
return;
}
self.note_edge(false);
self.start_para().push_text(&s, &f);
}
/// Record whether text about to be added keeps its white space (see `pre_lead`).
fn note_edge(&mut self, preserved: bool) {
if self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) {
self.pre_lead = Some(preserved);
}
self.pre_tail = preserved;
}
fn pre_fresh(&self) -> bool {
self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
}
@ -1294,7 +1319,10 @@ fn table_html(t: &FTable, out: &mut String, depth: usize) {
if c.rowspan > 1 {
attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan));
}
let mut css = vec!["border:1px solid #999".to_string(), "padding:4px 6px".to_string(), "vertical-align:top".to_string()];
let mut css = vec!["padding:4px 6px".to_string(), "vertical-align:top".to_string()];
if !t.borderless {
css.insert(0, "border:1px solid #999".to_string());
}
if let Some(s) = c.shading {
css.push(format!("background-color:#{}", s.hex()));
}
@ -1474,6 +1502,31 @@ mod tests {
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").as_deref(), Some("TableGrid"));
}
#[test]
fn borderless_tables_export_without_cell_borders() {
// #438: a table without grid style and borders writes no cell borders and reopens borderless.
for (src, grid) in [
("<table style='border:none'><tr><td>A<td>B</table>", false),
("<table><tr><td>A<td>B</table>", false),
("<table border=1><tr><td>A<td>B</table>", true),
] {
let out = export(&crate::model::to_doc(&parse(src)));
assert_eq!(out.contains("border:1px"), grid, "{src}: {out}");
assert_eq!(table_style(&out).as_deref(), Some(if grid { "TableGrid" } else { "" }), "{src}");
assert!(out.contains(">A</td>") && out.contains(">B</td>"), "{out}");
}
}
#[test]
fn css_preformatted_paragraphs_keep_edge_spaces() {
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
// paragraphs still collapse.
let f = parse(
"<p style=\"white-space:pre\"> Alpha Beta </p><p style=\"white-space:pre-wrap\">\n Gamma \n</p><pre> Alpha </pre><p> Alpha Beta </p>",
);
assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]);
}
#[test]
fn tag_soup() {
let f = parse(

View File

@ -2076,12 +2076,15 @@ impl<'a> Parser<'a> {
Tok::Tie => self.text("\u{A0}"),
Tok::Amp => self.text(" "),
Tok::Open => {
// A group scopes declarations (`{\bfseries …}`).
// A group scopes declarations (`{\bfseries …}`, `{\centering …\par}`):
// character formatting and paragraph alignment come back at its end.
let toks = {
self.pos -= 1;
self.arg_tokens()
};
let saved_ctx = self.ctx;
self.inline_with(toks, |_| {});
self.ctx = saved_ctx;
}
Tok::Close => {}
Tok::Math(m, display) => {
@ -2862,6 +2865,21 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after.
\end{document}
"#;
#[test]
fn alignment_declarations_end_with_their_group() {
// #440: `{\centering …\par}` centres only inside the group; the outer alignment returns.
let align = |body: &str| -> Vec<(String, Option<Align>)> {
paras(&format!("\\documentclass{{article}}\\begin{{document}}{body}\\end{{document}}")).iter().map(|p| (p.text(), p.align)).collect()
};
let a = |t: &str, al: Option<Align>| (t.to_string(), al);
assert_eq!(align(r"{\centering Center\par}After\par"), vec![a("Center", Some(Align::Center)), a("After", None)]);
assert_eq!(
align(r"\raggedleft Outer\par{\centering Inner\par}Restored\par"),
vec![a("Outer", Some(Align::Right)), a("Inner", Some(Align::Center)), a("Restored", Some(Align::Right))]
);
assert_eq!(align(r"\centering One\par Two\par"), vec![a("One", Some(Align::Center)), a("Two", Some(Align::Center))]);
}
#[test]
fn article_structure() {
let flow = parse(ARTICLE);

View File

@ -132,8 +132,15 @@ impl Para {
/// Remove whitespace at the very start and end of the paragraph's text.
pub fn trim(&mut self) {
const WS: &[char] = &[' ', '\t', '\r', '\n'];
self.trim_with(WS, WS);
}
/// Remove the characters in `start` from the start of the paragraph's text and those in
/// `end` from its end.
pub fn trim_with(&mut self, start: &[char], end: &[char]) {
while let Some(Inline::Text(t, _)) = self.inlines.first_mut() {
let n = t.trim_start_matches([' ', '\t', '\r', '\n']).len();
let n = t.trim_start_matches(start).len();
let cut = t.len() - n;
t.replace_range(..cut, "");
if t.is_empty() {
@ -143,7 +150,7 @@ impl Para {
}
}
while let Some(Inline::Text(t, _)) = self.inlines.last_mut() {
let n = t.trim_end_matches([' ', '\t', '\r', '\n']).len();
let n = t.trim_end_matches(end).len();
t.truncate(n);
if t.is_empty() {
self.inlines.pop();
@ -792,7 +799,11 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable {
}
rows.push(cells);
}
FTable { rows, widths: t.grid.clone(), borderless: false }
// Without a table style and without visible table or cell borders the table shows no grid.
let visible = |b: &Option<wordcraft_doc::props::Borders>| b.as_ref().is_some_and(|b| b.any_visible());
let borderless =
t.props.style.is_none() && !visible(&t.props.borders) && t.rows.iter().flat_map(|r| &r.cells).all(|c| !visible(&c.props.borders));
FTable { rows, widths: t.grid.clone(), borderless }
}
/// The document body as a flow.

View File

@ -631,6 +631,8 @@ struct StyleInfo {
page_break: bool,
outline: Option<u8>,
border_bottom: bool,
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
cell_background: Option<Rgb>,
}
/// Length → points.
@ -786,6 +788,13 @@ impl Styles {
}
}
}
"table-cell-properties" => {
if let Some((_, s)) = &mut cur
&& let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color)
{
s.cell_background = Some(c);
}
}
"paragraph-properties" => {
if let Some((_, s)) = &mut cur {
s.align = match get(&a, "fo:text-align") {
@ -861,11 +870,22 @@ impl Styles {
f
}
/// Paragraph kind, alignment, page break, rule and base formatting (automatic styles only).
/// Background colour of a table-cell style (whole chain).
fn cell_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
}
/// Paragraph kind, alignment, page break, rule and base formatting. The text formatting of
/// every style in the chain applies (automatic and common, ODF 1.3 §16.2 inheritance), except
/// the style that gives the paragraph its kind and that style's ancestors: their formatting
/// is the kind's own look (a heading's size and colour), not direct formatting. `Standard`,
/// the default paragraph style, holds document defaults and is left out too.
fn para(&self, name: &str) -> (Kind, Option<Align>, bool, bool, Fmt) {
let chain = self.chain(name);
let mut kind = Kind::Normal;
for (n, s) in &chain {
let mut kind_at = chain.len();
for (i, (n, s)) in chain.iter().enumerate() {
kind_at = i;
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
let nn = n.to_ascii_lowercase().replace("_20_", " ");
for cand in [dn.as_str(), nn.as_str()] {
@ -886,12 +906,13 @@ impl Styles {
kind = Kind::Heading(o.min(6));
break;
}
kind_at = chain.len();
}
let align = chain.iter().find_map(|(_, s)| s.align);
let pb = chain.iter().take_while(|(_, s)| s.auto).any(|(_, s)| s.page_break);
let rule = chain.iter().any(|(_, s)| s.border_bottom);
let mut f = Fmt::default();
for (_, s) in chain.iter().rev().filter(|(_, s)| s.auto) {
for (_, s) in chain.iter().take(kind_at).rev().filter(|(n, s)| s.auto || *n != "Standard") {
s.fmt.apply(&mut f);
}
(kind, align, pb, rule, f)
@ -1149,7 +1170,7 @@ impl Body<'_> {
self.end_para();
let span = get(a, "table:number-columns-spanned").and_then(|v| v.parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
let shading = get(a, "table:style-name").and_then(|s| self.styles.map.get(s)).and_then(|s| s.fmt.background);
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
if let Some(t) = self.tables.last_mut() {
t.hcover = span as usize - 1;
}
@ -1404,4 +1425,57 @@ mod tests {
fn rejects_garbage() {
assert!(parse(b"not a zip").is_err());
}
const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
/// A package with `content.xml` and, when given, `styles.xml`.
fn package(content: &str, styles: Option<&str>) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in
[("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)].into_iter().chain(styles.map(|s| ("styles.xml", s)))
{
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
fn paras(f: &Flow) -> Vec<&Para> {
f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
}
#[test]
fn common_paragraph_styles_give_text_formatting() {
// #436: a common paragraph style's text properties reach its paragraphs, directly,
// through a derived common style and through an automatic style's parent.
let props = r##"<style:text-properties fo:font-weight="bold" fo:color="#ff0000" fo:font-size="24pt"/>"##;
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Emphasis" style:family="paragraph" style:parent-style-name="Standard">{props}</style:style><style:style style:name="Derived" style:family="paragraph" style:parent-style-name="Emphasis"/></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="Bridge" style:family="paragraph" style:parent-style-name="Emphasis"/></office:automatic-styles><office:body><office:text><text:p>Plain</text:p><text:p text:style-name="Emphasis">Common</text:p><text:p text:style-name="Derived">Derived</text:p><text:p text:style-name="Bridge">Bridge</text:p></office:text></office:body></office:document-content>"#
);
let f = parse(&package(&content, Some(&styles))).unwrap();
let ps = paras(&f);
assert_eq!(ps.len(), 4);
let fmt = |p: &Para| p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap();
assert_eq!(fmt(ps[0]), Fmt::default());
for p in &ps[1..] {
let f = fmt(p);
assert!(f.bold && f.color == Some(Rgb(255, 0, 0)) && f.size == Some(24.0), "{}: {f:?}", p.text());
}
}
#[test]
fn table_cell_style_background_is_shading() {
// #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading.
let content = format!(
r##"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="RedCell" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell table:style-name="RedCell"><text:p>Red</text:p></table:table-cell><table:table-cell><text:p>Plain</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"##
);
let f = parse(&package(&content, None)).unwrap();
let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
let row: Vec<_> = t.rows[0].iter().map(|c| (c.text(), c.shading)).collect();
assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]);
}
}

View File

@ -1048,7 +1048,9 @@ impl Reader {
}
return;
}
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => return,
// Text destinations take Unicode escapes (with fallback skipping) like the body.
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(w, "u" | "uc") => return,
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => {}
Dest::Body => {}
}
match w {
@ -1279,7 +1281,10 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
if word == "info" {
r.info_depth = Some(r.depth());
}
if r.skip_chars > 0 && r.st.dest == Dest::Body && !matches!(word, "par" | "pard" | "cell" | "row") {
if r.skip_chars > 0
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark)
&& !matches!(word, "par" | "pard" | "cell" | "row")
{
r.skip_chars -= 1;
first_in_group = false;
continue;
@ -1461,6 +1466,22 @@ mod tests {
Ok(())
}
#[test]
fn unicode_in_info_bookmarks_and_field_instructions() {
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
let rtf = br#"{\rtf1\ansi\uc1{\info{\title Caf\u233?}{\author Caf\u233\'3f}}{\*\bkmkstart caf\u233?}{\*\bkmkend caf\u233?}{\field{\*\fldinst{HYPERLINK "https://example.test/caf\u233?"}}{\fldrslt{link}}}\par Caf\u233?\par}"#;
let f = parse(rtf).unwrap();
assert_eq!(f.meta.title, "Café");
assert_eq!(f.meta.author, "Café");
let inl: Vec<&Inline> = f.blocks.iter().flat_map(|b| if let FBlock::Para(p) = b { p.inlines.iter().collect() } else { vec![] }).collect();
assert!(inl.iter().any(|i| matches!(i, Inline::Anchor(n) if n == "café")), "{inl:?}");
assert!(
inl.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "link" && f.link.as_deref() == Some("https://example.test/café"))),
"{inl:?}"
);
assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café"));
}
#[test]
fn rejects_non_rtf() {
assert!(parse(b"hello").is_err());

View File

@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc import fixes landed, #458–#463; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@ -83,6 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |