feat: improve ordered list
This commit is contained in:
1 parent
3b7e7606bb
commit
d018092cce
14 files changed
+508
-163
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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=""/><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
|
||||
|
||||
@@ -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'))),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user