mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
Word 2024 saves Strict Open XML with measures written out with their unit: the issue's file carries `w:spacing w:after="8pt" w:line="13.90pt" w:lineRule="auto"` in its paragraph defaults. The reader sent `13.90` straight into the `auto` branch, where a bare number counts 240ths of a line (240 = single), so 13.90pt became a 13.9 multiple: 191.8 pt lines, and the issue's document laid out as 10 pages against Word's 1. Under `lineRule="auto"` a unit-suffixed height now reads through twips, as Word renders it (ISO 29500 `ST_SignedTwipsMeasure`: 13.90 pt = 278 twips = the 1.1583 multiple). A bare number still counts 240ths of a line; `exact`/`atLeast` already read units as points. Checked with the issue's file: 1 page with baselines on Word's (15.98 pt line advance against Word's 16.10/15.96; paragraph advance 23.99 against 24.00; table lines 12.65 against 12.60/12.72), and a save/reopen keeps it. Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
4deb654862
commit
a14948a23d
@@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip |
|
||||
| 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved |
|
||||
| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) |
|
||||
| 2026-10-16 | Strict OOXML line heights render as Word renders them (#2329): `w:line="13.90pt"` under `lineRule="auto"` is a height read through twips (13.90 × 20 = 278; the 1.1583 multiple Word shows), not the 13.9 multiple the bare transitional number counts, so a Word 2024 file with 13.9 lines of line spacing opens at Word's 1 page with matching baselines instead of 10 |
|
||||
| 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt |
|
||||
| 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
|
||||
@@ -14,7 +14,7 @@ use wordcraft_doc::section::{
|
||||
};
|
||||
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -315,7 +315,8 @@ impl PropCtx {
|
||||
p.line_spacing = match rule.as_str() {
|
||||
"exact" => measure(line, 20.0).map(|v| LineSpacing::Exactly(v.abs())),
|
||||
"atleast" => measure(line, 20.0).map(|v| LineSpacing::AtLeast(v.abs())),
|
||||
_ => measure(line, 240.0).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
// Strict OOXML writes the height with a unit (`13.90pt`, #2329).
|
||||
_ => line_multiple(line).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
|
||||
}
|
||||
|
||||
/// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =
|
||||
/// single). Without a unit the value already is that — the transitional form. A universal
|
||||
/// measure is a height (strict OOXML writes `w:line="13.90pt"`, ISO 29500
|
||||
/// `ST_SignedTwipsMeasure`), so it goes through twips as Word reads it: × 20 ÷ 240 (13.90 pt =
|
||||
/// 278 twips, the 1.1583 multiple Word renders).
|
||||
pub fn line_multiple(s: &str) -> Option<f32> {
|
||||
let t = s.trim();
|
||||
let measured = t.ends_with(|c: char| c.is_ascii_alphabetic());
|
||||
measure(t, 240.0).map(|v| if measured { v * 20.0 / 240.0 } else { v })
|
||||
}
|
||||
|
||||
/// Plain integer (accepts decimals, rounds; clamps into i64 range).
|
||||
pub fn int(s: &str) -> Option<i64> {
|
||||
let t = s.trim();
|
||||
@@ -98,6 +109,16 @@ mod tests {
|
||||
assert_eq!(measure("1e400", 20.0), None);
|
||||
assert_eq!(measure("99999999999999", 20.0), Some(MAX_LEN_PT));
|
||||
assert_eq!(measure("x", 20.0), None);
|
||||
// `w:line` auto: the multiple in 240ths without a unit, through twips with one (#2329).
|
||||
assert_eq!(line_multiple("240"), Some(1.0));
|
||||
assert_eq!(line_multiple("276"), Some(1.15));
|
||||
assert_eq!(line_multiple("0"), Some(0.0));
|
||||
let strict = line_multiple("13.90pt").expect("strict line height");
|
||||
assert!((strict - 278.0 / 240.0).abs() < 1e-5, "{strict}");
|
||||
let inches = line_multiple("1.2in").expect("inches");
|
||||
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
|
||||
assert_eq!(line_multiple("junk"), None);
|
||||
assert_eq!(line_multiple(""), None);
|
||||
assert_eq!(int("12.6"), Some(13));
|
||||
assert_eq!(u32_of("-5"), Some(0));
|
||||
}
|
||||
|
||||
@@ -882,6 +882,28 @@ fn line_rule_is_read_case_insensitively() {
|
||||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(15.0)));
|
||||
}
|
||||
|
||||
/// #2329: Word 2024 saves strict OOXML with the line height written out with a unit
|
||||
/// (`w:line="13.90pt"`). Under `auto` it reads through twips, as Word renders it (13.90 × 20 =
|
||||
/// 278; the 1.1583 multiple), not as 240ths of a line like the bare number — a Word file with
|
||||
/// a `line="13.90pt"` default came in at 13.9 lines per line of text.
|
||||
#[test]
|
||||
fn strict_line_heights_with_a_unit_are_read_as_point_multiples() {
|
||||
let d = read_body(
|
||||
r#"<w:p><w:pPr><w:spacing w:after="8pt" w:line="13.90pt" w:lineRule="auto"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p><w:p><w:pPr><w:spacing w:line="13.90pt" w:lineRule="exact"/></w:pPr></w:p>"#,
|
||||
);
|
||||
let p = paras(&d);
|
||||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m)) = p[0].props.line_spacing else {
|
||||
panic!("auto line spacing: {:?}", p[0].props.line_spacing)
|
||||
};
|
||||
assert!((m - 278.0 / 240.0).abs() < 1e-5, "{m}");
|
||||
assert_eq!(p[0].props.space_after, Some(8.0), "the unit form of w:after too");
|
||||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(13.9)), "exact keeps reading it as points");
|
||||
// Saves as the transitional 240ths form and reads back the same multiple.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m2)) = paras(&again)[0].props.line_spacing else { panic!("multiple after a save") };
|
||||
assert!((m2 - m).abs() < 1e-5, "{m2} vs {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_border_reads_and_nil_resolves_off() {
|
||||
let d = read_body(
|
||||
|
||||
@@ -36,7 +36,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read |
|
||||
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
@@ -226,6 +226,7 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | RTF export writes sections: `\sectd` formatting and `\sect` per section, round-tripping through the reader (gap 19) |
|
||||
| 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) |
|
||||
| 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) |
|
||||
| 2026-10-14 | trivial | ODT export writes sections: each new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is a columned `text:section` (gap 19) |
|
||||
| 2026-10-17 | trivial | Paste: an HTML `<blockquote>` (LibreOffice's both-sides indent) pastes as a Normal paragraph with its margins as indents, not the centred italic Quote style; opening .html unchanged (#1270) |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
|
||||
Reference in New Issue
Block a user