mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
* #1380: WIP * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1380: nil raw shading keeps the style colour; no zero-cell rows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
0a1f2ce7a8
commit
b78adf2def
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: colour emoji on screen, #1269; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -170,6 +170,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
|
||||
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
|
||||
| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures |
|
||||
| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 |
|
||||
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
|
||||
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
|
||||
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
|
||||
@@ -336,6 +337,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
|
||||
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
|
||||
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
|
||||
| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) |
|
||||
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
|
||||
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
|
||||
|
||||
+88
-22
@@ -28,6 +28,15 @@ const T_TABLE_BORDERS_80: u16 = 0xD605;
|
||||
const T_ROW_HEIGHT: u16 = 0x9407;
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
|
||||
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
|
||||
const T_DEF_TABLE_SHD: u16 = 0xD612;
|
||||
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
|
||||
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
|
||||
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
|
||||
/// ranges, which Word 2007+ writes next to the above.
|
||||
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
|
||||
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
|
||||
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
|
||||
const T_CANT_SPLIT_90: u16 = 0x3403;
|
||||
const T_MERGE: u16 = 0x5624;
|
||||
const T_VERT_MERGE: u16 = 0xD62B;
|
||||
@@ -49,8 +58,10 @@ pub(crate) struct RowInfo {
|
||||
pub(crate) cell_mark: bool,
|
||||
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
|
||||
pub(crate) depth: u32,
|
||||
/// Column widths (points) from `sprmTDefTable`.
|
||||
pub(crate) grid: Vec<f32>,
|
||||
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
|
||||
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
|
||||
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
|
||||
pub(crate) edges: Vec<f32>,
|
||||
height: Option<f32>,
|
||||
height_rule: HeightRule,
|
||||
header: bool,
|
||||
@@ -62,7 +73,9 @@ pub(crate) struct RowInfo {
|
||||
merges: Vec<(u8, u8)>,
|
||||
/// Per-cell overrides keyed by (pre-merge) cell index.
|
||||
vmerge: Vec<(u8, VMerge)>,
|
||||
shading: Vec<(u8, Rgb)>,
|
||||
/// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an
|
||||
/// automatic `Shd` (`None`) clears it.
|
||||
shading: Vec<(u8, Option<Rgb>)>,
|
||||
widths: Vec<(u8, f32)>,
|
||||
valign: Vec<(u8, VAlign)>,
|
||||
}
|
||||
@@ -74,6 +87,12 @@ impl RowInfo {
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// The fill cell `i` ends up with (the last shading entry for it wins), for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
|
||||
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
@@ -87,7 +106,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32,
|
||||
T_DEF_TABLE => r.grid = def_table_grid(prl.operand),
|
||||
T_DEF_TABLE => r.edges = def_table_edges(prl.operand),
|
||||
T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
T_ROW_HEIGHT => {
|
||||
@@ -135,8 +154,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb)
|
||||
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
|
||||
{
|
||||
for c in *a..*b {
|
||||
r.shading.push((c, rgb));
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.shading.push((c, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,10 +163,29 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
|
||||
if let Some(rgb) = shd80_rgb(b) {
|
||||
r.shading.push((i as u8, rgb));
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => {
|
||||
// Each array covers its own cell range (0-21, 22-43, 44-62); extra entries
|
||||
// are ignored so an over-long array can't override the next range.
|
||||
let (first, lim) = match prl.op {
|
||||
T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44),
|
||||
T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS),
|
||||
_ => (0, 22),
|
||||
};
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD);
|
||||
for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() {
|
||||
// In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style
|
||||
// shades it", so it leaves any earlier fill alone.
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
|
||||
if let [a, b, fts, w0, w1, ..] = prl.operand
|
||||
@@ -221,8 +259,8 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
|
||||
c.props.width = Some(*w);
|
||||
}
|
||||
if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) {
|
||||
c.props.shading = Some(*s);
|
||||
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.shading = *s;
|
||||
}
|
||||
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
|
||||
c.props.valign = *v;
|
||||
@@ -295,6 +333,13 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
if !is_table {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
// Table props are per-table (first row carrying the sprm wins within the
|
||||
// table): reset the accumulator so one table's borders, align, indent and
|
||||
// cell margins never leak into the next (the repro's first table wrote
|
||||
// Word's default 108-twip gap, and every later table — including the
|
||||
// change history with its own 40-twip gap — inherited 5.4 pt margins).
|
||||
props = TableProps::default();
|
||||
open = false;
|
||||
depth_seen = 0;
|
||||
}
|
||||
@@ -305,8 +350,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
open = true;
|
||||
}
|
||||
depth_seen = depth_seen.max(info.depth);
|
||||
if !info.grid.is_empty() && grid.is_empty() {
|
||||
grid = info.grid.clone();
|
||||
if grid.is_empty() {
|
||||
// Column widths from the table's first `sprmTDefTable`.
|
||||
grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect();
|
||||
}
|
||||
if info.borders.is_some() && props.borders.is_none() {
|
||||
props.borders = info.borders;
|
||||
@@ -318,10 +364,25 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
props.indent = info.indent;
|
||||
}
|
||||
if info.cell_mark {
|
||||
// A row-end (TTP) mark terminates the row. Word writes rows as N content
|
||||
// cells each ended by their own cell mark, followed by an empty row-end mark
|
||||
// that carries the row's TDefTable (the 3GPP change history: 8 content marks
|
||||
// + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no
|
||||
// content of its own and the TDefTable already has a column per flushed cell,
|
||||
// it is a pure row terminator, not the end of a phantom (N+1)th cell — such a
|
||||
// cell has no column (width None) and shifts every real cell's span one grid
|
||||
// column over. A TTP mark with content, or one short of the TDefTable's
|
||||
// columns, still ends a real cell (rows without a TDefTable keep today's
|
||||
// shape: the count is unknowable, so the mark always flushes).
|
||||
let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len();
|
||||
// ...but a row-end mark with no cells before it still makes a (one-cell) row.
|
||||
let row_end_only = row_end_only && !cells.is_empty();
|
||||
if !p.para.text.is_empty() {
|
||||
cell.push(para_block(p.para));
|
||||
}
|
||||
flush_cell(&mut cells, &mut cell);
|
||||
if !row_end_only {
|
||||
flush_cell(&mut cells, &mut cell);
|
||||
}
|
||||
if info.ttp {
|
||||
let row = build_row(std::mem::take(&mut cells), &info);
|
||||
rows.push(row);
|
||||
@@ -336,21 +397,23 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
out
|
||||
}
|
||||
|
||||
/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1
|
||||
/// twip boundaries followed by the TC80s (skipped).
|
||||
fn def_table_grid(operand: &[u8]) -> Vec<f32> {
|
||||
/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then
|
||||
/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored:
|
||||
/// per-row, in operand order; sorting and capping happen when the table grid is assembled.
|
||||
fn def_table_edges(operand: &[u8]) -> Vec<f32> {
|
||||
let itc = operand.get(2).copied().unwrap_or(0) as usize;
|
||||
if itc == 0 || itc > MAX_COLS {
|
||||
if itc == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut edges = Vec::with_capacity(itc + 1);
|
||||
// itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end.
|
||||
let mut edges = Vec::with_capacity((itc + 1).min(256));
|
||||
for i in 0..=itc {
|
||||
match operand.get(3 + i * 2..3 + i * 2 + 2) {
|
||||
Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect()
|
||||
edges
|
||||
}
|
||||
|
||||
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
|
||||
@@ -381,7 +444,8 @@ fn brc80(b: &[u8]) -> Option<Border> {
|
||||
}
|
||||
|
||||
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
|
||||
/// show the foreground; other patterns are approximated by their background.
|
||||
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
|
||||
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
|
||||
fn shd_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
let cv = |b: &[u8]| -> Option<Rgb> {
|
||||
match b {
|
||||
@@ -392,19 +456,21 @@ fn shd_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
};
|
||||
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
|
||||
match ipat {
|
||||
0 | 0xFFFF => None,
|
||||
0xFFFF => None,
|
||||
1 => cv(b.get(0..4)?),
|
||||
_ => cv(b.get(4..8)?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields).
|
||||
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in
|
||||
/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word
|
||||
/// 97 writes a plain fill; ico 0 is automatic (no fill).
|
||||
fn shd80_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]);
|
||||
let fore = v & 0x1F;
|
||||
let back = (v >> 5) & 0x1F;
|
||||
let ipat = v >> 10;
|
||||
if ipat == 0 || (fore == 0x1F && back == 0x1F) {
|
||||
if fore == 0x1F && back == 0x1F {
|
||||
return None;
|
||||
}
|
||||
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
|
||||
|
||||
@@ -1906,3 +1906,269 @@ fn section_marks_end_paragraphs() {
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
|
||||
}
|
||||
|
||||
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
|
||||
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
|
||||
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
|
||||
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
|
||||
|
||||
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
|
||||
/// after it, plus one; Word may store fewer TC80s than cells).
|
||||
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
|
||||
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
|
||||
for e in edges {
|
||||
body.extend_from_slice(&e.to_le_bytes());
|
||||
}
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
|
||||
v.extend_from_slice(&body);
|
||||
v
|
||||
}
|
||||
|
||||
/// A variable-length table sprm whose operand is cb then `body`.
|
||||
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = op.to_le_bytes().to_vec();
|
||||
v.push(body.len() as u8);
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
|
||||
/// Word writes a plain cell fill.
|
||||
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
|
||||
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
|
||||
one.repeat(n)
|
||||
}
|
||||
|
||||
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
|
||||
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
|
||||
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![
|
||||
(0, 0, in_tbl.clone()), // a..e cell marks
|
||||
(10, 0, ttp(row1)), // row 1 end mark
|
||||
(11, 0, in_tbl.clone()),
|
||||
(21, 0, ttp(row2)),
|
||||
(22, 0, in_tbl),
|
||||
(29, 0, ttp(row3)),
|
||||
(30, 0, Vec::new()), // tail
|
||||
],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
read(&struct_doc(&spec)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_table_row_end_mark_and_cell_shading() {
|
||||
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
|
||||
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
|
||||
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
|
||||
// 365 writes it. The exact colours must beat the palette colour.
|
||||
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
|
||||
let row1 = [
|
||||
t_def_edges(&edges5),
|
||||
sprm_cb(0xD609, &shd80),
|
||||
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
|
||||
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
|
||||
]
|
||||
.concat();
|
||||
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
|
||||
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
|
||||
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
|
||||
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
|
||||
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
|
||||
// Row 3: one cell from the first edge to the last, filled FFF2CC.
|
||||
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
|
||||
let doc = word_table_doc(row1, row2, row3);
|
||||
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
|
||||
assert_eq!(tbl.grid.len(), 5, "grid columns");
|
||||
assert_eq!(tbl.rows.len(), 3);
|
||||
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
|
||||
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
|
||||
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
|
||||
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
|
||||
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
|
||||
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
|
||||
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
|
||||
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
|
||||
assert_eq!(legend, "legend");
|
||||
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
|
||||
assert_eq!(tail.text, "tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word97_shd80_clear_fill_shows_its_background() {
|
||||
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
|
||||
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
|
||||
// solid (ipat 1) with icoFore 2 (blue).
|
||||
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let papx = sprm_cb(0xD609, &shd80);
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
|
||||
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
|
||||
assert_eq!(row.shading_of(2), None);
|
||||
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shd_arrays_keep_to_their_cell_ranges() {
|
||||
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
|
||||
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
|
||||
let red = shd_clear([0xFF, 0, 0], 1);
|
||||
let green = shd_clear([0, 0xFF, 0], 1);
|
||||
let blue = shd_clear([0, 0, 0xFF], 1);
|
||||
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
|
||||
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
|
||||
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
|
||||
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
|
||||
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
|
||||
let papx = sprm_cb(first, &red);
|
||||
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_table_shading_and_spans_are_safe() {
|
||||
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
|
||||
// run backwards or past the grid; merges past the row's end.
|
||||
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
|
||||
let long = shd_clear([1, 2, 3], 6);
|
||||
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
|
||||
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
|
||||
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
|
||||
(
|
||||
{
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&[0xFF, 0x00, 200]);
|
||||
v.extend_from_slice(&[0x10; 24]);
|
||||
v
|
||||
},
|
||||
t_def_edges(&[7200, 0, -5, 30000]),
|
||||
t_def_edges(&[-32000, 32000]),
|
||||
),
|
||||
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
|
||||
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
|
||||
];
|
||||
for (r1, r2, r3) in rows {
|
||||
let doc = word_table_doc(r1, r2, r3);
|
||||
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
|
||||
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
|
||||
assert!(t.grid.len() <= 63);
|
||||
for r in &t.rows {
|
||||
assert!(r.cells.iter().all(|c| c.props.span <= 63));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
|
||||
// merge and a SHD array, then a normal row.
|
||||
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
|
||||
// must not turn the lone row-end mark into a row with no cells.
|
||||
let text = "\u{7}a\u{7}\u{7}tail\r";
|
||||
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
|
||||
let one_edge = {
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
|
||||
v.extend_from_slice(&[3, 0, 0]);
|
||||
v
|
||||
};
|
||||
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
|
||||
let spec = one_section(
|
||||
text,
|
||||
Vec::new(),
|
||||
vec![
|
||||
(0, 0, ttp(lone)),
|
||||
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
|
||||
(3, 0, ttp(t_def_edges(&[0, 1440]))),
|
||||
(4, 0, Vec::new()),
|
||||
],
|
||||
);
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
|
||||
for t in doc.body.iter().filter_map(|b| b.as_table()) {
|
||||
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nil_raw_shading_keeps_the_earlier_colour() {
|
||||
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
|
||||
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
|
||||
let blue = shd_clear([0, 0, 0xFF], 1);
|
||||
let red = shd_clear([0xFF, 0, 0], 1);
|
||||
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
|
||||
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
|
||||
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
|
||||
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(row.shading_of(2), None, "automatic clears");
|
||||
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
|
||||
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
|
||||
let cell = if first == 0xD616 { 22 } else { 44 };
|
||||
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_table_gets_its_own_grid() {
|
||||
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
|
||||
// comes from its own rows, not the first table's, and its borders, alignment and indent
|
||||
// are its own too (the first table's centred, bordered and indented).
|
||||
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
|
||||
let table1_props = {
|
||||
let mut borders = (0xD605u16).to_le_bytes().to_vec();
|
||||
borders.push(0x18);
|
||||
for _ in 0..6 {
|
||||
borders.extend_from_slice(&[4, 1, 1, 0]);
|
||||
}
|
||||
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
|
||||
};
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![
|
||||
(0, 0, in_tbl.clone()),
|
||||
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
|
||||
(11, 0, Vec::new()), // "between"
|
||||
(19, 0, in_tbl),
|
||||
(25, 0, ttp(&[0, 2000, 4000, 7200])),
|
||||
(26, 0, Vec::new()), // tail
|
||||
],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
|
||||
assert_eq!(tables.len(), 2);
|
||||
assert_eq!(tables[0].grid.len(), 5);
|
||||
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
|
||||
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
|
||||
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
|
||||
assert_eq!(tables[1].props.borders, None, "borders reset");
|
||||
assert_eq!(tables[1].props.align, None, "alignment reset");
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously 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
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously 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;
|
||||
@@ -22,8 +22,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
|
||||
| `.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` (56) | 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]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. 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 |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
|
||||
@@ -172,6 +172,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
| 2026-10-14 | trivial | HTML: `<th>` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) |
|
||||
| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) |
|
||||
| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) |
|
||||
| 2026-10-14 | trivial | `.doc` tables: cell shading read from every SHD record (`sprmTDefTableShd`/`ShdRaw` and their 2nd/3rd ranges for cells 22-62; Word 97 `Shd80` clear fills now show their background colour; exact colours win over the palette fallback), the row-end mark no longer becomes an extra empty column, and a table no longer inherits the previous table's grid, borders, alignment or indent (#1380). Horizontally merged rows (a row of fewer, wider cells) come with #1123 |
|
||||
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
|
||||
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
|
||||
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
|
||||
|
||||
Reference in New Issue
Block a user