fix: a numbered heading crosses into .docx as a heading, not a list item

Chapter numbering rides the heading styles' w:numPr, so re-import read every
chapter as a list item — which swallowed the page break in front of it, since
the break is read for body paragraphs only. Headings now also write their own
w:outlineLvl, so a document setting its chapters in a style of its own
("Appendix 1") keeps them headings. SVG goes out rasterized: Word reads none,
and the importer makes one of every EMF too.

The corpus cross-format leg now carries the file's own stylesheet, where both
the numbering and a heading's style live; measured on one fixture, the leg
ODT → .docx → back loses 0 of 34 headings and 0 of 7 page breaks, was 7 and 7.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:11 +02:00
1 parent f26de200fe
commit e12a564f52
7 files changed
+141 -12

No files matched your search

+8
View File
@@ -59,6 +59,14 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
- **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex).
- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness).
- **A heading writes its own `w:outlineLvl`** beside its `w:pStyle`: the style carries the
look, the level says it is a heading at all, and a document setting its chapters in a
style of its own leaves the importer nothing else to go by.
- **SVG is rasterized on the way into a .docx** (`rasterizeSvgImages`, before the walk —
the emitters that place pictures are synchronous): Word reads no SVG, and an EMF arrives
as one too, so a vector picture is drawn to PNG at 2× its placed size. Off the main
thread there is no canvas and the picture is skipped, as it always was.
- **Factory style slots** (`FACTORY_SLOTS`, `buildStyles`): the `docx` package always writes its own Title/Heading1–6 into styles.xml, so the registry's versions ride `styles.default.title/headingN` instead of `paragraphStyles` — a second definition under the same `w:styleId` makes Word **and** LibreOffice drop the `basedOn` chain (headings lose their sans/bold). Table and numbering styles are **spliced post-pack** (`applyRawStylesDocx`): passing them as `importedStyles` makes the package's Styles merge replace its whole factory set, `w:docDefaults` (default font/size/language) included.
The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`.
+21
View File
@@ -15,6 +15,7 @@ import type {
ILevelsOptions, IFloating, IBorderOptions, IParagraphStyleOptions, ICharacterStyleOptions,
} from 'docx';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { isSvgDataUrl, svgToPngDataUrl } from '../import/imageFormats';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { SHAPES, isShapeKind, drawingMlPath, type ShapeKind } from '../utils/shapes';
import { cellFormatCode, isCellFormat } from '../utils/cellFormat';
@@ -894,6 +895,20 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
};
}
// An SVG picture — which is also what the importer makes of an EMF — has no place in a
// .docx, so it is drawn to PNG first. Where no canvas can (the test suite), the node is
// left alone and the emitters skip it, as they did before.
async function rasterizeSvgImages(node: TiptapNode): Promise<TiptapNode> {
if (node.type === 'image' && isSvgDataUrl(node.attrs?.src)) {
const w = typeof node.attrs?.width === 'number' && node.attrs.width > 0 ? node.attrs.width : 200;
const h = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? node.attrs.height : 150;
const png = await svgToPngDataUrl(node.attrs.src as string, w, h);
return png ? { ...node, attrs: { ...node.attrs, src: png } } : node;
}
if (!node.content?.length) return node;
return { ...node, content: await Promise.all(node.content.map(rasterizeSvgImages)) };
}
function imageRun(node: TiptapNode): ImageRun | null {
const src = node.attrs?.src;
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
@@ -2122,6 +2137,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
spacing: opts.inCell ? cellSpacingOf(attrs, node) : spacingOf(attrs),
indent: Object.keys(indent).length ? indent : undefined,
pageBreakBefore: attrs.breakBefore === 'page' || undefined,
// w:outlineLvl — the locale- and name-independent heading signal. A heading wearing
// a style of its own ("Appendix 1") has nothing else that says what level it is.
outlineLevel: node.type === 'heading' ? ((attrs.level as number) ?? 1) - 1 : undefined,
widowControl: attrs.widowControl === false ? false : undefined,
keepNext: attrs.keepNext === true || undefined,
keepLines: attrs.keepLines === true || undefined,
@@ -2695,6 +2713,9 @@ export async function buildDocx(
spacingAtPageStart = true,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
// Before the walk: every picture the file can hold has to be a raster by then, and
// rasterizing is async where the emitters that place them are not.
docJson = await rasterizeSvgImages(docJson);
exportSheet = styles;
exportSpacingModel = spacingModel;
docFormulas = [];
+8
View File
@@ -110,3 +110,11 @@ Body text with no resolved font falls back to the *document's own theme minor fo
document's own comes from the **first** section, as the paper and the margins already do.
- **Chapter numbering** — `text:outline-style` on the ODF side, the heading styles'
`w:numPr` on Word's — becomes `StyleSheet.outline`; see `src/lib/styles/CLAUDE.md`.
A numbered heading is therefore **never a list item** (`paragraphNum` returns null for
anything with an outline level): read as one, every chapter came back wrapped in an
`orderedList` that swallowed the `w:pageBreakBefore` in front of it — `blockAttrs`
reads the break for `kind === 'body'` only.
- **A heading is found by `w:outlineLvl`, not only by its style's name.** A file may set
its chapters in a style of its own ("Appendix 1") whose `w:basedOn` chain reaches
Heading1; `styleOutlineLvl` walks that chain, and the exporter writes the level on the
paragraph too, so nothing rests on a style being called "Heading n".
+7 -1
View File
@@ -644,7 +644,13 @@ function paragraphNum(el: Element, ctx: Ctx): { numId: number; ilvl: number } |
let np: { numId: number; ilvl: number } | null = null;
const numPr = fc(ppr, 'numPr');
if (numPr) np = readNumPr(numPr);
if (!np) { const ps = fc(ppr, 'pStyle'); np = ctx.styles.styleNumPr(ps ? wVal(ps) : null); }
if (!np) {
// Chapter numbering rides the heading styles' own w:numPr, which is how both
// products write it — a numbered heading is a heading, never a list item.
if (headingLevelOf(ppr, ctx) != null) return null;
const ps = fc(ppr, 'pStyle');
np = ctx.styles.styleNumPr(ps ? wVal(ps) : null);
}
return np && np.numId !== 0 ? np : null; // numId 0 = "no list"
}
+32
View File
@@ -203,3 +203,35 @@ export function placeholderImage(label: string, widthPx: number, heightPx: numbe
+ ` text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
// Word reads no SVG — a vector picture goes into a .docx as pixels or not at all. The
// browser is the only renderer at hand, so the export draws it onto a canvas; off the
// main thread (the test suite) there is none and the caller keeps the vector.
const SVG_RASTER_SCALE = 2; // the picture is placed at CSS px, so raster at 2× for print
export function isSvgDataUrl(src: unknown): src is string {
return typeof src === 'string' && src.startsWith('data:image/svg+xml');
}
export async function svgToPngDataUrl(src: string, widthPx: number, heightPx: number): Promise<string | null> {
if (typeof document === 'undefined' || typeof Image === 'undefined') return null;
const w = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE));
const h = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE));
try {
const img = new Image();
await new Promise<void>((ok, fail) => {
img.onload = () => ok();
img.onerror = () => fail(new Error('svg'));
img.src = src;
});
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, 0, 0, w, h);
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
+19 -11
View File
@@ -21,14 +21,19 @@ const files = existsSync(FIX)
: [];
const load = (f: string) => new Uint8Array(readFileSync(join(FIX, f)));
const importAny = (f: string, bytes: Uint8Array): N =>
(f.endsWith('.odt') ? importOdt(bytes) : importDocx(bytes)).content;
const readAny = (f: string, bytes: Uint8Array) =>
f.endsWith('.odt') ? importOdt(bytes) : importDocx(bytes);
const importAny = (f: string, bytes: Uint8Array): N => readAny(f, bytes).content;
// A block's type and its text, flattened depth-first: the shape a round trip must keep.
// The heading attrs ride along: a chapter that loses its level, its own style or the
// break in front of it still says the same words, and reflows the document anyway.
function outline(node: N, out: string[] = []): string[] {
for (const child of node.content ?? []) {
if (child.type === 'text') continue;
out.push(`${child.type}:${textOf(child).replace(/\s+/g, ' ').trim()}`);
const a = child.type === 'heading' ? child.attrs ?? {} : null;
const head = a ? `heading${a.level}${a.styleName ? `[${a.styleName}]` : ''}${a.breakBefore ? '+brk' : ''}` : child.type;
out.push(`${head}:${textOf(child).replace(/\s+/g, ' ').trim()}`);
outline(child, out);
}
return out;
@@ -76,17 +81,20 @@ describe.skipIf(!files.length)('the authored corpus', () => {
}
// The same document in both formats, and each one exported as the other: the four
// legs a document takes through this editor have to agree on what it says.
// legs a document takes through this editor have to agree on what it says. The file's
// **own stylesheet** rides the cross legs, as it does when the app saves — chapter
// numbering and a heading's own style live there and nowhere in the document tree, so
// an export handed builtinStyleSheet() never carries either.
for (const name of files.filter((f) => f.endsWith('.docx')).map((f) => f.slice(0, -5))) {
it(`${name} reads the same out of either format`, async () => {
const fromDocx = importAny(`${name}.docx`, load(`${name}.docx`));
const fromOdt = importAny(`${name}.odt`, load(`${name}.odt`));
expect(outline(fromOdt)).toEqual(outline(fromDocx));
const docx = readAny(`${name}.docx`, load(`${name}.docx`));
const odt = readAny(`${name}.odt`, load(`${name}.odt`));
expect(outline(odt.content)).toEqual(outline(docx.content));
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
expect(outline(importOdt(await buildOdt(fromDocx, margins, 'portrait')).content))
.toEqual(outline(fromDocx));
expect(outline(importDocx(await buildDocx(fromOdt, margins, 'portrait')).content))
.toEqual(outline(fromOdt));
const asOdt = await buildOdt(docx.content, margins, 'portrait', undefined, undefined, undefined, docx.styles);
expect(outline(importOdt(asOdt).content)).toEqual(outline(docx.content));
const asDocx = await buildDocx(odt.content, margins, 'portrait', undefined, undefined, undefined, odt.styles);
expect(outline(importDocx(asDocx).content)).toEqual(outline(odt.content));
});
}
});
+46
View File
@@ -1371,3 +1371,49 @@ describe('DOCX named list styles', () => {
expect(imported?.levels[0]?.indentCm).toBeCloseTo(0.73, 2); // 1134 twips = 2cm → +0.73
});
});
// Chapter numbering rides the heading styles (w:numPr in styles.xml), and a heading may
// wear a style of its own — both only reach the export through the document's own
// stylesheet, so a fixture built on builtinStyleSheet() never sees either.
describe('numbered headings under a document stylesheet', () => {
const outline = Array.from({ length: 10 }, (_, i) => ({
format: '1' as const, prefix: '', suffix: '', displayLevels: i + 1, start: 1,
}));
const sheet = () => {
const s = builtinStyleSheet();
s.outline = outline;
// A heading style the name test can't recognise: only its outline level says so.
s.paragraph['Appendix 1'] = { name: 'Appendix 1', parent: 'Heading 1', next: 'Standard', outlineLevel: 1, para: {}, text: {} };
return s;
};
const doc = {
type: 'doc',
content: [
heading(1, 'Plain chapter', { breakBefore: 'page' }),
para('body'),
heading(1, 'Appendix', { styleName: 'Appendix 1', breakBefore: 'page' }),
para('more'),
],
} as any;
const roundTrip = async () => importDocx(await buildDocx(doc, undefined, undefined, undefined, undefined, undefined, sheet())).content as N;
it('keeps a numbered heading a heading instead of a list item', async () => {
const back = await roundTrip();
expect(walk(back, 'orderedList')).toHaveLength(0);
expect(walk(back, 'listItem')).toHaveLength(0);
expect(walk(back, 'heading').map((h) => h.attrs.level)).toEqual([1, 1]);
});
it('keeps the page break in front of a numbered heading', async () => {
const back = await roundTrip();
expect(walk(back, 'heading').map((h) => h.attrs.breakBefore)).toEqual(['page', 'page']);
});
it('keeps a heading whose style is not named "Heading n"', async () => {
const back = await roundTrip();
const named = walk(back, 'heading').find((h) => h.attrs.styleName === 'Appendix 1');
expect(named, 'the custom heading style survives as a heading').toBeTruthy();
expect(named!.attrs.level).toBe(1);
});
});