From 80463883f037f718dbf7a4852a702ea07e0a51d1 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 2 Sep 2026 23:03:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20chapter=20numbering=20=E2=80=94=20headi?= =?UTF-8?q?ngs=20carry=20the=20number=20the=20file=20gives=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A numbered thesis lost every "1.", "2.1.4" and "A." on import: the outline definition was read as a list wrapping headings and dropped. It is now a document-level definition on the style sheet (format, prefix, suffix, display-levels, start per level), drawn by CSS counters that reset down the levels, and carried by both formats — ODF's text:outline-style, Word's multilevel numbering bound to the heading styles. The contents rows count the same way, since no text walk can read a CSS counter. --- src/lib/editor/extensions/tableOfContents.ts | 29 ++++- src/lib/export/docx.ts | 77 ++++++++++++- src/lib/export/odt.ts | 24 +++++ src/lib/import/docx.ts | 32 ++++++ src/lib/import/odt.ts | 32 ++++++ src/lib/import/styleResolver.ts | 5 + src/lib/styles/outlineNumbering.ts | 108 +++++++++++++++++++ src/lib/styles/styleSheet.ts | 12 ++- tests/unit/outline-numbering.test.ts | 84 +++++++++++++++ 9 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 src/lib/styles/outlineNumbering.ts create mode 100644 tests/unit/outline-numbering.test.ts diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 7874edc..4063a5a 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -1,4 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; +import { styleSheet } from '../../styles/sheet.svelte'; +import { outlineIsEmpty, outlineLabel } from '../../styles/outlineNumbering'; +import { formatOrdinal } from '../../utils/orderedListTypes'; import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; import { MAX_HEADING_LEVEL } from '../../export/odt'; @@ -185,6 +188,16 @@ function entryText(node: PMNode): string { return raw.trim(); } +// A heading inside a table cell or a list item is numbered by neither product. +function inCellOrItem(doc: PMNode, pos: number): boolean { + const $pos = doc.resolve(pos); + for (let d = $pos.depth; d > 0; d--) { + const name = $pos.node(d).type.name; + if (name === 'tableCell' || name === 'tableHeader' || name === 'listItem') return true; + } + return false; +} + // Node view: renders the title + one clickable row per heading. Recomputes on each // pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change, // writing entries back to the node attr — guarded by a serialized key against a loop. @@ -262,11 +275,23 @@ class TocView { return out; } const max = Math.min(MAX_HEADING_LEVEL, Number(this.node()?.attrs?.maxLevel) || MAX_HEADING_LEVEL); - this.editor.state.doc.descendants((node, pos) => { + // Chapter numbering is drawn by CSS counters on the page, which no text walk can + // read — a contents row carries the same label, counted the same way (a heading in + // a cell or a list item is not part of the count, as outlineCss has it). + const outline = styleSheet().outline; + const counts: number[] = []; + const doc = this.editor.state.doc; + doc.descendants((node, pos) => { if (node.type.name === 'heading') { const text = entryText(node); const level = Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1); - if (text && level <= max) out.push({ text, level, pos }); + let label = ''; + if (!outlineIsEmpty(outline) && !inCellOrItem(doc, pos)) { + counts[level - 1] = (counts[level - 1] ?? (outline![level - 1]?.start ?? 1) - 1) + 1; + for (let d = level; d < counts.length; d++) counts[d] = (outline![d]?.start ?? 1) - 1; + label = outlineLabel(outline, level, counts, formatOrdinal); + } + if (text && level <= max) out.push({ text: label + text, level, pos }); } }); return out; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index ccfc19a..366f2fd 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -36,6 +36,7 @@ import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops'; import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker'; 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 { outlineIsEmpty, type OutlineNumbering } from '../styles/outlineNumbering'; 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'; @@ -330,6 +331,34 @@ class Numbering { return { reference: entry.reference, instance: entry.instances++ }; } + // Chapter numbering: one multilevel abstract the heading styles are bound to + // post-pack (Word's own shape — w:pStyle per level, w:numPr on the style). + outlineRef(outline: OutlineNumbering): number { + const reference = 'num-outline'; + const levels: ILevelsOptions[] = []; + for (let l = 0; l < 9; l++) { + const def = outline[l]; + if (!def || def.format === 'none') { + levels.push({ level: l, format: LevelFormat.NONE, text: '', start: 1 }); + continue; + } + const from = Math.max(1, l + 1 - Math.max(1, def.displayLevels) + 1); + const chain = []; + for (let k = from; k <= l + 1; k++) if (outline[k - 1]?.format !== 'none') chain.push(`%${k}`); + levels.push({ + level: l, + format: ORDERED_FORMAT[def.format] ?? LevelFormat.DECIMAL, + text: `${def.prefix}${chain.join('.')}${def.suffix || ' '}`, + start: def.start ?? 1, + alignment: AlignmentType.LEFT, + }); + } + this.map.set(reference, levels); + const index = this.config.length; + this.config.push({ reference, levels }); + return index; + } + // 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 }[] { @@ -1416,6 +1445,50 @@ function applyListStylesDocx(bytes: Uint8Array, links: { name: string; configInd return zipSync(out); } +// Bind the outline abstract to the heading styles: each level names the style it +// numbers (w:pStyle) and each style points back at the numbering (w:numPr) — which is +// how Word writes chapter numbering, and what makes a new heading count on its own. +function applyOutlineNumberingDocx(bytes: Uint8Array, configIndex: number): Uint8Array { + if (configIndex < 0) 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); + const abstract = (numbering.match(//g) ?? [])[configIndex + 1]; + const absId = abstract && /w:abstractNumId="(\d+)"/.exec(abstract)?.[1]; + if (absId == null) return bytes; + let level = -1; + const bound = abstract! + .replace(/(]*\/>)/, '') + // w:pStyle sits between w:lvlRestart and w:suff in the element's fixed order. + .replace(//g, (lvl) => { + level += 1; + return lvl.replace(/(]*\/>)/, `$1`); + }); + numbering = numbering.replace(abstract!, bound); + // No paragraph references the abstract — the styles do — so the library writes no + // w:num for it and one has to be minted past the ids it did write. + let numId = new RegExp(`]*>\\s*`).exec(numbering)?.[1]; + if (!numId) { + const ids = [...numbering.matchAll(/ Number(m[1])); + numId = String(Math.max(0, ...ids) + 1); + numbering = numbering.replace('', ``); + } + for (let n = 1; n <= 9; n++) { + const numPr = ``; + stylesXml = stylesXml.replace( + new RegExp(`(]*w:styleId="Heading${n}"[\\s\\S]*?)()`), + `$1$2${numPr}`); + } + 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. @@ -2594,6 +2667,8 @@ export async function buildDocx( docPlaceholders = []; docSources = []; const num = new Numbering(); + // Registered before the body walk, so its abstract keeps a stable place in the config. + const outlineIndex = outlineIsEmpty(styles.outline) ? -1 : num.outlineRef(styles.outline!); const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation); const contentWidthCm = pageWidthCm - margins.left - margins.right; @@ -2778,7 +2853,7 @@ export async function buildDocx( ...usedTableStyles(docJson, styles).map(tableStyleXml), ...num.styleLinks().map((l) => numberingStyleXml(l.name)), ]); - const linked = applyListStylesDocx(styled, num.styleLinks()); + const linked = applyOutlineNumberingDocx(applyListStylesDocx(styled, num.styleLinks()), outlineIndex); const packed = applyFormulasDocx(applyTextBoxesDocx(linked, docTextBoxes), docFormulas); const cited = applyBibliographyDocx(applyPlaceholdersDocx(applyRubyDocx(packed, docRubies), docPlaceholders), docSources, docCitationStyle(docJson)); const withNotes = docNoteIds.size ? applyNoteMarksDocx(applyNotePrDocx(cited, notesSettings)) : cited; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 96ac836..691002a 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -17,6 +17,7 @@ import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, } from '../styles/tableStyles'; import { effectiveListLevel, listStyleMarginCm, listStyleOverridden, MAX_LIST_LEVELS, type ListStyle } from '../styles/listStyles'; +import { outlineIsEmpty, type OutlineNumbering } from '../styles/outlineNumbering'; // A table's named style plus the conditional areas it opts into (Word's Table Style // Options), collected by exportTable in document order. @@ -1962,9 +1963,32 @@ function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set, block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text); added.push(block); } + const outline = outlineStyleXml(sheet.outline); + if (outline) added.push(outline); return added.length ? styles.replace('', `${added.join('')}`) : styles; } +// The document's chapter numbering. LibreOffice keeps it in one +// beside the named styles, and numbers a text:h from it with no markup on the heading. +function outlineStyleXml(outline: OutlineNumbering | null | undefined): string { + if (outlineIsEmpty(outline)) return ''; + const levels: string[] = []; + outline!.forEach((level, i) => { + if (level.format === 'none') return; + const attrs: Record = { + 'text:level': String(i + 1), + 'style:num-format': level.format, + 'style:num-suffix': level.suffix, + }; + if (level.prefix) attrs['style:num-prefix'] = level.prefix; + if (level.displayLevels > 1) attrs['text:display-levels'] = String(level.displayLevels); + if (level.start !== 1) attrs['text:start-value'] = String(level.start); + if (level.charStyle) attrs['text:style-name'] = odfStyleName(level.charStyle); + levels.push(setTagAttrs('', attrs)); + }); + return levels.length ? `${levels.join('')}` : ''; +} + // Resolve STY sentinels: point each marked block at its named style — directly when it // carries no direct formatting, else via a clone of its automatic style whose parent // becomes the named one (so hard formatting keeps overriding the style, as in LO). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 72243b8..adbadcf 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 { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../styles/outlineNumbering'; 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'; @@ -919,6 +920,36 @@ function runTextProps(run: RunProps): TextProps { // The document's style registry: the built-ins with the file's own definitions merged // over them — only the styles blocks reference, plus their parent chains. +// Word keeps chapter numbering as one multilevel numbering the heading styles point at +// (w:numPr on the style, w:pStyle in the level). Level N is whatever "Heading N" uses. +function outlineFromDocx(ctx: Ctx): OutlineNumbering | null { + const byName = new Map(); + for (const [id, def] of ctx.styles.namedParagraphStyles()) byName.set(def.name.toLowerCase().replace(/\s+/g, ''), id); + const out: OutlineNumbering = []; + let numbered = false; + for (let level = 1; level <= MAX_OUTLINE_LEVELS; level++) { + const styleId = byName.get(`heading${level}`) ?? `Heading${level}`; + const np = ctx.styles.styleNumPr(styleId); + const def = np ? ctx.styles.level(np.numId, np.ilvl) : null; + const format = def?.numFmt ? DOCX_PAGE_NUM_FORMAT[def.numFmt] : null; + if (!def || !format) { out.push({ ...DEFAULT_OUTLINE_LEVEL }); continue; } + numbered = true; + const text = def.lvlText ?? ''; + const holders = [...text.matchAll(/%\d/g)]; + const last = holders[holders.length - 1]; + out.push({ + format, + prefix: holders.length ? text.slice(0, holders[0].index) : '', + suffix: last ? text.slice((last.index ?? 0) + 2) : '', + displayLevels: Math.max(1, holders.length), + start: def.start ?? 1, + }); + } + // Trailing unnumbered levels carry nothing — a definition ends at its last number. + while (out.length && out[out.length - 1].format === 'none') out.pop(); + return numbered ? out : null; +} + function collectStyleSheet(ctx: Ctx): StyleSheet { const defs = ctx.styles.namedParagraphStyles(); const sheet = builtinStyleSheet(); @@ -971,6 +1002,7 @@ function collectStyleSheet(ctx: Ctx): StyleSheet { for (const [numId, name] of ctx.usedListStyles) { sheet.list[name] = listStyleFromDocx(name, ctx, numId, sheet.list[name]?.builtin); } + sheet.outline = outlineFromDocx(ctx); return sheet; } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index e0e5363..02d5e75 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate'; import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; +import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../styles/outlineNumbering'; import { LIST_LEVEL_STEP_CM, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage, framePx } from '../editor/extensions/image'; @@ -1547,9 +1548,40 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet { const name = displayStyleName(odfName, resolver.listStyleDisplayName(odfName)); sheet.list[name] = listStyleFromOdf(name, el, sheet.list[name]?.builtin); } + sheet.outline = outlineFromOdf(resolver.outlineStyle(), ctx); return sheet; } +// → the chapter-numbering definition, level 1 first. A level the +// file leaves out is unnumbered, which is how both products switch one off. +function outlineFromOdf(el: Element | null, ctx: Ctx): OutlineNumbering | null { + if (!el) return null; + const out: OutlineNumbering = []; + let numbered = false; + for (let level = 1; level <= MAX_OUTLINE_LEVELS; level++) { + const def = Array.from(el.children).find( + (c) => c.localName === 'outline-level-style' && c.getAttributeNS(NS.text, 'level') === String(level)); + const format = def?.getAttributeNS(NS.style, 'num-format') ?? ''; + if (!def || !['1', 'a', 'A', 'i', 'I'].includes(format)) { + out.push({ ...DEFAULT_OUTLINE_LEVEL }); + continue; + } + numbered = true; + const charStyle = def.getAttributeNS(NS.text, 'style-name'); + out.push({ + format: format as NoteNumFormat, + prefix: def.getAttributeNS(NS.style, 'num-prefix') ?? '', + suffix: def.getAttributeNS(NS.style, 'num-suffix') ?? '', + displayLevels: Math.max(1, parseInt(def.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) || 1), + start: Math.max(0, parseInt(def.getAttributeNS(NS.text, 'start-value') ?? '1', 10) || 1), + ...(charStyle ? { charStyle: ctx.charStyleNames.get(charStyle) ?? charStyle } : {}), + }); + } + // Trailing unnumbered levels carry nothing — a definition ends at its last number. + while (out.length && out[out.length - 1].format === 'none') out.pop(); + return numbered ? out : null; +} + // A named element → the registry's shape. Each level's indentCm is // its margin step past the level above minus the 1.27cm base (the export's inverse). function listStyleFromOdf(name: string, el: Element, builtin?: boolean): ListStyle { diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index aa71a7f..a2b9fc0 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -818,6 +818,11 @@ export class StyleResolver { return { margins, orientation, format, rtl }; } + // The document's chapter numbering — one in styles.xml. + outlineStyle(): Element | null { + return this.stylesDoc?.getElementsByTagNameNS(NS.text, 'outline-style')[0] ?? null; + } + // The page's own decoration (storage/pageDecor.ts): background and border ride the // page layout, the watermark is a fontwork shape in the master page's header — // exactly where LibreOffice keeps all three (probed). diff --git a/src/lib/styles/outlineNumbering.ts b/src/lib/styles/outlineNumbering.ts new file mode 100644 index 0000000..1c4acd3 --- /dev/null +++ b/src/lib/styles/outlineNumbering.ts @@ -0,0 +1,108 @@ +// Chapter numbering: the automatic label in front of every heading, LibreOffice's +// Tools ▸ Heading Numbering and Word's multilevel list bound to the heading styles. +// One definition per document, level 1 first (ODF text:outline-style). + +import type { NoteNumFormat } from '../storage/noteSettings'; + +export type OutlineLevel = { + // 'none' = this level is unnumbered, which is how both products switch a level off. + format: NoteNumFormat | 'none'; + prefix: string; + suffix: string; + // How many levels the label shows: 1 → "2", 3 → "2.1.4" (ODF text:display-levels). + displayLevels: number; + start: number; + // The character style the label takes, where the file names one. + charStyle?: string | null; +}; + +export type OutlineNumbering = OutlineLevel[]; + +export const MAX_OUTLINE_LEVELS = 10; + +export const DEFAULT_OUTLINE_LEVEL: OutlineLevel = { + format: 'none', prefix: '', suffix: '', displayLevels: 1, start: 1, +}; + +export function outlineLevelAt(outline: OutlineNumbering | null | undefined, level: number): OutlineLevel | null { + const l = outline?.[level - 1]; + return l && l.format !== 'none' ? l : null; +} + +export function outlineIsEmpty(outline: OutlineNumbering | null | undefined): boolean { + return !outline?.some((l) => l.format !== 'none'); +} + +const CSS_STYLE: Record = { + '1': 'decimal', a: 'lower-alpha', A: 'upper-alpha', i: 'lower-roman', I: 'upper-roman', +}; + +const COUNTER = (level: number) => `edt-outline-${level}`; +const quote = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + +// The label a heading shows, as the parts of a CSS `content` value. The parent levels a +// display-levels chain shows are joined by '.', the way both products render one. +function labelContent(outline: OutlineNumbering, level: number): string | null { + const own = outlineLevelAt(outline, level); + if (!own) return null; + const from = Math.max(1, level - Math.max(1, own.displayLevels) + 1); + const parts: string[] = []; + for (let l = from; l <= level; l++) { + const at = outline[l - 1]; + // A level switched off in the middle of a chain contributes nothing, as in both + // products — "1..3" is never rendered. + if (!at || at.format === 'none') continue; + if (parts.length) parts.push(quote('.')); + parts.push(`counter(${COUNTER(l)}, ${CSS_STYLE[at.format]})`); + } + if (!parts.length) return null; + return [own.prefix ? quote(own.prefix) : '', ...parts, quote(own.suffix || ' ')] + .filter(Boolean).join(' '); +} + +// The plain-text label — what the contents entry and the chapter field show, where the +// browser's counters are not available. `counts` holds the number in force per level. +export function outlineLabel( + outline: OutlineNumbering | null | undefined, level: number, counts: readonly number[], + ordinal: (n: number, format: NoteNumFormat) => string, +): string { + if (!outline) return ''; + const own = outlineLevelAt(outline, level); + if (!own) return ''; + const from = Math.max(1, level - Math.max(1, own.displayLevels) + 1); + const parts: string[] = []; + for (let l = from; l <= level; l++) { + const at = outline[l - 1]; + if (!at || at.format === 'none') continue; + parts.push(ordinal(counts[l - 1] ?? at.start, at.format)); + } + if (!parts.length) return ''; + return `${own.prefix}${parts.join('.')}${own.suffix || ' '}`; +} + +// The counters that draw the labels. A heading increments its own level and resets every +// deeper one, which is what makes 2.1.4 restart at every new chapter. +export function outlineCss(outline: OutlineNumbering | null | undefined): string { + if (outlineIsEmpty(outline)) return ''; + const levels = outline!; + const rules: string[] = []; + const resets = levels + .map((l, i) => (l.format === 'none' ? '' : `${COUNTER(i + 1)} ${Math.max(0, (l.start ?? 1) - 1)}`)) + .filter(Boolean).join(' '); + rules.push(`.paper .tiptap {\n counter-reset: ${resets};\n}`); + for (let level = 1; level <= Math.min(MAX_OUTLINE_LEVELS, levels.length); level++) { + if (levels[level - 1]?.format === 'none') continue; + const deeper = levels + .map((l, i) => (i + 1 > level && l.format !== 'none' ? `${COUNTER(i + 1)} ${Math.max(0, (l.start ?? 1) - 1)}` : '')) + .filter(Boolean).join(' '); + rules.push(`.paper .tiptap h${level} {\n counter-increment: ${COUNTER(level)};${deeper ? `\n counter-reset: ${deeper};` : ''}\n}`); + const content = labelContent(levels, level); + if (content) rules.push(`.paper .tiptap h${level}::before {\n content: ${content};\n white-space: pre;\n}`); + } + // A heading in a cell, a list item or a frame is not part of the chapter count — + // neither product numbers one. + const hs = Array.from({ length: MAX_OUTLINE_LEVELS }, (_, i) => `h${i + 1}`).join(', '); + rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs}) {\n counter-increment: none;\n}`); + rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs})::before {\n content: none;\n}`); + return rules.join('\n\n'); +} diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index cc9a9f5..36fcafd 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -3,6 +3,7 @@ // formatting on the block or run still overrides the result. import { builtinTableStyles, tableStyleCss, type TableStyle } from './tableStyles'; +import { outlineCss, type OutlineNumbering } from './outlineNumbering'; import { builtinListStyles, type ListStyle } from './listStyles'; import type { CapsMode } from '../editor/extensions/textEffects'; @@ -59,6 +60,9 @@ export type StyleSheet = { character: Record; table: Record; list: Record; + // Chapter numbering, one entry per heading level. null = the headings are unnumbered, + // which is what a document that declares none has. + outline?: OutlineNumbering | null; }; export const DEFAULT_STYLE = 'Standard'; @@ -108,7 +112,8 @@ export function mergeStoredSheet(stored: unknown): StyleSheet { const sheet = builtinStyleSheet(); const data = stored as | { v?: number; paragraph?: Record; character?: Record; - table?: Record; list?: Record } + table?: Record; list?: Record; + outline?: OutlineNumbering | null } | null; if (!data?.paragraph || typeof data.paragraph !== 'object') return sheet; const current = data.v === STYLE_SHEET_VERSION; @@ -127,6 +132,7 @@ export function mergeStoredSheet(stored: unknown): StyleSheet { (sheet[family] as Record)[name] = style; } } + if (Array.isArray(data.outline)) sheet.outline = data.outline; return sheet; } @@ -135,7 +141,7 @@ export function builtinStyleSheet(): StyleSheet { for (const s of BUILTINS) paragraph[s.name] = structuredClone(s); const character: Record = {}; for (const s of CHAR_BUILTINS) character[s.name] = structuredClone(s); - return { paragraph, character, table: builtinTableStyles(), list: builtinListStyles() }; + return { paragraph, character, table: builtinTableStyles(), list: builtinListStyles(), outline: null }; } // Inheritance order: every style directly followed by its own children, so the manager's @@ -337,6 +343,8 @@ export function styleCss(sheet: StyleSheet): string { // Table styles last: their cell selectors must outrank the paragraph rules above. const table = tableStyleCss(sheet.table ?? {}); if (table) rules.push(table); + const outline = outlineCss(sheet.outline); + if (outline) rules.push(outline); return rules.join('\n\n'); } diff --git a/tests/unit/outline-numbering.test.ts b/tests/unit/outline-numbering.test.ts new file mode 100644 index 0000000..35043cc --- /dev/null +++ b/tests/unit/outline-numbering.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { builtinStyleSheet } from '../../src/lib/styles/styleSheet'; +import { outlineCss, outlineLabel, type OutlineNumbering } from '../../src/lib/styles/outlineNumbering'; +import { formatOrdinal } from '../../src/lib/utils/orderedListTypes'; + +const NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' + + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' + + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' + + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'; + +// Level 1 "1. ", level 2 "1.1 " — LibreOffice's own Tools ▸ Heading Numbering shape. +const outline: OutlineNumbering = [ + { format: '1', prefix: '', suffix: '. ', displayLevels: 1, start: 1 }, + { format: '1', prefix: '', suffix: ' ', displayLevels: 2, start: 1 }, + { format: 'A', prefix: '', suffix: ') ', displayLevels: 1, start: 2 }, +]; + +function odtWithOutline(): Uint8Array { + const styles = ` + + + + + + + `; + const content = ` + One + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const headingDoc = { + type: 'doc', + content: [1, 2, 3].map((level) => ({ + type: 'heading', attrs: { level }, content: [{ type: 'text', text: `H${level}` }], + })), +}; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +describe('chapter numbering', () => { + it('reads text:outline-style, level 1 first', () => { + expect(importOdt(odtWithOutline()).styles?.outline).toEqual(outline); + }); + + it('round-trips through ODT', async () => { + const sheet = { ...builtinStyleSheet(), outline }; + const bytes = await buildOdt(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + expect(styles).toContain(' { + const sheet = { ...builtinStyleSheet(), outline }; + const bytes = await buildDocx(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + const files = unzipSync(bytes); + expect(strFromU8(files['word/numbering.xml'])).toContain(''); + expect(strFromU8(files['word/styles.xml'])).toMatch(/w:styleId="Heading2"[\s\S]*?/); + // Word carries no prefix/suffix of its own — they live in the level text, so what + // comes back is the same label, split the same way. + expect(importDocx(bytes).styles?.outline).toEqual(outline); + }); + + it('labels a level from the counts in force', () => { + expect(outlineLabel(outline, 1, [3], formatOrdinal)).toBe('3. '); + expect(outlineLabel(outline, 2, [3, 4], formatOrdinal)).toBe('3.4 '); + expect(outlineLabel(outline, 3, [3, 4, 2], formatOrdinal)).toBe('B) '); + }); + + it('draws the label with counters that reset down the levels', () => { + const css = outlineCss(outline); + expect(css).toContain('counter-increment: edt-outline-1'); + expect(css).toMatch(/h2::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/); + // A heading in a cell or a list item is not part of the chapter count. + expect(css).toContain('counter-increment: none'); + }); +});