feat: chapter numbering — headings carry the number the file gives them

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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:06 +02:00
1 parent 430bef5795
commit 80463883f0
9 files changed
+398 -5

No files matched your search

+27 -2
View File
@@ -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;
+76 -1
View File
@@ -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(/<w:abstractNum [\s\S]*?<\/w:abstractNum>/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:multiLevelType[^>]*\/>)/, '<w:multiLevelType w:val="multilevel"/>')
// w:pStyle sits between w:lvlRestart and w:suff in the element's fixed order.
.replace(/<w:lvl\b[\s\S]*?<\/w:lvl>/g, (lvl) => {
level += 1;
return lvl.replace(/(<w:numFmt[^>]*\/>)/, `$1<w:pStyle w:val="Heading${level + 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(`<w:num w:numId="(\\d+)"[^>]*>\\s*<w:abstractNumId w:val="${absId}"/>`).exec(numbering)?.[1];
if (!numId) {
const ids = [...numbering.matchAll(/<w:num w:numId="(\d+)"/g)].map((m) => Number(m[1]));
numId = String(Math.max(0, ...ids) + 1);
numbering = numbering.replace('</w:numbering>', `<w:num w:numId="${numId}"><w:abstractNumId w:val="${absId}"/></w:num></w:numbering>`);
}
for (let n = 1; n <= 9; n++) {
const numPr = `<w:numPr><w:ilvl w:val="${n - 1}"/><w:numId w:val="${numId}"/></w:numPr>`;
stylesXml = stylesXml.replace(
new RegExp(`(<w:style w:type="paragraph"[^>]*w:styleId="Heading${n}"[\\s\\S]*?)(<w:pPr>)`),
`$1$2${numPr}`);
}
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.
@@ -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;
+24
View File
@@ -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<string>,
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('</office:styles>', `${added.join('')}</office:styles>`) : styles;
}
// The document's chapter numbering. LibreOffice keeps it in one <text:outline-style>
// 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<string, string> = {
'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('<text:outline-level-style/>', attrs));
});
return levels.length ? `<text:outline-style style:name="Outline">${levels.join('')}</text:outline-style>` : '';
}
// 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).
+32
View File
@@ -5,6 +5,7 @@ import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../e
import { PLACEHOLDER_SDT_TAG } from '../export/docx';
import { FOLD_MARK_NAME } from '../storage/foldMarks';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { 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<string, string>();
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;
}
+32
View File
@@ -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;
}
// <text:outline-style> → 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 <text:list-style> 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 {
+5
View File
@@ -818,6 +818,11 @@ export class StyleResolver {
return { margins, orientation, format, rtl };
}
// The document's chapter numbering — one <text:outline-style> 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).
+108
View File
@@ -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<NoteNumFormat, string> = {
'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');
}
+10 -2
View File
@@ -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<string, Style>;
table: Record<string, TableStyle>;
list: Record<string, ListStyle>;
// 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<string, Style>; character?: Record<string, Style>;
table?: Record<string, TableStyle>; list?: Record<string, ListStyle> }
table?: Record<string, TableStyle>; list?: Record<string, ListStyle>;
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<string, TableStyle | ListStyle>)[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<string, Style> = {};
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');
}
+84
View File
@@ -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 = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
<style:style style:name="Standard" style:family="paragraph"/>
<text:outline-style style:name="Outline">
<text:outline-level-style text:level="1" style:num-suffix=". " style:num-format="1"/>
<text:outline-level-style text:level="2" style:num-suffix=" " style:num-format="1" text:display-levels="2"/>
<text:outline-level-style text:level="3" style:num-suffix=") " style:num-format="A" text:start-value="2"/>
</text:outline-style>
</office:styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
<text:h text:outline-level="1" text:style-name="Standard">One</text:h>
</office:text></office:body></office:document-content>`;
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('<text:outline-style');
expect(importOdt(bytes).styles?.outline).toEqual(outline);
});
it('round-trips through DOCX as a numbering the heading styles carry', async () => {
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('<w:pStyle w:val="Heading1"/>');
expect(strFromU8(files['word/styles.xml'])).toMatch(/w:styleId="Heading2"[\s\S]*?<w:numPr><w:ilvl w:val="1"\/>/);
// 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');
});
});