feat: named list styles — DOCX round-trip

One shared abstract per style (each list its own w:num instance, so numbering
restarts); a post-pack pass injects w:styleLink and the numbering style's real
numId, which the docx library cannot express. Import follows w:styleLink and
w:numStyleLink — a linked abstract used to resolve to {} and silently import
as a bullet list — maps the style onto listStyleName + sheet.list, and derives
no per-level attrs. Probed: LO ignores DOCX numbering styles both ways (mints
WWNum# on import, flattens to the look on export); ODF carries the identity.
This commit is contained in:
Steffen Becker committed 2026-08-25 22:19:14 +00:00
1 parent e2525237de
commit b0de0311a8
6 files changed
+359 -33

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
- **`applyListStyleNames`** — a list carrying an unoverridden `listStyleName` is repointed from its `L#` automatic style to the named `<text:list-style>` `applyNamedStyles` writes into styles.xml (its nested lists stay bare and inherit); runs after every `L#`-keyed pass. The list collectors resolve attrs through `effectiveListLevel`, so an overridden styled list keeps a fully resolved `L#` clone — see `src/lib/styles/CLAUDE.md`.
- **`applyListStyleNames`** — a list carrying an unoverridden `listStyleName` is repointed from its `L#` automatic style to the named `<text:list-style>` `applyNamedStyles` writes into styles.xml (its nested lists stay bare and inherit); runs after every `L#`-keyed pass. The list collectors resolve attrs through `effectiveListLevel`, so an overridden styled list keeps a fully resolved `L#` clone — see `src/lib/styles/CLAUDE.md`. **DOCX**: `Numbering.refForStyle` shares one abstract per style (each list its own `w:num` instance, so numbering restarts) and `applyListStylesDocx` injects the `w:styleLink` + the `w:type="numbering"` style's real numId post-pack — the library can express neither. A list with a `start` attr keeps a private clone (the shared abstract starts every instance at the style's start). Probed: LibreOffice ignores DOCX numbering styles both ways (its import mints `WWNum#`, its export flattens to the look) — the identity is a Word capability; ODF carries it in both products.
- **`applyListStartValues`** — odf-kit drops an ordered list's `start` attr; a Word list continued across an intervening paragraph (same numId, split into separate `orderedList` nodes with `start` > 1 by the importer) gets `text:start-value` on its first `<text:list-item>` so numbering keeps counting instead of restarting at 1.
- **`applyTableRowHeights`** — dragged row heights → `style:min-row-height` automatic table-row styles.
- **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
+160 -29
View File
@@ -34,7 +34,8 @@ import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCell
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops';
import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, orderedTypeDef, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { effectiveListLevel, listStyleMarginCm, listStyleOverridden, type ListStyle as ListStyleDef } from '../styles/listStyles';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
@@ -73,10 +74,29 @@ type Writable<T> = { -readonly [P in keyof T]: T[P] };
const SCREEN_FONT = 'Liberation Serif';
const DOC_FONT = 'Times New Roman';
// Effective bullet glyph of a list node: its bulletChar attr, else the default cycle.
function bulletCharOf(node: TiptapNode, depth0: number): string {
const ch = node.attrs?.bulletChar;
return typeof ch === 'string' && ch ? ch : defaultBulletChar(depth0);
// Effective bullet glyph of a list node: its bulletChar attr, else its named list
// style's level, else the default cycle.
function bulletCharOf(node: TiptapNode, depth0: number, style: ListStyleDef | null = null): string {
const ch = effectiveListLevel(node.attrs ?? {}, false, style, depth0 + 1).bulletChar;
return ch ?? defaultBulletChar(depth0);
}
// The named list style governing a top-level list (null = none, or an unknown name).
function listStyleOf(node: TiptapNode): ListStyleDef | null {
const name = node.attrs?.listStyleName;
return typeof name === 'string' && name ? exportSheet.list?.[name] ?? null : null;
}
// The list's numbering key after its style (node attr ?? style level ?? null).
function effOrderedKey(node: TiptapNode, style: ListStyleDef | null, depth0: number): string | null {
return effectiveListLevel(node.attrs ?? {}, node.type === 'orderedList', style, depth0 + 1).listStyleType;
}
// A `start` attr anywhere means a numbering continued across a split — only a private
// reference can carry that; the style's shared abstract starts every instance at 1.
function subtreeHasStart(node: TiptapNode): boolean {
if (typeof node.attrs?.start === 'number' && node.attrs.start > 1) return true;
return (node.content ?? []).some(subtreeHasStart);
}
// List geometry: 0.5in left step per level, 0.25in hanging for the marker (Word defaults).
@@ -237,6 +257,7 @@ class Numbering {
private map = new Map<string, ILevelsOptions[]>();
private mlRefs = new Set<string>();
private counter = 0;
private styleRefs = new Map<string, { reference: string; configIndex: number; instances: number }>();
newReference(): string {
const reference = `num-${this.counter++}`;
@@ -246,24 +267,69 @@ class Numbering {
return reference;
}
// One shared reference per named list style, all 9 levels pre-filled from its
// definition; each list gets its own instance, so every one restarts at the style's
// start (the library writes one w:num with a startOverride per instance).
refForStyle(style: ListStyleDef): { reference: string; instance: number } {
let entry = this.styleRefs.get(style.name);
if (!entry) {
const reference = `num-style-${this.styleRefs.size}`;
const levels: ILevelsOptions[] = [];
for (let l = 0; l < 9; l++) {
const def = style.levels[l];
const indent: IIndentAttributesProperties = {
left: cmToTwip(listStyleMarginCm(style, l + 1)), hanging: cmToTwip(LIST_HANGING_CM),
};
const alignment = def?.markerAlign === 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT;
if (def?.kind === 'bullet') {
levels.push({
level: l, format: LevelFormat.BULLET, text: def.bulletChar ?? defaultBulletChar(l),
alignment, style: { paragraph: { indent } },
});
} else {
const t = orderedTypeDef(style.multilevel ? 'decimal' : def?.numType ?? 'decimal');
levels.push({
level: l,
format: ORDERED_FORMAT[t.numFormat] ?? LevelFormat.DECIMAL,
text: style.multilevel ? Array.from({ length: l + 1 }, (_, i) => `%${i + 1}.`).join('') : `%${l + 1}${t.numSuffix}`,
alignment, start: def?.startAt ?? 1,
style: { paragraph: { indent } },
});
}
}
this.map.set(reference, levels);
if (style.multilevel) this.mlRefs.add(reference);
entry = { reference, configIndex: this.config.length, instances: 0 };
this.config.push({ reference, levels });
this.styleRefs.set(style.name, entry);
}
return { reference: entry.reference, instance: entry.instances++ };
}
// The styles whose shared abstract the post-pack pass links (w:styleLink); the
// config index locates it in numbering.xml behind the library's default abstract.
styleLinks(): { name: string; configIndex: number }[] {
return [...this.styleRefs.entries()].map(([name, e]) => ({ name, configIndex: e.configIndex }));
}
// A level keeps the first format it saw, so a sibling nested list of another kind,
// number format or bullet char forks its own reference (the ODT side mints its own
// list style). The fork copies the shallower levels so the importer's cycle walk
// (listBaseCycle) still sees the ancestry.
forkFor(reference: string, depth: number, node: TiptapNode, cycle: OrderedCycle): string {
forkFor(reference: string, depth: number, node: TiptapNode, cycle: OrderedCycle, style: ListStyleDef | null = null): string {
const levels = this.map.get(reference)!;
const l = levels.find((lv) => lv.level === depth);
if (!l) return reference;
let format: string, text: string;
if (node.type === 'orderedList') {
const attr = node.attrs?.listStyleType as string | null | undefined;
const attr = effOrderedKey(node, style, depth);
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
format = ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL;
text = `%${depth + 1}${def.numSuffix}`;
if (this.mlRefs.has(reference) && !attr) return reference; // chain member inherits
if (this.mlRefs.has(reference) && !node.attrs?.listStyleType) return reference; // chain member inherits
} else {
format = LevelFormat.BULLET;
text = bulletCharOf(node, depth);
text = bulletCharOf(node, depth, style);
}
if (l.format === format && l.text === text) return reference;
const fork = this.newReference();
@@ -271,10 +337,10 @@ class Numbering {
return fork;
}
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number, cycle: OrderedCycle): void {
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number, cycle: OrderedCycle, style: ListStyleDef | null = null): void {
// A multilevel top list turns the whole reference into a "%1.%2." chain; its
// attr-less nested lists inherit it (the top registers first — pre-order walk).
if (depth === 0 && node.attrs?.listStyleType === 'multilevel') this.mlRefs.add(reference);
if (depth === 0 && effOrderedKey(node, style, 0) === 'multilevel') this.mlRefs.add(reference);
const levels = this.map.get(reference)!;
if (levels.some((l) => l.level === depth)) return;
const indent: IIndentAttributesProperties = {
@@ -282,10 +348,11 @@ class Numbering {
hanging: cmToTwip(LIST_HANGING_CM),
};
// w:lvlJc: which end of the hanging indent the label is set against.
const alignment = node.attrs?.markerAlign === 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT;
const eff = effectiveListLevel(node.attrs ?? {}, node.type === 'orderedList', style, depth + 1);
const alignment = eff.markerAlign === 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT;
if (node.type === 'orderedList') {
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr);
const attr = eff.listStyleType;
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr || attr === 'multilevel');
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
levels.push({
level: depth,
@@ -294,7 +361,7 @@ class Numbering {
? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
: `%${depth + 1}${def.numSuffix}`,
alignment,
start: typeof node.attrs?.start === 'number' ? node.attrs.start : 1,
start: typeof node.attrs?.start === 'number' ? node.attrs.start : eff.startAt ?? 1,
style: { paragraph: { indent }, run: markerRunProps(node) },
});
} else {
@@ -303,7 +370,7 @@ class Numbering {
format: LevelFormat.BULLET,
// The Unicode char goes into w:lvlText literally; Word renders it in the
// paragraph font (no Wingdings/Symbol rFonts needed).
text: bulletCharOf(node, depth),
text: bulletCharOf(node, depth, style),
alignment,
style: { paragraph: { indent }, run: markerRunProps(node) },
});
@@ -1245,6 +1312,42 @@ function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array
return zipSync(out);
}
// Link each named list style to its shared abstract numbering: the library cannot
// express w:styleLink, so the pass injects it and points the numbering style's
// placeholder w:numId at the first concrete num over that abstract.
function applyListStylesDocx(bytes: Uint8Array, links: { name: string; configIndex: number }[]): Uint8Array {
if (!links.length) return bytes;
const files = unzipSync(bytes);
const numberingBytes = files['word/numbering.xml'];
const stylesBytes = files['word/styles.xml'];
if (!numberingBytes || !stylesBytes) return bytes;
let numbering = strFromU8(numberingBytes);
let stylesXml = strFromU8(stylesBytes);
// Abstract definitions in document order: the library's own default bullet abstract
// first, then one per config entry — configIndex + 1 is the style's.
const abstracts = numbering.match(/<w:abstractNum [\s\S]*?<\/w:abstractNum>/g) ?? [];
for (const { name, configIndex } of links) {
const abstract = abstracts[configIndex + 1];
const absId = abstract && /w:abstractNumId="(\d+)"/.exec(abstract)?.[1];
if (absId == null) continue;
const styleId = docxStyleId(name);
numbering = numbering.replace(abstract!,
abstract!.replace(/(<w:multiLevelType[^>]*\/>)/, `$1<w:styleLink w:val="${styleId}"/>`));
const numId = new RegExp(`<w:num w:numId="(\\d+)"[^>]*>\\s*<w:abstractNumId w:val="${absId}"/>`).exec(numbering)?.[1];
if (!numId) continue;
stylesXml = stylesXml.replace(
new RegExp(`(<w:style w:type="numbering" w:styleId="${styleId}">[\\s\\S]*?<w:numId w:val=")0("/>)`),
`$1${numId}$2`);
}
files['word/numbering.xml'] = strToU8(numbering);
files['word/styles.xml'] = strToU8(stylesXml);
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
const B_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography';
const DS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/customXml';
// The part's identity inside the package, not the document's — one fixed value serves.
@@ -1734,7 +1837,7 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
}
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; force?: TextProps; inCell?: boolean };
type ParaOpts = { numbering?: { reference: string; level: number; instance?: number }; indentLeftTwip?: number; force?: TextProps; inCell?: boolean };
// Paragraph background ("colored field") → w:shd; per-side borders ("rule line") → w:pBdr.
function paraShadingOf(attrs: TiptapNode['attrs']) {
@@ -1800,21 +1903,23 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
function listToParagraphs(
node: TiptapNode, depth: number, reference: string, extraIndentCm: number,
num: Numbering, out: (Paragraph | Table | TableOfContents)[], cycle: OrderedCycle = ROOT_ORDERED_CYCLE,
style: ListStyleDef | null = null, instance = 0,
): void {
const indentCm = extraIndentCm + (typeof node.attrs?.indent === 'number' ? node.attrs.indent : 0);
num.ensureLevel(reference, depth, node, indentCm, cycle);
const eff = effectiveListLevel(node.attrs ?? {}, node.type === 'orderedList', style, depth + 1);
const indentCm = extraIndentCm + eff.indent;
num.ensureLevel(reference, depth, node, indentCm, cycle, style);
const levelLeftTwip = cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + indentCm);
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
const cChild = childCycle(cycle, eff.listStyleType, node.type === 'orderedList');
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
let numberedFirst = false;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
const ref = num.forkFor(reference, depth + 1, child, cChild);
listToParagraphs(child, depth + 1, ref, indentCm, num, out, cChild);
const ref = num.forkFor(reference, depth + 1, child, cChild, style);
listToParagraphs(child, depth + 1, ref, indentCm, num, out, cChild, style, ref === reference ? instance : 0);
} else if (child.type === 'paragraph' || child.type === 'heading') {
if (!numberedFirst) {
out.push(paragraphToDocx(child, { numbering: { reference, level: depth } }));
out.push(paragraphToDocx(child, { numbering: { reference, level: depth, instance } }));
numberedFirst = true;
} else {
out.push(paragraphToDocx(child, { indentLeftTwip: levelLeftTwip }));
@@ -1824,6 +1929,18 @@ function listToParagraphs(
}
}
// A list carrying an unoverridden named list style shares the style's numbering
// definition (its own instance); anything else keeps a private, fully resolved one.
function listEntryToDocx(node: TiptapNode, num: Numbering, out: (Paragraph | Table | TableOfContents)[]): void {
const style = listStyleOf(node);
if (style && !listStyleOverridden(node) && !subtreeHasStart(node)) {
const { reference, instance } = num.refForStyle(style);
listToParagraphs(node, 0, reference, 0, num, out, ROOT_ORDERED_CYCLE, style, instance);
} else {
listToParagraphs(node, 0, num.newReference(), 0, num, out, ROOT_ORDERED_CYCLE, style);
}
}
// ---- tables ----------------------------------------------------------------
// Per-column widths (cm) from the first row's proportional colwidth weights, scaled
// to fill the text column exactly. Mirrors odt.ts tableColumnWidthsCm.
@@ -1889,7 +2006,7 @@ function cellBlocksToDocx(content: TiptapNode[] = [], force: TextProps, num: Num
const out: (Paragraph | Table)[] = [];
for (const child of content) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
listToParagraphs(child, 0, num.newReference(), 0, num, out);
listEntryToDocx(child, num, out);
} else if (child.type === 'table') {
out.push(tableToDocx(child, contentWidthCm, num));
} else if (child.type === 'paragraph' || child.type === 'heading') {
@@ -1992,7 +2109,7 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
if (node.type === 'paragraph' || node.type === 'heading') {
out.push(paragraphToDocx(node));
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
listToParagraphs(node, 0, num.newReference(), 0, num, out);
listEntryToDocx(node, num, out);
} else if (node.type === 'table') {
out.push(tableToDocx(node, contentWidthCm, num));
} else if (node.type === 'image') {
@@ -2215,7 +2332,18 @@ function usedTableStyles(doc: TiptapNode, sheet: StyleSheet): string[] {
return [...names];
}
function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language: string; country: string } | null, usedTables: string[] = []) {
// Word needs a referenced numbering style to exist; its w:numId placeholder (0) is
// rewritten by applyListStylesDocx once the packed numbering ids are known.
function numberingStyleXml(name: string): ImportedXmlComponent {
return ImportedXmlComponent.fromXmlString(
`<w:style w:type="numbering" w:styleId="${docxStyleId(name)}">`
+ `<w:name w:val="${escapeXml(name)}"/><w:uiPriority w:val="99"/>`
+ '<w:pPr><w:numPr><w:numId w:val="0"/></w:numPr></w:pPr>'
+ '</w:style>',
);
}
function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language: string; country: string } | null, usedTables: string[] = [], usedLists: string[] = []) {
const run: Writable<IRunStylePropertiesOptions> = { font: DOC_FONT, size: 24 };
if (language) run.language = { value: `${language.language}-${language.country}` };
return {
@@ -2225,7 +2353,9 @@ function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language
// The document's named styles, chain intact — Word shows them in its style list.
paragraphStyles: Object.values(sheet.paragraph).filter(st => used.has(st.name)).map(paragraphStyleOf),
characterStyles: Object.values(sheet.character ?? {}).map(characterStyleOf),
...(usedTables.length ? { importedStyles: usedTables.map(tableStyleXml) } : {}),
...(usedTables.length || usedLists.length
? { importedStyles: [...usedTables.map(tableStyleXml), ...usedLists.map(numberingStyleXml)] }
: {}),
};
}
@@ -2377,7 +2507,7 @@ export async function buildDocx(
...(hasToc || recordChanges
? { features: { ...(hasToc ? { updateFields: true } : {}), ...(recordChanges ? { trackRevisions: true } : {}) } }
: {}),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles), num.styleLinks().map((l) => l.name)),
numbering: { config: num.config },
...(Object.keys(notesByClass.footnote).length ? { footnotes: notesByClass.footnote } : {}),
...(Object.keys(notesByClass.endnote).length ? { endnotes: notesByClass.endnote } : {}),
@@ -2418,7 +2548,8 @@ export async function buildDocx(
});
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
const linked = applyListStylesDocx(new Uint8Array(await blob.arrayBuffer()), num.styleLinks());
const packed = applyFormulasDocx(applyTextBoxesDocx(linked, textBoxes), docFormulas);
const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson));
const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited;
const withResolved = applyCommentsResolvedDocx(withNotes);
+6
View File
@@ -88,6 +88,12 @@ a producer writing EMU there (360000 = 1cm in EMU, 635cm read as twips) would ot
leave columns of no width. Under that width LibreOffice lays the declared gap out literally
— probed to 10.16cm on a 15.24cm text — so only the unholdable value is dropped.
**A `w:numStyleLink` abstract carries no levels** — it defers to its numbering style's own
numbering, so `DocxStyles.level()` resolves through the link (style → its `w:numPr` → the
`w:styleLink` abstract); unresolved it read as `{}` and every linked list silently imported
as a bullet list. A linked numId's list gets `listStyleName` (the style's `w:name`), no
per-level attrs, and its definition lands in `sheet.list` (`listStyleFromDocx`).
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.
+48 -1
View File
@@ -5,6 +5,7 @@ import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../e
import { PLACEHOLDER_SDT_TAG } from '../export/docx';
import { FOLD_MARK_NAME } from '../storage/foldMarks';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { fitInlineImage, framePx } from '../editor/extensions/image';
import { formatTabStops } from '../editor/extensions/tabStops';
@@ -65,6 +66,7 @@ type Ctx = {
convertedImages: ConvertedImages;
pendingBlocks: Node[];
listCounters: Map<number, Map<number, number>>; // numId → ilvl → last number used
usedListStyles: Map<number, string>; // numId → the named numbering style it links to
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
// Left page margin (cm), the origin a page-relative frame offset is measured against.
@@ -192,7 +194,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), citationStyle: docxCitationStyle(files), notes: [], noteParts: {
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), usedListStyles: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), hyphenate: docAutoHyphenation(files), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), bibSources: docxSources(files), citationStyle: docxCitationStyle(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
@@ -662,6 +664,15 @@ function listBaseCycle(ctx: Ctx, numId: number, ilvl: number): OrderedCycle {
function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
const def = ctx.styles.level(numId, ilvl);
const bullet = !def.numFmt || def.numFmt === 'bullet' || def.numFmt === 'none';
// A linked numbering style travels as `listStyleName` on the outermost list; its
// levels stay in the registry, so no per-level attrs are derived.
const styleName = ctx.styles.numberingStyleOf(numId);
if (styleName) {
ctx.usedListStyles.set(numId, styleName);
const node: Node = { type: bullet ? 'bulletList' : 'orderedList', content: [] };
if (ilvl === 0) node.attrs = { listStyleName: styleName };
return node;
}
const attrs: Record<string, unknown> = {};
if (bullet) {
const ch = bulletCharAttr(bulletCharFromDocx(def.lvlText, def.bulletFont), ilvl);
@@ -941,9 +952,45 @@ function collectStyleSheet(ctx: Ctx): StyleSheet {
para: {}, text: styleText(ctx, id, true),
};
}
for (const [numId, name] of ctx.usedListStyles) {
sheet.list[name] = listStyleFromDocx(name, ctx, numId, sheet.list[name]?.builtin);
}
return sheet;
}
// A linked numbering's levels → the registry's shape, mirroring makeListNode's math
// (Word writes 9 levels; each indentCm is the margin step past the 1.27cm base).
function listStyleFromDocx(name: string, ctx: Ctx, numId: number, builtin?: boolean): ListStyle {
const levels: ListLevelStyle[] = [];
let multilevel = false;
let prevLeftCm = 0;
for (let l = 0; l < MAX_LIST_LEVELS; l++) {
const def = ctx.styles.level(numId, l);
if (!Object.keys(def).length) break;
const bullet = !def.numFmt || def.numFmt === 'bullet' || def.numFmt === 'none';
const level: ListLevelStyle = { kind: bullet ? 'bullet' : 'number' };
const leftCm = def.leftTwip != null ? twipToCm(def.leftTwip) : prevLeftCm + LIST_LEFT_STEP_CM;
const extra = round2(leftCm - prevLeftCm - LIST_LEFT_STEP_CM);
if (extra) level.indentCm = extra;
prevLeftCm = leftCm;
if (def.rightAligned) level.markerAlign = 'right';
if (bullet) {
const ch = bulletCharFromDocx(def.lvlText, def.bulletFont);
if (ch) level.bulletChar = ch;
} else {
if (((def.lvlText ?? '').match(/%\d/g) ?? []).length > 1) multilevel = true;
const key = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
level.numType = key === 'multilevel' ? 'decimal' : key;
if (def.start != null && def.start > 1) level.startAt = def.start;
}
levels.push(level);
}
const style: ListStyle = { name, levels };
if (builtin) style.builtin = true;
if (multilevel) style.multilevel = true;
return style;
}
function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: boolean): Node {
const ppr = fc(el, 'pPr');
const pStyle = fc(ppr, 'pStyle');
+31 -2
View File
@@ -193,6 +193,9 @@ export class DocxStyles {
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
private numToAbstract = new Map<string, string>();
private abstractLevels = new Map<string, Map<number, LevelDef>>();
private numStyleNames = new Map<string, string>(); // numbering styleId → display name
private abstractStyleLink = new Map<string, string>(); // abstract → its own numbering style (w:styleLink)
private abstractNumStyleLink = new Map<string, string>(); // abstract → the style it defers to (w:numStyleLink)
private minorFont?: string; // theme1.xml body font (e.g. Calibri)
private majorFont?: string; // theme1.xml heading font (e.g. Calibri Light)
@@ -289,11 +292,12 @@ export class DocxStyles {
const rowBand = bandSize('tblStyleRowBandSize'), colBand = bandSize('tblStyleColBandSize');
if (rowBand || colBand) this.ownBandSize.set(id, { row: rowBand, col: colBand });
const kind = style.getAttributeNS(W, 'type');
if (kind === 'paragraph' || kind === 'character' || kind === 'table') {
if (kind === 'paragraph' || kind === 'character' || kind === 'table' || kind === 'numbering') {
const nameEl = firstChild(style, 'name');
const name = (nameEl && wVal(nameEl)) || id;
if (kind === 'paragraph') this.paraStyleNames.set(id, name);
else if (kind === 'table') this.tableStyleNames.set(id, name);
else if (kind === 'numbering') this.numStyleNames.set(id, name);
else this.charStyleNames.set(id, name);
}
}
@@ -358,6 +362,10 @@ export class DocxStyles {
levels.set(ilvl, def);
}
this.abstractLevels.set(id, levels);
const sl = firstChild(abs, 'styleLink');
if (sl) { const v = wVal(sl); if (v) this.abstractStyleLink.set(id, v); }
const nsl = firstChild(abs, 'numStyleLink');
if (nsl) { const v = wVal(nsl); if (v) this.abstractNumStyleLink.set(id, v); }
}
for (const num of Array.from(doc.getElementsByTagNameNS(W, 'num'))) {
const numId = num.getAttributeNS(W, 'numId');
@@ -567,11 +575,32 @@ export class DocxStyles {
return { row: own?.row || base.row, col: own?.col || base.col };
}
// The abstract a numId's levels really live in: a w:numStyleLink abstract carries no
// levels itself — it defers to the linked numbering style's own numbering.
private resolvedAbstract(numId: number, seen = new Set<string>()): string | null {
const abs = this.numToAbstract.get(String(numId)) ?? null;
if (!abs || seen.has(abs)) return abs;
seen.add(abs);
const linked = this.abstractNumStyleLink.get(abs);
const np = linked ? this.styleNum.get(linked) : null;
if (!np) return abs;
return this.resolvedAbstract(np.numId, seen) ?? abs;
}
level(numId: number, ilvl: number): LevelDef {
const abs = this.numToAbstract.get(String(numId));
const abs = this.resolvedAbstract(numId);
if (!abs) return {};
return this.abstractLevels.get(abs)?.get(ilvl) ?? {};
}
// The named numbering style a numId's abstract belongs to (its own w:styleLink, or
// the one it defers to via w:numStyleLink); null for a private numbering.
numberingStyleOf(numId: number): string | null {
const abs = this.numToAbstract.get(String(numId));
if (!abs) return null;
const styleId = this.abstractNumStyleLink.get(abs) ?? this.abstractStyleLink.get(abs);
return styleId ? this.numStyleNames.get(styleId) ?? styleId : null;
}
}
function firstChild(el: Element, localName: string): Element | null {
+113
View File
@@ -1204,3 +1204,116 @@ describe('DOCX text box: a list inside it is a real list, a picture a real pictu
expect(String(img.attrs.src).startsWith('data:image/png;base64,')).toBe(true);
});
});
describe('DOCX named list styles', () => {
const sheet = builtinStyleSheet();
sheet.list['Prüfliste'] = {
name: 'Prüfliste',
levels: [
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
{ kind: 'bullet', bulletChar: '✓' },
{ kind: 'number', numType: 'lower-alpha' },
],
};
const fixture = {
type: 'doc',
content: [
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [
li(para('one'), { type: 'bulletList', content: [li(para('sub'))] }),
li(para('two')),
] },
para('between'),
// A second list in the same style: its own instance, so it restarts at 1.
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [li(para('restart'))] },
{ type: 'bulletList', attrs: { listStyleName: 'List 2' }, content: [li(para('dash'))] },
],
};
it('links the shared abstract via w:styleLink and the numbering style', async () => {
const bytes = await buildDocx(fixture as any, undefined, undefined, undefined, undefined, undefined, sheet);
const files = unzipSync(bytes);
const numbering = strFromU8(files['word/numbering.xml']);
const styles = strFromU8(files['word/styles.xml']);
const abstract = numbering.match(/<w:abstractNum [^>]*>(?:(?!<\/w:abstractNum>)[\s\S])*w:styleLink w:val="Prfliste"[\s\S]*?<\/w:abstractNum>/)?.[0] ?? '';
expect(abstract, 'the style abstract carries w:styleLink').toBeTruthy();
expect(abstract).toContain('<w:numFmt w:val="upperRoman"/>');
expect(abstract).toContain('<w:lvlText w:val="%1)"/>');
expect(abstract).toContain('<w:lvlJc w:val="right"/>');
expect(abstract).toContain('<w:lvlText w:val="✓"/>');
const absId = /w:abstractNumId="(\d+)"/.exec(abstract)![1];
// Two lists in one style → two concrete nums over the one abstract (each restarts).
const nums = numbering.match(new RegExp(`<w:num w:numId="\\d+"[^>]*>\\s*<w:abstractNumId w:val="${absId}"/>`, 'g')) ?? [];
expect(nums.length, 'one instance per list').toBe(2);
const style = styles.match(/<w:style w:type="numbering" w:styleId="Prfliste">[\s\S]*?<\/w:style>/)?.[0] ?? '';
expect(style).toContain('<w:name w:val="Prüfliste"/>');
expect(/<w:numId w:val="[1-9]\d*"\/>/.test(style), 'placeholder numId resolved').toBe(true);
expect(styles).toContain('w:styleId="List2"');
});
it('round-trips the assignment and the definition', async () => {
const bytes = await buildDocx(fixture as any, undefined, undefined, undefined, undefined, undefined, sheet);
const result = importDocx(bytes);
const doc = result.content as N;
const lists = (doc.content ?? []).filter((n) => n.type === 'orderedList' || n.type === 'bulletList');
expect(lists[0]?.attrs?.listStyleName).toBe('Prüfliste');
expect(lists[0]?.attrs?.listStyleType).toBeUndefined();
expect(lists[0]?.attrs?.indent).toBeUndefined();
const nested = walk(lists[0], 'bulletList')[0];
expect(nested?.attrs ?? null, 'nested list stays attr-free').toBeNull();
expect(lists[1]?.attrs?.listStyleName).toBe('Prüfliste');
expect(lists[2]?.attrs?.listStyleName).toBe('List 2');
const imported = result.styles.list['Prüfliste'];
expect(imported?.levels[0]).toMatchObject({ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 });
expect(imported?.levels[1]).toMatchObject({ kind: 'bullet', bulletChar: '✓' });
expect(imported?.levels[2]).toMatchObject({ kind: 'number', numType: 'lower-alpha' });
});
it('an overridden list keeps a private, fully resolved numbering and drops the name', async () => {
const doc = { type: 'doc', content: [
{ type: 'orderedList', attrs: { listStyleName: 'Numbering ABC', listStyleType: 'lower-roman' }, content: [li(para('broken out'))] },
] };
const bytes = await buildDocx(doc as any, undefined, undefined, undefined, undefined, undefined, sheet);
const numbering = strFromU8(unzipSync(bytes)['word/numbering.xml']);
expect(numbering).not.toContain('w:styleLink');
expect(numbering).toContain('<w:numFmt w:val="lowerRoman"/>');
const list = (importDocx(bytes).content as N).content!.find((n) => n.type === 'orderedList');
expect(list?.attrs?.listStyleType).toBe('lower-roman');
expect(list?.attrs?.listStyleName).toBeUndefined();
});
it('follows w:numStyleLink in a foreign file (a linked list is numbers, not bullets)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>linked one</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>linked two</w:t></w:r></w:p>
</w:body></w:document>`;
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="numbering" w:styleId="MyNum"><w:name w:val="My Numbering"/>
<w:pPr><w:numPr><w:numId w:val="2"/></w:numPr></w:pPr></w:style>
</w:styles>`;
// Word's indirection: the document's abstract 1 carries only w:numStyleLink; the
// style's own numbering (num 2 → abstract 0, carrying w:styleLink) has the levels.
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="multilevel"/><w:styleLink w:val="MyNum"/>
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="upperLetter"/><w:lvlText w:val="%1)"/><w:pPr><w:ind w:left="1134" w:hanging="360"/></w:pPr></w:lvl>
</w:abstractNum>
<w:abstractNum w:abstractNumId="1"><w:numStyleLink w:val="MyNum"/></w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="0"/></w:num>
</w:numbering>`;
const result = importDocx(zipSync({
'word/document.xml': strToU8(documentXml),
'word/styles.xml': strToU8(stylesXml),
'word/numbering.xml': strToU8(numberingXml),
}));
const doc = result.content as N;
const list = doc.content!.find((n) => n.type === 'orderedList');
expect(list, 'the linked list resolves to numbers, not the bullet fallback').toBeTruthy();
expect(list!.attrs?.listStyleName).toBe('My Numbering');
const imported = result.styles.list['My Numbering'];
expect(imported?.levels[0]).toMatchObject({ kind: 'number', numType: 'upper-alpha-paren' });
expect(imported?.levels[0]?.indentCm).toBeCloseTo(0.73, 2); // 1134 twips = 2cm → +0.73
});
});