feat: improve ordered list

This commit is contained in:
Steffen Becker committed 2026-07-04 21:45:05 +02:00
1 parent 3b7e7606bb
commit d018092cce
14 files changed
+508 -163

No files matched your search

+1
View File
@@ -30,6 +30,7 @@
- Line spacing and paragraph spacing (space before / after)
- Increase / decrease indent (paragraphs and lists)
- Bulleted and ordered lists — nested, with multiple numbering styles (decimal, alpha, roman; `.` or `)` suffix) and whole-list indent; Tab / Shift-Tab to nest / un-nest
- Word-style nesting defaults for ordered lists: an indented level numbers 1. → a. → i. (repeating) instead of restarting at "1." everywhere; explicit styles per level still override. Plus legal/outline numbering (1., 1.1., 1.2.1. …) as a list type — rendered via CSS counters, round-trips to ODF `text:display-levels` and DOCX `%1.%2.` lvlText chains
- Customizable bullet symbols per list level (Word-style picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF `text:bullet-char` and DOCX `w:lvlText`, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (`EdenSymbols.woff2`), so markers look compact and identical on every platform instead of a stretched OS fallback
- Tab stops (real tab character) and manual line breaks (Shift+Enter)
+6 -4
View File
@@ -2,8 +2,9 @@
import type { Editor } from '@tiptap/core';
import HistoryButton from './HistoryButton.svelte';
import AlignButton from './AlignButton.svelte';
import { ORDERED_LIST_TYPES, DEFAULT_ORDERED_TYPE, type OrderedListType } from '../utils/orderedListTypes';
import { ORDERED_LIST_TYPES, type OrderedListType } from '../utils/orderedListTypes';
import { BULLET_TYPES } from '../utils/bulletListTypes';
import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -27,10 +28,11 @@
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList= $derived(tick >= 0 && !!editor?.isActive('orderedList'));
// Numbering style of the ordered list at the cursor (null when not in one).
// Effective numbering at the cursor's list level — explicit attr, inherited
// multilevel chain, or the depth default (null when not in an ordered list).
let currentOrderedType = $derived.by<OrderedListType | null>(() => {
if (tick < 0 || !editor || !editor.isActive('orderedList')) return null;
return (editor.getAttributes('orderedList').listStyleType ?? DEFAULT_ORDERED_TYPE) as OrderedListType;
return effectiveOrderedTypeAt(editor.state);
});
let olMenuOpen = $state(false);
@@ -71,7 +73,7 @@
olMenuOpen = false;
const chain = editor.chain().focus();
if (!editor.isActive('orderedList')) chain.toggleOrderedList();
chain.updateAttributes('orderedList', { listStyleType: key }).run();
chain.setOrderedListType(key).run();
}
function menuClickOutside(node: HTMLElement, close: () => void) {
+98 -8
View File
@@ -1,22 +1,112 @@
import OrderedListBase from '@tiptap/extension-ordered-list';
import { DEFAULT_ORDERED_TYPE } from '../../utils/orderedListTypes';
import type { EditorState } from '@tiptap/pm/state';
import type { ResolvedPos } from '@tiptap/pm/model';
import { defaultOrderedType, orderedTypeAttr, type OrderedListType } from '../../utils/orderedListTypes';
// OrderedList with a `listStyleType` attr (an ORDERED_LIST_TYPES key), rendered
// as `data-list-style` on the <ol>: editor.css maps it to the on-screen marker,
// export/odt.ts to style:num-format. Parent attrs spread in to keep start/type.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
customOrderedList: {
/** Set the numbering style of the ordered list at the cursor (multilevel targets the outermost list). */
setOrderedListType: (key: OrderedListType) => ReturnType;
};
}
}
// 0-based list nesting depth of the node at resolved depth `d` (counts bullet and
// ordered ancestors — the level Word/ODF assign to it).
function listDepth0($from: ResolvedPos, d: number): number {
let depth0 = 0;
for (let i = d - 1; i > 0; i--) {
const name = $from.node(i).type.name;
if (name === 'bulletList' || name === 'orderedList') depth0++;
}
return depth0;
}
// Innermost/outermost ordered-list ancestor depths at the cursor (null = not in one).
function orderedContext(state: EditorState): { innermost: number; outermost: number } | null {
const { $from } = state.selection;
let innermost = -1;
let outermost = -1;
for (let d = $from.depth; d > 0; d--) {
if ($from.node(d).type.name !== 'orderedList') continue;
if (innermost === -1) innermost = d;
outermost = d;
}
return innermost === -1 ? null : { innermost, outermost };
}
// The numbering the cursor's list level renders: explicit attr, else 'multilevel'
// when a governing ancestor chain is set to it, else the depth default. Drives the
// toolbar's active state. null = not in an ordered list.
export function effectiveOrderedTypeAt(state: EditorState): OrderedListType | null {
const ctx = orderedContext(state);
if (!ctx) return null;
const { $from } = state.selection;
const own = $from.node(ctx.innermost).attrs.listStyleType as OrderedListType | null;
if (own) return own;
for (let d = ctx.innermost - 1; d > 0; d--) {
const node = $from.node(d);
if (node.type.name !== 'orderedList') continue;
const t = node.attrs.listStyleType as string | null;
if (t === 'multilevel') return 'multilevel';
if (t) break; // a nearer explicit style cuts the chain
}
return defaultOrderedType(listDepth0($from, ctx.innermost));
}
// OrderedList with a `listStyleType` attr (an ORDERED_LIST_TYPES key; null = the
// per-depth default cycle 1. → a. → i.), rendered as `data-list-style` on the <ol>:
// editor.css maps it to the on-screen marker, export/odt.ts to style:num-format.
export const OrderedList = OrderedListBase.extend({
addAttributes() {
return {
...this.parent?.(),
listStyleType: {
default: DEFAULT_ORDERED_TYPE,
parseHTML: (element) => element.getAttribute('data-list-style') || DEFAULT_ORDERED_TYPE,
default: null,
parseHTML: (element) => element.getAttribute('data-list-style') || null,
renderHTML: (attributes) => {
const v = attributes.listStyleType;
// Omit the attr for the default so plain lists stay clean in the DOM.
return v && v !== DEFAULT_ORDERED_TYPE ? { 'data-list-style': v } : {};
// An explicit decimal must be emitted: it overrides the depth cycle.
return v ? { 'data-list-style': v } : {};
},
},
};
},
addCommands() {
return {
...this.parent?.(),
// setNodeMarkup on exactly the target list (updateAttributes would rewrite
// ancestor lists too). 'multilevel' is list-wide: it goes on the outermost
// ordered list and clears explicit styles below so the whole chain renders.
setOrderedListType:
(key) =>
({ state, tr, dispatch }) => {
const ctx = orderedContext(state);
if (!ctx) return false;
const { $from } = state.selection;
const target = key === 'multilevel' ? ctx.outermost : ctx.innermost;
const node = $from.node(target);
const pos = $from.before(target);
// Inside a multilevel chain an explicit style must stay explicit even if
// it matches the depth default — null would re-join the chain.
const inChain = target !== ctx.outermost && $from.node(ctx.outermost).attrs.listStyleType === 'multilevel';
const value = key === 'multilevel' ? 'multilevel'
: inChain ? key
: orderedTypeAttr(key, listDepth0($from, target));
if (dispatch) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, listStyleType: value });
if (key === 'multilevel') {
node.descendants((child, offset) => {
if (child.type.name === 'orderedList' && child.attrs.listStyleType) {
tr.setNodeMarkup(tr.mapping.map(pos + 1 + offset), undefined, { ...child.attrs, listStyleType: null });
}
});
}
}
return true;
},
};
},
});
+20 -7
View File
@@ -19,7 +19,7 @@ import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { orderedTypeDef } from '../utils/orderedListTypes';
import { effectiveOrderedDef, formatOrdinal } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
@@ -93,6 +93,7 @@ function hexColor(input: string): string | undefined {
class Numbering {
readonly config: { reference: string; levels: ILevelsOptions[] }[] = [];
private map = new Map<string, ILevelsOptions[]>();
private mlRefs = new Set<string>();
private counter = 0;
newReference(): string {
@@ -104,6 +105,9 @@ class Numbering {
}
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number): 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);
const levels = this.map.get(reference)!;
if (levels.some((l) => l.level === depth)) return;
const indent: IIndentAttributesProperties = {
@@ -111,11 +115,15 @@ class Numbering {
hanging: cmToTwip(LIST_HANGING_CM),
};
if (node.type === 'orderedList') {
const def = orderedTypeDef(node.attrs?.listStyleType as string | undefined);
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr);
const def = effectiveOrderedDef(attr === 'multilevel' ? 'decimal' : attr, depth);
levels.push({
level: depth,
format: ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL,
text: `%${depth + 1}${def.numSuffix}`,
format: chained ? LevelFormat.DECIMAL : ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL,
text: chained
? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
: `%${depth + 1}${def.numSuffix}`,
alignment: AlignmentType.LEFT,
start: typeof node.attrs?.start === 'number' ? node.attrs.start : 1,
style: { paragraph: { indent } },
@@ -400,8 +408,12 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
// In-box lists flatten to literal-marker paragraphs (real numbering would need
// numbering.xml references, which the post-pack string pass can't mint).
function txbxListXml(node: TiptapNode, depth: number): string {
// `mlPrefix`: parent chain ("1.2.") when inside a multilevel list, else null.
function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null): string {
const ordered = node.type === 'orderedList';
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = ordered && (attr === 'multilevel' || (mlPrefix !== null && !attr));
const def = effectiveOrderedDef(attr === 'multilevel' ? 'decimal' : attr, depth);
let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
let out = '';
for (const item of node.content ?? []) {
@@ -409,9 +421,10 @@ function txbxListXml(node: TiptapNode, depth: number): string {
let first = true;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
out += txbxListXml(child, depth + 1);
out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null);
} else if (child.type === 'paragraph' || child.type === 'heading') {
const marker = first ? (ordered ? `${n}. ` : `${bulletCharOf(node, depth)} `) : '';
const ordinal = chained ? `${mlPrefix ?? ''}${n}. ` : `${formatOrdinal(n, def.numFormat)}${def.numSuffix} `;
const marker = first ? (ordered ? ordinal : `${bulletCharOf(node, depth)} `) : '';
out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
first = false;
}
+128 -58
View File
@@ -6,7 +6,7 @@ import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
import { orderedTypeDef, effectiveOrderedDef } from '../utils/orderedListTypes';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -401,10 +401,9 @@ function paraStyleProps(style: ParaStyle): string[] {
// applyCellBlocks to rebuild real <text:h>/<text:p>/<text:list> from the SEG-segmented
// <text:p> odf-kit emits. Each paragraph/heading/list item is one segment, in DFS order.
type CellListItem = { style: ParaStyle; nested: CellListBlock | null };
// numFormat/numSuffix carry the ordered-list numbering style (orderedTypeDef);
// for bullet lists they hold the decimal defaults and are unused. bulletChar is
// the bullet list's marker attr (null = default cycle).
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; numFormat: string; numSuffix: string; bulletChar: string | null; items: CellListItem[] };
// listStyleType is the ordered list's raw attr (null = depth default / inherited
// multilevel chain); bulletChar the bullet list's marker attr (null = default cycle).
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; listStyleType: string | null; bulletChar: string | null; items: CellListItem[] };
type CellBlock =
| { kind: 'paragraph'; style: ParaStyle }
| { kind: 'heading'; level: number; style: ParaStyle }
@@ -696,26 +695,64 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// odf-kit always emits ordered lists as "1." (num-format="1" num-suffix="."). This
// rewrites the matching L# list style to the user's chosen format; formats[] has one
// entry per top-level list in odf-kit's order (null for bullets/default 'decimal').
// Limitation: the chosen format applies to every nesting level (odf-kit emits one
// L# style per top-level list), so a nested list of a different type inherits it.
// odf-kit always emits ordered lists as "1." at every level. This rewrites the
// matching L# list style per level: the depth-default cycle (1. → a. → i.), explicit
// listStyleType attrs, or the multilevel chain (decimal + text:display-levels).
type OrderedFmt = { numFormat: string; numSuffix: string };
type OlStyleFix = { fmts: OrderedFmt[]; multilevel: boolean };
function collectOrderedListFormats(node: TiptapNode, result: (OrderedFmt | null)[]): void {
const DEFAULT_ORDERED_FMTS: OrderedFmt[] = Array.from({ length: 6 }, (_, i) => {
const d = effectiveOrderedDef(null, i);
return { numFormat: d.numFormat, numSuffix: d.numSuffix };
});
const MULTILEVEL_FMTS: OrderedFmt[] = Array.from({ length: 6 }, () => ({ numFormat: '1', numSuffix: '.' }));
// Effective numbering of an ordered list at a 1-based depth: its attr, else the
// depth-default cycle ('multilevel' handled by the callers).
function effOrderedFmt(list: TiptapNode, depth: number): OrderedFmt {
const key = list.attrs?.listStyleType as string | null | undefined;
const def = effectiveOrderedDef(key === 'multilevel' ? 'decimal' : key, depth - 1);
return { numFormat: def.numFormat, numSuffix: def.numSuffix };
}
// Per-level numbering for a list subtree — same DFS-first model as bulletCharVector.
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[]): OrderedFmt[] {
const vec = [...base];
const seen = new Set<number>();
const walk = (node: TiptapNode, depth: number) => {
if (depth > vec.length) return;
if (node.type === 'orderedList' && !seen.has(depth)) {
seen.add(depth);
vec[depth - 1] = effOrderedFmt(node, depth);
}
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1);
}
}
};
walk(list, startDepth);
return vec;
}
function collectOrderedListFormats(node: TiptapNode, result: (OlStyleFix | null)[]): void {
for (const child of node.content ?? []) {
if (child.type === 'bulletList') {
result.push(null);
} else if (child.type === 'orderedList') {
const def = orderedTypeDef(child.attrs?.listStyleType as string | undefined);
result.push(def.key === DEFAULT_ORDERED_TYPE ? null : { numFormat: def.numFormat, numSuffix: def.numSuffix });
if (child.attrs?.listStyleType === 'multilevel') {
result.push({ fmts: MULTILEVEL_FMTS, multilevel: true });
} else {
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS);
// odf-kit's own output is all-decimal; only an all-"1." vector needs no rewrite.
result.push(vec.every(f => f.numFormat === '1' && f.numSuffix === '.') ? null : { fmts: vec, multilevel: false });
}
}
}
}
function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | null)[]): Uint8Array {
function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OlStyleFix | null)[]): Uint8Array {
if (formats.every(f => f === null)) return odtBytes;
const files = unzipSync(odtBytes);
@@ -723,15 +760,20 @@ function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | nu
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
formats.forEach((fmt, i) => {
if (!fmt) return;
// Rewrite only inside this list's own <text:list-style> block (all 6 levels).
formats.forEach((fix, i) => {
if (!fix) return;
// Rewrite only inside this list's own <text:list-style> block, level by level.
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
content = content.replace(re, (_m, open: string, body: string, close: string) =>
open +
body
.replace(/style:num-format="1"/g, `style:num-format="${fmt.numFormat}"`)
.replace(/style:num-suffix="\."/g, `style:num-suffix="${fmt.numSuffix}"`) +
body.replace(
/(<text:list-level-style-number text:level=")(\d)(" style:num-format=")[^"]*(" style:num-suffix=")[^"]*(")/g,
(_mm, a: string, lvl: string, b: string, c: string, d: string) => {
const f = fix.fmts[+lvl - 1] ?? DEFAULT_ORDERED_FMTS[+lvl - 1];
const disp = fix.multilevel && +lvl > 1 ? ` text:display-levels="${lvl}"` : '';
return a + lvl + b + f.numFormat + c + f.numSuffix + d + disp;
},
) +
close,
);
});
@@ -852,15 +894,14 @@ function applyListIndents(odtBytes: Uint8Array, indents: number[]): Uint8Array {
// odf-kit emits nested lists as bare <text:list> sharing the top-level L# style, so a
// nested list of a different kind/format/marker loses its look. Mint it its own
// 6-level list style (same pattern applyCellBlocks uses for cell lists).
type ListDef = { ordered: boolean; numFormat: string; numSuffix: string; bulletChars: string[] };
type ListDef = { ordered: boolean; multilevel: boolean; fmts: OrderedFmt[]; bulletChars: string[] };
function listDefOf(node: TiptapNode, depth: number, baseChars: string[]): ListDef {
const ordered = node.type === 'orderedList';
const def = ordered ? orderedTypeDef(node.attrs?.listStyleType as string | undefined) : null;
function listDefOf(node: TiptapNode, depth: number, baseChars: string[], baseFmts: OrderedFmt[]): ListDef {
const multilevel = node.attrs?.listStyleType === 'multilevel';
return {
ordered,
numFormat: def?.numFormat ?? '1',
numSuffix: def?.numSuffix ?? '.',
ordered: node.type === 'orderedList',
multilevel,
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts),
bulletChars: bulletCharVector(node, depth, baseChars),
};
}
@@ -872,11 +913,21 @@ function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): vo
let gov = governing;
if (!isTop) {
const ordered = list.type === 'orderedList';
const fmt = ordered ? orderedTypeDef(list.attrs?.listStyleType as string | undefined) : null;
const differs = ordered !== governing.ordered
|| (ordered && (fmt!.numFormat !== governing.numFormat || fmt!.numSuffix !== governing.numSuffix))
|| (!ordered && effBulletChar(list, depth) !== governing.bulletChars[(depth - 1) % governing.bulletChars.length]);
const def = differs ? listDefOf(list, depth, governing.bulletChars) : null;
let differs: boolean;
if (ordered !== governing.ordered) {
differs = true;
} else if (!ordered) {
differs = effBulletChar(list, depth) !== governing.bulletChars[(depth - 1) % governing.bulletChars.length];
} else if (governing.multilevel) {
// Attr-less lists inherit the chain; only an explicit style breaks out.
const attr = list.attrs?.listStyleType;
differs = !!attr && attr !== 'multilevel';
} else {
const f = effOrderedFmt(list, depth);
const g = governing.fmts[(depth - 1) % governing.fmts.length];
differs = f.numFormat !== g.numFormat || f.numSuffix !== g.numSuffix;
}
const def = differs ? listDefOf(list, depth, governing.bulletChars, governing.fmts) : null;
result.push(def);
if (def) gov = def; // restyled list governs its own descendants
}
@@ -889,7 +940,7 @@ function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): vo
};
for (const child of doc.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
walkList(child, listDefOf(child, 1, DEFAULT_BULLET_CYCLE), true, 1);
walkList(child, listDefOf(child, 1, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS), true, 1);
}
}
}
@@ -905,12 +956,14 @@ function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]):
const minted: string[] = [];
const nameByKey = new Map<string, string>();
const styleFor = (fix: ListDef): string => {
const key = fix.ordered ? `o|${fix.numFormat}|${fix.numSuffix}` : `b|${fix.bulletChars.join('')}`;
const key = fix.ordered
? `o|${fix.multilevel ? 'M' : ''}|${fix.fmts.map(f => f.numFormat + f.numSuffix).join('')}`
: `b|${fix.bulletChars.join('')}`;
let name = nameByKey.get(key);
if (!name) {
name = `NL${nameByKey.size + 1}`;
nameByKey.set(key, name);
minted.push(buildCellListStyle(name, fix.ordered, fix.numFormat, fix.numSuffix, fix.bulletChars));
minted.push(buildCellListStyle(name, fix.ordered, { fmts: fix.fmts, multilevel: fix.multilevel, bulletChars: fix.bulletChars }));
}
return name;
};
@@ -932,7 +985,13 @@ function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]):
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
// in-cell lists reference. Six nesting levels with the given bullet chars /
// indents / numbering, so cell lists match the editor's top-level lists.
function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1', numSuffix = '.', bulletChars: string[] = DEFAULT_BULLET_CYCLE): string {
function buildCellListStyle(
styleName: string,
ordered: boolean,
opts: { fmts?: OrderedFmt[]; multilevel?: boolean; bulletChars?: string[] } = {},
): string {
const fmts = opts.multilevel ? MULTILEVEL_FMTS : opts.fmts ?? MULTILEVEL_FMTS;
const bulletChars = opts.bulletChars ?? DEFAULT_BULLET_CYCLE;
let levels = '';
for (let level = 1; level <= 6; level++) {
const indent = level * 0.635;
@@ -940,7 +999,9 @@ function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1'
const textIndent = `-${indent.toFixed(3)}cm`;
const labelAlign = `<style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="${marginLeft}" fo:text-indent="${textIndent}" fo:margin-left="${marginLeft}"/></style:list-level-properties>`;
if (ordered) {
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${numFormat}" style:num-suffix="${numSuffix}">${labelAlign}</text:list-level-style-number>`;
const f = fmts[(level - 1) % fmts.length];
const disp = opts.multilevel && level > 1 ? ` text:display-levels="${level}"` : '';
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${f.numFormat}" style:num-suffix="${f.numSuffix}"${disp}>${labelAlign}</text:list-level-style-number>`;
} else {
const ch = bulletChars[(level - 1) % bulletChars.length];
levels += `<text:list-level-style-bullet text:level="${level}" text:bullet-char="${ch}">${labelAlign}</text:list-level-style-bullet>`;
@@ -1011,18 +1072,18 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
}
return name;
};
// Ordered cell lists are minted one style per distinct numbering format (1. / a) /
// I. / …) so each list exports with its chosen type. The first format reuses the
// legacy CELL_LIST_NUMBER_STYLE name; further ones get a counter suffix.
const numberStyles: { name: string; numFormat: string; numSuffix: string }[] = [];
// One minted style per distinct numbering format (first = CELL_LIST_NUMBER_STYLE,
// further ones counter-suffixed). The multilevel chain shares one style: nested lists
// reference the same name, and their nesting depth selects the display-levels def.
const numberStyles: { name: string; numFormat: string; numSuffix: string; multilevel?: boolean }[] = [];
const numberStyleByKey = new Map<string, string>();
const numberStyleFor = (numFormat: string, numSuffix: string): string => {
const key = `${numFormat}|${numSuffix}`;
const numberStyleFor = (numFormat: string, numSuffix: string, multilevel = false): string => {
const key = multilevel ? 'ML' : `${numFormat}|${numSuffix}`;
let name = numberStyleByKey.get(key);
if (!name) {
name = numberStyleByKey.size === 0 ? CELL_LIST_NUMBER_STYLE : `${CELL_LIST_NUMBER_STYLE}${numberStyleByKey.size}`;
numberStyleByKey.set(key, name);
numberStyles.push({ name, numFormat, numSuffix });
numberStyles.push({ name, numFormat, numSuffix, multilevel });
}
return name;
};
@@ -1030,16 +1091,25 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
// bullet/number nesting renders correctly; indent comes from nesting depth. One
// segment consumed per list item, DFS order (matching buildCellContent).
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1): string => {
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1, mlGov = false): string => {
const isBullet = !list.ordered;
const listStyle = isBullet ? bulletStyleFor(list.bulletChar, depth) : numberStyleFor(list.numFormat, list.numSuffix);
const ml = !isBullet && (list.listStyleType === 'multilevel' || (mlGov && !list.listStyleType));
let listStyle: string;
if (isBullet) {
listStyle = bulletStyleFor(list.bulletChar, depth);
} else if (ml) {
listStyle = numberStyleFor('1', '.', true);
} else {
const def = effectiveOrderedDef(list.listStyleType, depth - 1);
listStyle = numberStyleFor(def.numFormat, def.numSuffix);
}
const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number';
let out = `<text:list text:style-name="${listStyle}">`;
list.items.forEach((item, idx) => {
const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : '';
const seg = segments[cur.i++] ?? '';
out += `<text:list-item${startAttr}><text:p text:style-name="${styleNameFor(paraParent, item.style)}">${seg}</text:p>`;
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1);
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1, ml);
out += '</text:list-item>';
});
return out + '</text:list>';
@@ -1091,10 +1161,10 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
);
if (usedBulletList) additions.push(buildCellListStyle(CELL_LIST_BULLET_STYLE, false));
for (const { name, chars } of bulletStyles) {
additions.push(buildCellListStyle(name, false, '1', '.', chars));
additions.push(buildCellListStyle(name, false, { bulletChars: chars }));
}
for (const { name, numFormat, numSuffix } of numberStyles) {
additions.push(buildCellListStyle(name, true, numFormat, numSuffix));
for (const { name, numFormat, numSuffix, multilevel } of numberStyles) {
additions.push(buildCellListStyle(name, true, { fmts: Array.from({ length: 6 }, () => ({ numFormat, numSuffix })), multilevel }));
}
if (additions.length) {
@@ -1300,8 +1370,6 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder, forceBold = false):
const walkList = (listNode: TiptapNode): CellListBlock => {
const ordered = listNode.type === 'orderedList';
const start = ordered ? ((listNode.attrs?.start as number) ?? null) : null;
// Same numbering style the editor shows / top-level export uses (orderedTypeDef).
const def = ordered ? orderedTypeDef(listNode.attrs?.listStyleType as string | undefined) : null;
const items: CellListItem[] = [];
for (const item of listNode.content ?? []) {
if (item.type !== 'listItem') continue;
@@ -1310,8 +1378,10 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder, forceBold = false):
const nested = item.content?.find(x => x.type === 'bulletList' || x.type === 'orderedList');
items.push({ style: paraStyleFromAttrs(firstPara?.attrs), nested: nested ? walkList(nested) : null });
}
// Raw attrs; applyCellBlocks resolves the depth defaults when it knows the depth.
const listStyleType = ordered && typeof listNode.attrs?.listStyleType === 'string' ? (listNode.attrs.listStyleType as string) : null;
const bulletChar = !ordered && typeof listNode.attrs?.bulletChar === 'string' ? (listNode.attrs.bulletChar as string) : null;
return { kind: 'list', ordered, start, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.', bulletChar, items };
return { kind: 'list', ordered, start, listStyleType, bulletChar, items };
};
for (const block of cell.content ?? []) {
@@ -1830,10 +1900,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
},
});
// Rewrite odf-kit's default numbering (1.) into the per-list style the user
// chose (a) / I.) / …). Runs before applyListItemStyles, which only touches
// <text:p> styles, not the <text:list-style> definitions.
const olFormats: (OrderedFmt | null)[] = [];
// Rewrite odf-kit's default numbering (1.) into per-level formats (depth cycle,
// explicit types, multilevel chains). Runs before applyListItemStyles, which only
// touches <text:p> styles, not the <text:list-style> definitions.
const olFormats: (OlStyleFix | null)[] = [];
collectOrderedListFormats(raw, olFormats);
let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
+43 -60
View File
@@ -8,7 +8,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { extensions } from '../editor/extensions';
import { columnPercents } from '../editor/extensions/tableView';
import { orderedTypeDef } from '../utils/orderedListTypes';
import { effectiveOrderedDef, formatOrdinal } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { deriveFilename } from './odt';
import globalCss from '../../styles/global.css?inline';
@@ -122,34 +122,50 @@ function collectRuns(root: HTMLElement): Run[] {
return runs;
}
function toRoman(n: number): string {
const map: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
let s = '';
for (const [v, sym] of map) while (n >= v) { s += sym; n -= v; }
return s;
// 1-based ordinal of a li within its list (start attr respected).
function liOrdinal(li: HTMLElement): number {
const parent = li.parentElement!;
const items = Array.from(parent.children).filter((c) => c.tagName === 'LI');
return parseInt(parent.getAttribute('start') ?? '1', 10) + items.indexOf(li);
}
function toAlpha(n: number): string {
let s = '';
while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); }
return s;
// The <ol> whose 'multilevel' style governs this list: itself, or the nearest
// ordered ancestor with the attr reached without crossing another explicit style.
function multilevelRoot(ol: HTMLElement, root: HTMLElement): HTMLElement | null {
for (let e: HTMLElement | null = ol; e && e !== root; e = e.parentElement) {
if (e.tagName !== 'OL') continue;
const t = e.getAttribute('data-list-style');
if (t === 'multilevel') return e;
if (t) return null; // a nearer explicit style cuts the chain
}
return null;
}
// The marker text the browser renders for a list item: the nesting-depth bullet for a
// <ul>, or the formatted ordinal (orderedListTypes) for an <ol>.
// The marker text the browser renders for a list item: the depth bullet/bulletChar
// for a <ul>, the effective ordinal (depth cycle 1. → a. → i., explicit type, or
// the multilevel chain "1.2.1.") for an <ol>.
function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
const parent = li.parentElement;
if (!parent) return '';
if (parent.tagName === 'OL') {
const items = Array.from(parent.children).filter((c) => c.tagName === 'LI');
const n = parseInt(parent.getAttribute('start') ?? '1', 10) + items.indexOf(li);
const def = orderedTypeDef(parent.getAttribute('data-list-style'));
const body = def.numFormat === 'a' ? toAlpha(n)
: def.numFormat === 'A' ? toAlpha(n).toUpperCase()
: def.numFormat === 'i' ? toRoman(n)
: def.numFormat === 'I' ? toRoman(n).toUpperCase()
: String(n);
return body + def.numSuffix;
const mlRoot = multilevelRoot(parent, root);
if (mlRoot) {
// Chain: ordinals of every ordered level from the multilevel root down.
const chain: number[] = [];
for (let e: HTMLElement | null = li; e && e !== root; e = e.parentElement) {
if (e.tagName === 'LI' && e.parentElement?.tagName === 'OL') {
chain.unshift(liOrdinal(e));
if (e.parentElement === mlRoot) break;
}
}
return chain.join('.') + '.';
}
let olDepth = 0;
for (let e: HTMLElement | null = parent; e && e !== root; e = e.parentElement) {
if (e.tagName === 'OL') olDepth++;
}
const def = effectiveOrderedDef(parent.getAttribute('data-list-style'), olDepth - 1);
return formatOrdinal(liOrdinal(li), def.numFormat) + def.numSuffix;
}
// Explicit bulletChar attr wins; otherwise the default cycle keyed by the number
// of <ul> ancestors (matching editor.css's `ul ul …` depth rules).
@@ -162,11 +178,11 @@ function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
return defaultBulletChar(depth - 1);
}
// html2canvas can't paint string list-style-type values (all our <ul> markers), so
// bullets become real spans hanging left of the first line — the same markup
// relocateFloatPushedMarkers uses, which also puts the glyphs into the text layer.
function materializeBulletMarkers(root: HTMLElement): void {
for (const li of Array.from(root.querySelectorAll<HTMLLIElement>('ul > li'))) {
// html2canvas can't paint our markers (string list-style-type, @counter-style paren
// numbering, counters() chains), so each marker becomes a real span hanging left of the
// item's first line — also keeping it beside float-pushed lines and in the text layer.
function materializeListMarkers(root: HTMLElement): void {
for (const li of Array.from(root.querySelectorAll<HTMLLIElement>('li'))) {
const cs = getComputedStyle(li);
if (cs.listStyleType === 'none') continue;
const glyph = listMarkerGlyph(li, root);
@@ -185,36 +201,6 @@ function materializeBulletMarkers(root: HTMLElement): void {
}
}
// html2canvas paints list markers at the item's content-box left edge, ignoring floats, so
// a floating image that pushes an item's first line right strands the marker on the far side
// of the image. Re-hang such markers beside the shifted line (matching the editor's render).
function relocateFloatPushedMarkers(root: HTMLElement): void {
for (const li of Array.from(root.querySelectorAll<HTMLLIElement>('li'))) {
const cs = getComputedStyle(li);
if (cs.listStyleType === 'none' || cs.textAlign === 'center' || cs.textAlign === 'right') continue;
const p = li.querySelector(':scope > p');
if (!p) continue;
const range = document.createRange();
range.selectNodeContents(p);
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
if (!rects.length) continue;
const liRect = li.getBoundingClientRect();
const contentLeft = liRect.left + parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth);
// First line sits in the normal marker column → html2canvas already paints it right.
if (rects[0].left <= contentLeft + 6) continue;
const glyph = listMarkerGlyph(li, root);
if (!glyph) continue;
li.style.listStyleType = 'none';
const slot = document.createElement('span');
slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
const label = document.createElement('span');
label.style.cssText = `display:inline-block;white-space:pre;transform:translateX(-100%);padding-right:0.4em;color:${cs.color}`;
label.textContent = glyph;
slot.appendChild(label);
p.insertBefore(slot, p.firstChild);
}
}
// Off-screen raster of the live .paper at scale 1, captured to one tall canvas covering
// every page (page i occupies [i*cycle, i*cycle+pageH]). Shared by the PDF download and
// the raster print path; the caller invokes cleanup() once done reading `clone`. `scale`
@@ -236,10 +222,7 @@ export async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<
try {
await document.fonts.ready;
// Bullets first: it sets list-style none on <ul> items, so the float pass
// (which skips those) only re-hangs ordered-list markers.
materializeBulletMarkers(clone);
relocateFloatPushedMarkers(clone);
materializeListMarkers(clone);
const pages = Math.max(1, opts.numPages ?? Math.round((clone.offsetHeight + PAGE_GAP) / cycle));
const html2canvas = (await import('html2canvas')).default;
+12 -3
View File
@@ -3,7 +3,7 @@ import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A,
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { orderedTypeFromFormat, orderedTypeAttr } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -293,8 +293,17 @@ function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
const ch = bulletCharAttr(bulletCharFromDocx(def.lvlText, def.bulletFont), ilvl);
if (ch) attrs.bulletChar = ch;
} else {
const lst = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
if (lst !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = lst;
const placeholders = (def.lvlText?.match(/%\d/g) ?? []).length;
// Multilevel chains ("%1.%2." lvlText below level 0): the attr sits on the top
// list only; chain members stay attr-less.
const chainBelow = ((ctx.styles.level(numId, 1).lvlText ?? '').match(/%\d/g) ?? []).length > 1;
if (ilvl === 0 && chainBelow) {
attrs.listStyleType = 'multilevel';
} else if (placeholders <= 1) {
const key = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
const attr = orderedTypeAttr(key, ilvl);
if (attr) attrs.listStyleType = attr;
}
if (def.start != null && def.start > 1) attrs.start = def.start;
}
if (ilvl === 0 && def.leftTwip != null) {
+25 -8
View File
@@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { orderedTypeFromFormat, orderedTypeAttr } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -744,10 +744,17 @@ function formatPt(v: number): string {
// `inheritedStyleName`: nested text:list elements usually carry no style-name of
// their own — the outermost list's style governs, with one level def per depth.
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number): Node | null {
// `govMultilevel`: this list sits inside a multilevel (display-levels) chain, so an
// explicit numbering here must never be suppressed to null (null = rejoin the chain).
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false): Node | null {
const styleName = el.getAttributeNS(NS.text, 'style-name') ?? inheritedStyleName;
const levelDef = listLevelDef(ctx.resolver.listStyle(styleName), depth);
const ordered = levelDef?.localName === 'list-level-style-number';
const displayLevels = parseInt(levelDef?.getAttributeNS(NS.text, 'display-levels') ?? '1', 10);
// A multilevel top shows plain numbers itself — detect it from its level-2 def.
const isMultilevelTop = ordered && depth === 1
&& parseInt(listLevelDef(ctx.resolver.listStyle(styleName), 2)?.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) > 1;
const inChain = ordered && (isMultilevelTop || displayLevels > 1);
const items: Node[] = [];
let start: number | null = null;
@@ -764,7 +771,7 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) {
blocks.push(convertParaLike(child, ctx, 'list'));
} else if (child.namespaceURI === NS.text && child.localName === 'list') {
const nested = convertList(child, ctx, styleName, depth + 1);
const nested = convertList(child, ctx, styleName, depth + 1, inChain);
if (nested) blocks.push(nested);
} else if (child.namespaceURI === NS.table && child.localName === 'table') {
ctx.warnings.add('Nested tables were flattened to paragraphs');
@@ -799,13 +806,23 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
return node;
}
const listStyleType = orderedTypeFromFormat(
levelDef!.getAttributeNS(NS.style, 'num-format'),
levelDef!.getAttributeNS(NS.style, 'num-suffix'),
);
let listStyleType: string | null;
if (isMultilevelTop) {
listStyleType = 'multilevel';
} else if (displayLevels > 1) {
listStyleType = null; // chain member — the multilevel top carries the attr
} else {
const key = orderedTypeFromFormat(
levelDef!.getAttributeNS(NS.style, 'num-format'),
levelDef!.getAttributeNS(NS.style, 'num-suffix'),
);
// Explicit styles inside a chain stay explicit; elsewhere the depth default
// (1. → a. → i.) imports as null so round trips don't accrete attrs.
listStyleType = govMultilevel ? key : orderedTypeAttr(key, depth - 1);
}
const attrs: Record<string, unknown> = {};
if (start != null) attrs.start = start;
if (listStyleType !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = listStyleType;
if (listStyleType) attrs.listStyleType = listStyleType;
if (indent != null) attrs.indent = indent;
const node: Node = { type: 'orderedList', content: items };
if (Object.keys(attrs).length) node.attrs = attrs;
+62 -13
View File
@@ -7,31 +7,44 @@ export type OrderedListType =
| 'lower-alpha' | 'lower-alpha-paren'
| 'upper-alpha' | 'upper-alpha-paren'
| 'lower-roman' | 'lower-roman-paren'
| 'upper-roman' | 'upper-roman-paren';
| 'upper-roman' | 'upper-roman-paren'
| 'multilevel';
export const DEFAULT_ORDERED_TYPE: OrderedListType = 'decimal';
// Numbering when the attr is null, keyed by 0-based nesting depth (Word's default
// multilevel template: 1. → a. → i., repeating).
export const DEFAULT_ORDERED_CYCLE: OrderedListType[] = ['decimal', 'lower-alpha', 'lower-roman'];
export function defaultOrderedType(depth0: number): OrderedListType {
return DEFAULT_ORDERED_CYCLE[depth0 % DEFAULT_ORDERED_CYCLE.length];
}
export interface OrderedTypeDef {
key: OrderedListType;
label: string; // human-readable description for the menu
preview: string; // a single marker shown in the dropdown, e.g. "1." or "a)"
numFormat: '1' | 'a' | 'A' | 'i' | 'I'; // ODF style:num-format
numSuffix: '.' | ')'; // ODF style:num-suffix
// Legal/outline numbering (1., 1.1., 1.2.1. …): each level shows the parent chain.
// ODF text:display-levels, DOCX "%1.%2." lvlText, CSS counters() markers.
multilevel?: boolean;
}
// In menu order. Keep aligned with the @counter-style / ol[data-list-style] rules
// in editor.css.
export const ORDERED_LIST_TYPES: OrderedTypeDef[] = [
{ key: 'decimal', label: '1, 2, 3', preview: '1.', numFormat: '1', numSuffix: '.' },
{ key: 'decimal-paren', label: '1), 2), 3)', preview: '1)', numFormat: '1', numSuffix: ')' },
{ key: 'lower-alpha', label: 'a, b, c', preview: 'a.', numFormat: 'a', numSuffix: '.' },
{ key: 'lower-alpha-paren', label: 'a), b), c)', preview: 'a)', numFormat: 'a', numSuffix: ')' },
{ key: 'upper-alpha', label: 'A, B, C', preview: 'A.', numFormat: 'A', numSuffix: '.' },
{ key: 'upper-alpha-paren', label: 'A), B), C)', preview: 'A)', numFormat: 'A', numSuffix: ')' },
{ key: 'lower-roman', label: 'i, ii, iii', preview: 'i.', numFormat: 'i', numSuffix: '.' },
{ key: 'lower-roman-paren', label: 'i), ii), iii)', preview: 'i)', numFormat: 'i', numSuffix: ')' },
{ key: 'upper-roman', label: 'I, II, III', preview: 'I.', numFormat: 'I', numSuffix: '.' },
{ key: 'upper-roman-paren', label: 'I), II), III)', preview: 'I)', numFormat: 'I', numSuffix: ')' },
{ key: 'decimal', label: '1, 2, 3', preview: '1.', numFormat: '1', numSuffix: '.' },
{ key: 'decimal-paren', label: '1), 2), 3)', preview: '1)', numFormat: '1', numSuffix: ')' },
{ key: 'multilevel', label: '1, 1.1, 1.2.1', preview: '1.1.', numFormat: '1', numSuffix: '.', multilevel: true },
{ key: 'lower-alpha', label: 'a, b, c', preview: 'a.', numFormat: 'a', numSuffix: '.' },
{ key: 'lower-alpha-paren', label: 'a), b), c)', preview: 'a)', numFormat: 'a', numSuffix: ')' },
{ key: 'upper-alpha', label: 'A, B, C', preview: 'A.', numFormat: 'A', numSuffix: '.' },
{ key: 'upper-alpha-paren', label: 'A), B), C)', preview: 'A)', numFormat: 'A', numSuffix: ')' },
{ key: 'lower-roman', label: 'i, ii, iii', preview: 'i.', numFormat: 'i', numSuffix: '.' },
{ key: 'lower-roman-paren', label: 'i), ii), iii)', preview: 'i)', numFormat: 'i', numSuffix: ')' },
{ key: 'upper-roman', label: 'I, II, III', preview: 'I.', numFormat: 'I', numSuffix: '.' },
{ key: 'upper-roman-paren', label: 'I), II), III)', preview: 'I)', numFormat: 'I', numSuffix: ')' },
];
const BY_KEY = new Map<string, OrderedTypeDef>(ORDERED_LIST_TYPES.map(t => [t.key, t]));
@@ -40,9 +53,45 @@ export function orderedTypeDef(key: string | null | undefined): OrderedTypeDef {
return BY_KEY.get(key ?? DEFAULT_ORDERED_TYPE) ?? BY_KEY.get(DEFAULT_ORDERED_TYPE)!;
}
// The def a list with this attr renders at this 0-based depth (null = depth cycle).
export function effectiveOrderedDef(key: string | null | undefined, depth0: number): OrderedTypeDef {
return orderedTypeDef(key ?? defaultOrderedType(depth0));
}
// Attr helper: null when the key equals the depth default, so round trips don't
// accrete explicit attrs. Callers inside a multilevel chain skip this — there the
// chain itself is the default, so an explicit key must stay explicit.
export function orderedTypeAttr(key: OrderedListType, depth0: number): OrderedListType | null {
return key === defaultOrderedType(depth0) ? null : key;
}
function toRoman(n: number): string {
const map: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
let s = '';
for (const [v, sym] of map) while (n >= v) { s += sym; n -= v; }
return s;
}
function toAlpha(n: number): string {
let s = '';
while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); }
return s;
}
// The ordinal body an item renders for a num-format char (no suffix): 3/'a' → "c".
export function formatOrdinal(n: number, numFormat: OrderedTypeDef['numFormat']): string {
switch (numFormat) {
case 'a': return toAlpha(n);
case 'A': return toAlpha(n).toUpperCase();
case 'i': return toRoman(n);
case 'I': return toRoman(n).toUpperCase();
default: return String(n);
}
}
// Reverse lookup for the ODT importer: ODF numbering attrs → listStyleType key.
// Unknown formats (e.g. "1.1.", figure numbering) fall back to decimal.
// Unknown formats (e.g. figure numbering) fall back to decimal.
export function orderedTypeFromFormat(numFormat: string | null, numSuffix: string | null): OrderedListType {
const match = ORDERED_LIST_TYPES.find(t => t.numFormat === numFormat && t.numSuffix === (numSuffix ?? '.'));
const match = ORDERED_LIST_TYPES.find(t => !t.multilevel && t.numFormat === numFormat && t.numSuffix === (numSuffix ?? '.'));
return match?.key ?? DEFAULT_ORDERED_TYPE;
}
+19
View File
@@ -387,6 +387,7 @@
@counter-style oledit-lower-roman-paren { system: extends lower-roman; suffix: ") "; }
@counter-style oledit-upper-roman-paren { system: extends upper-roman; suffix: ") "; }
.paper .tiptap ol[data-list-style="decimal"] { list-style-type: decimal; }
.paper .tiptap ol[data-list-style="decimal-paren"] { list-style-type: oledit-decimal-paren; }
.paper .tiptap ol[data-list-style="lower-alpha"] { list-style-type: lower-alpha; }
.paper .tiptap ol[data-list-style="lower-alpha-paren"] { list-style-type: oledit-lower-alpha-paren; }
@@ -397,6 +398,24 @@
.paper .tiptap ol[data-list-style="upper-roman"] { list-style-type: upper-roman; }
.paper .tiptap ol[data-list-style="upper-roman-paren"] { list-style-type: oledit-upper-roman-paren; }
/* Default numbering per nesting depth when no data-list-style is set — Word's
multilevel template 1. → a. → i., repeating (DEFAULT_ORDERED_CYCLE in
orderedListTypes.ts). Explicit attrs win via the higher-specificity rules above. */
.paper .tiptap ol { list-style-type: decimal; }
.paper .tiptap ol ol { list-style-type: lower-alpha; }
.paper .tiptap ol ol ol { list-style-type: lower-roman; }
.paper .tiptap ol ol ol ol { list-style-type: decimal; }
.paper .tiptap ol ol ol ol ol { list-style-type: lower-alpha; }
.paper .tiptap ol ol ol ol ol ol { list-style-type: lower-roman; }
/* Legal/outline numbering (1., 1.1., 1.2.1. …): the whole chain rendered via CSS
counters. Applies to the multilevel list and its attr-less nested ordered lists;
a nested explicit style breaks out via its own data-list-style rule. */
.paper .tiptap ol[data-list-style="multilevel"] > li::marker,
.paper .tiptap ol[data-list-style="multilevel"] ol:not([data-list-style]) > li::marker {
content: counters(list-item, ".") ". ";
}
/* Bullet markers render through the bundled symbol shim first: it holds only the
glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those come out compact and
platform-independent while every other char falls through to the text font. */
+35 -1
View File
@@ -43,6 +43,11 @@ describe('DOCX export → import round trip', () => {
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [li(para('one')), li(para('two'), { type: 'bulletList', attrs: { bulletChar: '➢' }, content: [li(para('nested'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
{ type: 'orderedList', content: [li(para('cycle top'), { type: 'orderedList', content: [li(para('cycle sub'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
li(para('ml one'), { type: 'orderedList', content: [li(para('ml one-one'))] }),
li(para('ml two')),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
para('box text'),
@@ -118,6 +123,20 @@ describe('DOCX export → import round trip', () => {
expect(ol.attrs.listStyleType).toBe('lower-alpha');
});
it('round-trips depth-default and multilevel numbering', () => {
const ols = walk(doc, 'orderedList');
// Attr-less nesting: exports 1. / a. and re-imports as null (cycle suppression).
const cycleTop = ols.find((o) => walk(o, 'text').some((t) => t.text === 'cycle top'))!;
expect(cycleTop.attrs?.listStyleType ?? null).toBe(null);
const cycleSub = walk(cycleTop, 'orderedList').find((o) => o !== cycleTop)!;
expect(cycleSub.attrs?.listStyleType ?? null).toBe(null);
// Multilevel: "%1.%2." chain lvlText → attr on the top list only.
const mlTop = ols.find((o) => walk(o, 'text').some((t) => t.text === 'ml one'))!;
expect(mlTop.attrs?.listStyleType).toBe('multilevel');
const mlSub = walk(mlTop, 'orderedList').find((o) => o !== mlTop)!;
expect(mlSub.attrs?.listStyleType ?? null).toBe(null);
});
it('round-trips the image (size + floating wrap)', () => {
const img = walk(doc, 'image')[0];
expect(img.attrs.width).toBe(100);
@@ -186,6 +205,8 @@ describe('DOCX import of a foreign Word document', () => {
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>First</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Wingdings diamond</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Courier hollow</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>Legal one</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>Legal one-one</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc><w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc></w:tr>
@@ -203,8 +224,13 @@ describe('DOCX import of a foreign Word document', () => {
<w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="&#xF076;"/><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
</w:abstractNum>
<w:abstractNum w:abstractNumId="2">
<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.%2."/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
<w:num w:numId="3"><w:abstractNumId w:val="2"/></w:num>
</w:numbering>`;
const bytes = zipSync({
@@ -234,8 +260,16 @@ describe('DOCX import of a foreign Word document', () => {
expect(nested.attrs?.bulletChar ?? null).toBe(null); // Courier 'o' = default ◦ at level 2
});
it("imports Word's legal numbering (%1.%2.) as a multilevel list", () => {
const ols = walk(doc, 'orderedList');
const top = ols.find((o) => walk(o, 'text').some((t) => t.text === 'Legal one'))!;
expect(top.attrs?.listStyleType).toBe('multilevel');
const sub = walk(top, 'orderedList').find((o) => o !== top)!;
expect(sub.attrs?.listStyleType ?? null).toBe(null);
});
it('reconstructs a numbered list and a vertically-merged table', () => {
expect(walk(doc, 'orderedList').length).toBe(1);
expect(walk(doc, 'orderedList').filter((o) => walk(o, 'text').some((t) => t.text === 'First')).length).toBe(1);
const rows = walk(doc, 'table')[0].content!;
expect(rows[0].content![0].attrs.rowspan).toBe(2); // vMerge restart → rowspan
expect(rows[1].content!.length).toBe(1); // covered cell dropped
+22
View File
@@ -87,6 +87,28 @@ const fixture: N = {
LI(P({ textAlign: 'center' }, T('centered item'))),
LI(P(null, T('plain item'))),
] },
// Depth-default numbering: attr-less nested levels export as a./i. and re-import
// as null (cycle suppression).
{ type: 'orderedList', content: [
LI(P(null, T('cycle one')), { type: 'orderedList', content: [
LI(P(null, T('cycle sub')), { type: 'orderedList', content: [
LI(P(null, T('cycle subsub'))),
] }),
] }),
] },
// Multilevel (1. / 1.1. / 1.1.1.): attr on the top list only; an explicit style
// inside the chain breaks out (NL mint) and must survive as an explicit attr.
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
LI(P(null, T('ml one')), { type: 'orderedList', content: [
LI(P(null, T('ml one-one')), { type: 'orderedList', content: [
LI(P(null, T('ml deep'))),
] }),
LI(P(null, T('ml one-two')), { type: 'orderedList', attrs: { listStyleType: 'upper-alpha' }, content: [
LI(P(null, T('ml override'))),
] }),
] }),
LI(P(null, T('ml two'))),
] },
{ type: 'bulletList', attrs: { indent: 2.5 }, content: [
LI(P(null, T('shifted bullet a'))),
LI(P(null, T('shifted bullet b'))),
+5
View File
@@ -38,6 +38,9 @@ const fixture = {
li(para('two'), { type: 'bulletList', content: [li(para('nested'))] }),
] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
li(para('legal'), { type: 'orderedList', content: [li(para('legal sub'))] }),
] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('box text')] },
{ type: 'table', content: [
@@ -114,6 +117,8 @@ describe('buildDocx', () => {
// Custom bullet char → level 0 lvlText; nested default level keeps the cycle (◦)
expect(numXml).toMatch(/w:lvlText[^>]*w:val="❖"/);
expect(numXml).toMatch(/w:lvlText[^>]*w:val="◦"/);
// Multilevel list → chained lvlText below level 0
expect(numXml).toMatch(/w:lvlText[^>]*w:val="%1\.%2\."/);
});
it('emits a fixed-layout table with merged cells and shading', () => {
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE } from '../../src/lib/utils/orderedListTypes';
import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE, defaultOrderedType, orderedTypeAttr, effectiveOrderedDef, formatOrdinal } from '../../src/lib/utils/orderedListTypes';
describe('orderedTypeDef', () => {
it('returns the matching definition for a known key', () => {
@@ -39,3 +39,34 @@ describe('orderedTypeFromFormat (ODF → listStyleType)', () => {
expect(orderedTypeFromFormat('Z', '.')).toBe('decimal');
});
});
describe('depth defaults (null attr → 1. → a. → i. cycle)', () => {
it('cycles decimal → lower-alpha → lower-roman and wraps', () => {
expect(defaultOrderedType(0)).toBe('decimal');
expect(defaultOrderedType(1)).toBe('lower-alpha');
expect(defaultOrderedType(2)).toBe('lower-roman');
expect(defaultOrderedType(3)).toBe('decimal');
});
it('orderedTypeAttr suppresses the cycle key at its own depth only', () => {
expect(orderedTypeAttr('decimal', 0)).toBeNull();
expect(orderedTypeAttr('lower-alpha', 1)).toBeNull();
expect(orderedTypeAttr('lower-alpha', 0)).toBe('lower-alpha');
expect(orderedTypeAttr('upper-roman-paren', 1)).toBe('upper-roman-paren');
});
it('effectiveOrderedDef resolves null by depth and keys explicitly', () => {
expect(effectiveOrderedDef(null, 1).numFormat).toBe('a');
expect(effectiveOrderedDef('upper-roman', 1).numFormat).toBe('I');
});
});
describe('formatOrdinal', () => {
it('formats alpha and roman ordinals', () => {
expect(formatOrdinal(3, '1')).toBe('3');
expect(formatOrdinal(3, 'a')).toBe('c');
expect(formatOrdinal(28, 'a')).toBe('ab');
expect(formatOrdinal(4, 'i')).toBe('iv');
expect(formatOrdinal(9, 'I')).toBe('IX');
});
});