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:
1 parent
f26de200fe
commit
e12a564f52
7 files changed
+141
-12
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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".
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user