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:
1 parent
2f6e771f06
commit
46ae0211af
5 files changed
+98
-3
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user