fix: a Word table draws only the borders it declares

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.
This commit is contained in:
Steffen Becker committed 2026-08-09 08:54:59 +00:00
1 parent 2f6e771f06
commit 46ae0211af
5 files changed
+98 -3

No files matched your search

+5 -1
View File
@@ -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
+5
View File
@@ -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.
+9 -1
View File
@@ -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'),
+15 -1
View File
@@ -179,6 +179,7 @@ export class DocxStyles {
private ownKeepLines = new Map<string, boolean>(); // style's own w:pPr/w:keepLines
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
private ownTblBorders = new Map<string, Element>(); // table style's own w:tblPr/w:tblBorders
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
@@ -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<string>()): 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 {};
+64
View File
@@ -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 = `<?xml version="1.0"?>
<w:styles xmlns:w="${W}">
<w:style w:type="table" w:default="1" w:styleId="NormalTable"><w:name w:val="Normal Table"/></w:style>
<w:style w:type="table" w:styleId="Formula"><w:name w:val="Formel-tab"/>
<w:basedOn w:val="NormalTable"/><w:tblPr/></w:style>
<w:style w:type="table" w:styleId="Grid"><w:name w:val="Table Grid"/><w:basedOn w:val="NormalTable"/>
<w:tblPr><w:tblBorders>
<w:top w:val="single" w:sz="4" w:color="auto"/><w:left w:val="single" w:sz="4" w:color="auto"/>
<w:bottom w:val="single" w:sz="4" w:color="auto"/><w:right w:val="single" w:sz="4" w:color="auto"/>
<w:insideH w:val="single" w:sz="4" w:color="auto"/><w:insideV w:val="single" w:sz="4" w:color="auto"/>
</w:tblBorders></w:tblPr></w:style>
<w:style w:type="table" w:styleId="Thick"><w:name w:val="Thick"/><w:basedOn w:val="Grid"/>
<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="24" w:color="FF0000"/></w:tblBorders></w:tblPr></w:style>
</w:styles>`;
const styledTable = (styleId: string | null) => `<w:tbl><w:tblPr>
${styleId ? `<w:tblStyle w:val="${styleId}"/>` : ''}
</w:tblPr><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>x</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`;
const docxWith = (body: string) =>
zipSync({
'word/document.xml': strToU8(
`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>${body}</w:body></w:document>`,
),
'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(
'</w:tblPr>',
'<w:tblBorders><w:top w:val="nil"/></w:tblBorders></w:tblPr>',
);
expect(importedSides(docxWith(own))).toEqual(['none', null, null, null]);
});
});