diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 677aa3d..39da430 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 `` 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 `` `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 `` `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 `` 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). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 259b628..73e29db 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 = { -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(); private mlRefs = new Set(); private counter = 0; + private styleRefs = new Map(); 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(//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(/(]*\/>)/, `$1`)); + const numId = new RegExp(`]*>\\s*`).exec(numbering)?.[1]; + if (!numId) continue; + stylesXml = stylesXml.replace( + new RegExp(`([\\s\\S]*?)`), + `$1${numId}$2`); + } + + files['word/numbering.xml'] = strToU8(numbering); + files['word/styles.xml'] = strToU8(stylesXml); + const out: Record = {}; + 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, 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( + `` + + `` + + '' + + '', + ); +} + +function buildStyles(sheet: StyleSheet, used: Set, language?: { language: string; country: string } | null, usedTables: string[] = [], usedLists: string[] = []) { const run: Writable = { 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, 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); diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 84f706a..4dc2297 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 12901d8..95c1756 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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>; // numId → ilvl → last number used + usedListStyles: Map; // 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 = {}; 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'); diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 55a7a82..f3866a8 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -193,6 +193,9 @@ export class DocxStyles { private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); + private numStyleNames = new Map(); // numbering styleId → display name + private abstractStyleLink = new Map(); // abstract → its own numbering style (w:styleLink) + private abstractNumStyleLink = new Map(); // 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 | 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 { diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 86eedbe..615b8ac 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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>)[\s\S])*w:styleLink w:val="Prfliste"[\s\S]*?<\/w:abstractNum>/)?.[0] ?? ''; + expect(abstract, 'the style abstract carries w:styleLink').toBeTruthy(); + expect(abstract).toContain(''); + expect(abstract).toContain(''); + expect(abstract).toContain(''); + expect(abstract).toContain(''); + 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(`]*>\\s*`, 'g')) ?? []; + expect(nums.length, 'one instance per list').toBe(2); + const style = styles.match(/[\s\S]*?<\/w:style>/)?.[0] ?? ''; + expect(style).toContain(''); + expect(//.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(''); + 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 = ` + linked one + linked two + `; + const stylesXml = ` + + + + `; + // 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 = ` + + + + + + + `; + 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 + }); +});