From 46ae0211af147a5facd6385eff863f9910b584a3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 9 Aug 2026 08:54:59 +0000 Subject: [PATCH] fix: a Word table draws only the borders it declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DOCX importer read w:tblBorders off the table but never off its table style, so a table whose style declares none (Word's Normal Table, and the thesis' Formel-tab around every formula) fell back to the editor's 0.5pt black default. Resolved per side along the w:basedOn chain, and an undeclared side is not drawn — LibreOffice writes fo:border="none" for exactly those tables when it re-saves the file. The ODF importer already treated an undeclared side that way. --- CHANGELOG.md | 6 ++- src/lib/import/CLAUDE.md | 5 +++ src/lib/import/docx.ts | 10 ++++- src/lib/import/docxStyles.ts | 16 +++++++- tests/unit/table-borders.test.ts | 64 ++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82656dd..4c4bea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,7 +181,11 @@ merely unimplemented belongs in the list above, not here. - A text box anchored inside a paragraph loses the vertical offset it was anchored by: it is a block node here, so the importer lifts it out and it simply follows that paragraph (measured 4.7 mm on the thesis' figure page). - Noted 2026-08-09. + It also costs flow height a word processor does not spend: Word's picture + caption is a text box declared 0.05 pt tall that overflows its own box, so + LibreOffice reserves nothing for it and the empty paragraph after it holds the + caption. Here it is a block, ~55 px — enough to push two lines off the thesis' + page 15 and keep it one page behind through page 45. Noted 2026-08-09. - Line height follows the paragraph, not the line: the block's CSS strut applies to every line, where a word processor takes each line's own runs. A paragraph whose runs all agree takes theirs, so only a paragraph of *mixed* sizes struts diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index a7bc5d6..ad3c2c0 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -26,6 +26,11 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Defaults on DOCX import +**Table borders** come from the table's own `w:tblBorders`, else its table style's along the +`w:basedOn` chain (per side, nearest declaring style wins). A side nobody declares is **not +drawn**: Word's Normal Table has no border, and the gridlines it shows on screen are not +printed. ODF says the same for an undeclared `fo:border`, so both importers agree. + Body text with no resolved font falls back to the *document's own theme minor font* (`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't (they keep the editor heading default); DOCX page margins default to Word's 2.54cm. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index a70843c..3c3fd82 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1539,8 +1539,16 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { const pad = docxCellPadding(tbl, styleId, ctx); const padBase = pad ?? DEFAULT_CELL_PADDING; + // The table's own w:tblBorders, then its style's; a side nobody declares is not drawn + // (Word's Normal Table has no border — its on-screen gridlines are not printed). const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders'); - const tblSide = (name: string) => docxBorderAttr(fc(tblBorders, name)); + const tblSide = (name: string) => { + for (const el of [tblBorders, ...ctx.styles.tableBorders(styleId)]) { + const v = docxBorderAttr(fc(el, name)); + if (v !== undefined) return v; + } + return 'none'; + }; const tblDef = { top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'), right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'), diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 91b5b51..fefdc71 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -179,6 +179,7 @@ export class DocxStyles { private ownKeepLines = new Map(); // style's own w:pPr/w:keepLines private ownTabs = new Map(); // style's own w:pPr/w:tabs private ownCellMar = new Map(); // table style's own w:tblPr/w:tblCellMar + private ownTblBorders = new Map(); // table style's own w:tblPr/w:tblBorders private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); @@ -253,8 +254,11 @@ export class DocxStyles { const left = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(left)) this.ownIndentTwip.set(id, left); } - const cellMar = firstChild(style, 'tblPr') && firstChild(firstChild(style, 'tblPr')!, 'tblCellMar'); + const tblPr = firstChild(style, 'tblPr'); + const cellMar = tblPr && firstChild(tblPr, 'tblCellMar'); if (cellMar) this.ownCellMar.set(id, cellMar); + const tblBorders = tblPr && firstChild(tblPr, 'tblBorders'); + if (tblBorders) this.ownTblBorders.set(id, tblBorders); const kind = style.getAttributeNS(W, 'type'); if (kind === 'paragraph' || kind === 'character' || kind === 'table') { const nameEl = firstChild(style, 'name'); @@ -466,6 +470,16 @@ export class DocxStyles { return this.ownCellMar.get(styleId) ?? this.tableCellMar(this.basedOn.get(styleId) ?? null, seen); } + // Every w:tblBorders along the w:basedOn chain, leaf first. The sides merge one by one, + // so the caller takes each from the nearest style that declares it. + tableBorders(styleId: string | null | undefined, seen = new Set()): Element[] { + if (!styleId || seen.has(styleId)) return []; + seen.add(styleId); + const own = this.ownTblBorders.get(styleId); + const rest = this.tableBorders(this.basedOn.get(styleId) ?? null, seen); + return own ? [own, ...rest] : rest; + } + level(numId: number, ilvl: number): LevelDef { const abs = this.numToAbstract.get(String(numId)); if (!abs) return {}; diff --git a/tests/unit/table-borders.test.ts b/tests/unit/table-borders.test.ts index 0df6a5f..d52bef9 100644 --- a/tests/unit/table-borders.test.ts +++ b/tests/unit/table-borders.test.ts @@ -16,6 +16,8 @@ import { type BorderPreset, type BorderSpec, } from '../../src/lib/editor/extensions/tableCellBorders'; import { borderAttrFromOdf } from '../../src/lib/import/odt'; +import { zipSync, strToU8 } from 'fflate'; +import { importDocx } from '../../src/lib/import/docx'; type N = any; @@ -243,3 +245,65 @@ describe('border attr helpers', () => { expect(borderAttrFromOdf('0.06pt double #808080')).toBe('0.06pt solid #808080'); // style coerced }); }); + +// A Word table draws only what something declares: its own w:tblBorders, else its table +// style's chain. Nothing anywhere (Normal Table) is no border — the gridlines Word shows +// on screen are not printed. +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +const TABLE_STYLES = ` + + + + + + + + + + + + +`; + +const styledTable = (styleId: string | null) => ` + ${styleId ? `` : ''} + + x`; + +const docxWith = (body: string) => + zipSync({ + 'word/document.xml': strToU8( + `${body}`, + ), + 'word/styles.xml': strToU8(TABLE_STYLES), + }); + +const importedSides = (bytes: Uint8Array) => { + const table = (importDocx(bytes).content.content as N[]).find((n: N) => n.type === 'table')!; + const attrs = table.content[0].content[0].attrs; + return ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'].map((s) => attrs[s] ?? null); +}; + +describe('DOCX table borders', () => { + it('draws nothing when no style in the chain declares one', () => { + expect(importedSides(docxWith(styledTable('Formula')))).toEqual(['none', 'none', 'none', 'none']); + expect(importedSides(docxWith(styledTable(null)))).toEqual(['none', 'none', 'none', 'none']); + }); + + it("takes the table style's borders, the editor default left off the attrs", () => { + expect(importedSides(docxWith(styledTable('Grid')))).toEqual([null, null, null, null]); + }); + + it('lets a leaf style override one side and inherit the rest', () => { + expect(importedSides(docxWith(styledTable('Thick')))).toEqual(['3pt solid #FF0000', null, null, null]); + }); + + it('lets the table’s own w:tblBorders win over its style', () => { + const own = styledTable('Grid').replace( + '', + '', + ); + expect(importedSides(docxWith(own))).toEqual(['none', null, null, null]); + }); +});