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:
1 parent
e2525237de
commit
b0de0311a8
6 files changed
+359
-33
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user