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:
1 parent
430bef5795
commit
80463883f0
9 files changed
+398
-5
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user