feat: named list styles — model, editor resolution, ODF round-trip
Fourth style family (LibreOffice's Listenformatvorlagen): per-level model in styles/listStyles.ts, five probed LO built-ins, listStyleName on the outermost list, one effective-level resolver shared by the decoration walk and the export. ODF writes the full named text:list-style into styles.xml and repoints unoverridden lists at it (overrides keep the resolved automatic clone); import maps named list styles back to the attr + registry, attr-free. Probed: LO keeps the named reference and display-name across a re-save.
This commit is contained in:
1 parent
7dd997cbfa
commit
e2525237de
19 files changed
+753
-122
No files matched your search
@@ -19,6 +19,7 @@ import { BulletList } from './extensions/bulletList';
|
||||
import { OrderedList } from './extensions/orderedList';
|
||||
import ListItem from '@tiptap/extension-list-item';
|
||||
import { ListMarker } from './extensions/listMarker';
|
||||
import { ListStyle } from './extensions/listStyle';
|
||||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from './extensions/tableRow';
|
||||
import History from '@tiptap/extension-history';
|
||||
@@ -189,6 +190,9 @@ export const extensions = [
|
||||
// Bullet/number formatting: it follows the item's first text portion, as in
|
||||
// LibreOffice (the sheet resolves a character style on that portion).
|
||||
ListMarker.configure({ sheet: styleSheet }),
|
||||
// Named list style on the outermost list (LibreOffice Listenformatvorlage / Word
|
||||
// numbering style); also owns the effective-marker walk both list types render by.
|
||||
ListStyle.configure({ sheet: styleSheet }),
|
||||
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
|
||||
// resize plugins, and ResizableTableRow supply the drag handling instead. Table
|
||||
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
|
||||
|
||||
@@ -28,6 +28,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
- **`textDirection.ts`** — a block's own base direction (`dir` attr; ODF `style:writing-mode`, Word `w:bidi`), overriding the page's (`storage/writingMode.ts`). The HTML `dir` is the whole rendering — it resolves the bidi run order and `text-align: start` follows it, so an unaligned block flips. Both importers suppress the direction the page already has: it is inheritance, not formatting. Set in `ParagraphDialog.svelte`, where LibreOffice keeps it.
|
||||
- **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set.
|
||||
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
|
||||
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `--bullet`, indent, label alignment) from node attr > style level > depth cycle — see `src/lib/styles/CLAUDE.md`.
|
||||
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
|
||||
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
|
||||
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
name: 'listMarker',
|
||||
|
||||
addOptions() {
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {}, list: {} }) };
|
||||
},
|
||||
|
||||
// Which end of the hanging indent the label is set against (Word's w:lvlJc, ODF's
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { childCycle, defaultOrderedTypeAt, ROOT_ORDERED_CYCLE, type OrderedCycle, type OrderedListType } from '../../utils/orderedListTypes';
|
||||
import { effectiveListLevel } from '../../styles/listStyles';
|
||||
import type { ListStyle as ListStyleDef } from '../../styles/listStyles';
|
||||
import type { StyleSheet } from '../../styles/styleSheet';
|
||||
import { FORCE_PAGE_RECALC } from './pageBreaks';
|
||||
|
||||
const EMPTY_SHEET: StyleSheet = { paragraph: {}, character: {}, table: {}, list: {} };
|
||||
|
||||
// The walk resolving what every list renders: the node's own attrs over its named list
|
||||
// style's level over the depth-cycle default. Ordered lists get `data-eff-list-style`
|
||||
// (editor.css maps it to counters); what a style supplies beyond the attrs is decorated
|
||||
// the same way the attrs would render, so editor.css needs no style-aware rules.
|
||||
export function listStyleDecos(doc: ProseMirrorNode, sheet: StyleSheet): DecorationSet {
|
||||
const decos: Decoration[] = [];
|
||||
const walk = (
|
||||
node: ProseMirrorNode, pos: number,
|
||||
cycle: OrderedCycle, multilevel: boolean, inList: boolean,
|
||||
style: ListStyleDef | null, depth: number,
|
||||
) => {
|
||||
let nextCycle = ROOT_ORDERED_CYCLE;
|
||||
let nextMultilevel = false;
|
||||
let nextInList = false;
|
||||
let nextStyle: ListStyleDef | null = null;
|
||||
let nextDepth = 0;
|
||||
const name = node.type.name;
|
||||
if (name === 'orderedList' || name === 'bulletList') {
|
||||
const listCycle = inList ? cycle : ROOT_ORDERED_CYCLE;
|
||||
const listMultilevel = inList && multilevel;
|
||||
// Only the outermost list's style counts (as in ODF); nested lists inherit it.
|
||||
nextStyle = inList ? style : sheet.list[node.attrs.listStyleName as string] ?? null;
|
||||
nextDepth = depth + 1;
|
||||
const eff = effectiveListLevel(node.attrs, name === 'orderedList', nextStyle, nextDepth);
|
||||
const attrs: Record<string, string> = {};
|
||||
const css: string[] = [];
|
||||
if (name === 'orderedList') {
|
||||
const own = eff.listStyleType;
|
||||
const type = own === 'multilevel' || (listMultilevel && !own) ? 'multilevel' : own ?? defaultOrderedTypeAt(listCycle);
|
||||
attrs['data-eff-list-style'] = type;
|
||||
if (eff.startAt != null && eff.startAt !== 1 && (node.attrs.start ?? 1) === 1) attrs.start = String(eff.startAt);
|
||||
nextMultilevel = type === 'multilevel';
|
||||
} else if (!node.attrs.bulletChar && eff.bulletChar) {
|
||||
css.push(`--bullet: "${eff.bulletChar}"`);
|
||||
}
|
||||
if (!node.attrs.markerAlign && eff.markerAlign) attrs['data-marker-align'] = eff.markerAlign;
|
||||
if (node.attrs.indent == null && eff.indent) css.push(`margin-left: calc(var(--sec-inset-left, 0px) + ${eff.indent}cm)`);
|
||||
if (css.length) attrs.style = css.join(';');
|
||||
if (Object.keys(attrs).length) decos.push(Decoration.node(pos, pos + node.nodeSize, attrs));
|
||||
nextCycle = childCycle(listCycle, eff.listStyleType as OrderedListType | null, name === 'orderedList');
|
||||
nextInList = true;
|
||||
} else if (name === 'listItem') {
|
||||
nextCycle = cycle;
|
||||
nextMultilevel = multilevel;
|
||||
nextInList = inList;
|
||||
nextStyle = style;
|
||||
nextDepth = depth;
|
||||
}
|
||||
let p = pos + 1;
|
||||
node.forEach((child) => {
|
||||
walk(child, p, nextCycle, nextMultilevel, nextInList, nextStyle, nextDepth);
|
||||
p += child.nodeSize;
|
||||
});
|
||||
};
|
||||
walk(doc, -1, ROOT_ORDERED_CYCLE, false, false, null, 0);
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
const listStyleKey = new PluginKey<DecorationSet>('listStyleEff');
|
||||
|
||||
// The named list style, the fourth style family: `listStyleName` on the outermost list
|
||||
// (LibreOffice's Listenformatvorlagen / Word's numbering styles).
|
||||
export const ListStyle = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
name: 'listStyle',
|
||||
|
||||
addOptions() {
|
||||
return { sheet: () => EMPTY_SHEET };
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['bulletList', 'orderedList'],
|
||||
attributes: {
|
||||
listStyleName: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('data-list-style-name') || null,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.listStyleName ? { 'data-list-style-name': attrs.listStyleName as string } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const sheet = () => this.options.sheet();
|
||||
return [
|
||||
new Plugin({
|
||||
key: listStyleKey,
|
||||
state: {
|
||||
init: (_, state) => listStyleDecos(state.doc, sheet()),
|
||||
// An edited registry changes what a list style means; that arrives as
|
||||
// FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect), as in listMarker.ts.
|
||||
apply: (tr, old) =>
|
||||
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listStyleDecos(tr.doc, sheet()) : old,
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return listStyleKey.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import OrderedListBase from '@tiptap/extension-ordered-list';
|
||||
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import type { ResolvedPos } from '@tiptap/pm/model';
|
||||
import { childCycle, defaultOrderedTypeAt, orderedTypeAttrAt, ROOT_ORDERED_CYCLE, type OrderedCycle, type OrderedListType } from '../../utils/orderedListTypes';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
@@ -117,59 +116,4 @@ export const OrderedList = OrderedListBase.extend({
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() ?? []),
|
||||
new Plugin({
|
||||
key: orderedListStyleKey,
|
||||
state: {
|
||||
init: (_, state) => orderedListStyleDecos(state.doc),
|
||||
apply: (tr, old) => (tr.docChanged ? orderedListStyleDecos(tr.doc) : old),
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return orderedListStyleKey.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const orderedListStyleKey = new PluginKey<DecorationSet>('orderedListEffStyle');
|
||||
|
||||
// An attr-less <ol>'s marker depends on its list-ancestor chain. This walk resolves
|
||||
// each list's effective numbering (re-anchoring at explicit styles, propagating
|
||||
// multilevel) and tags the <ol> with `data-eff-list-style`, which editor.css maps.
|
||||
export function orderedListStyleDecos(doc: ProseMirrorNode): DecorationSet {
|
||||
const decos: Decoration[] = [];
|
||||
const walk = (node: ProseMirrorNode, pos: number, cycle: OrderedCycle, multilevel: boolean, inList: boolean) => {
|
||||
let nextCycle = ROOT_ORDERED_CYCLE;
|
||||
let nextMultilevel = false;
|
||||
let nextInList = false;
|
||||
const name = node.type.name;
|
||||
if (name === 'orderedList' || name === 'bulletList') {
|
||||
const listCycle = inList ? cycle : ROOT_ORDERED_CYCLE;
|
||||
const listMultilevel = inList && multilevel;
|
||||
if (name === 'orderedList') {
|
||||
const own = node.attrs.listStyleType as string | null;
|
||||
const eff = own === 'multilevel' || (listMultilevel && !own) ? 'multilevel' : own ?? defaultOrderedTypeAt(listCycle);
|
||||
decos.push(Decoration.node(pos, pos + node.nodeSize, { 'data-eff-list-style': eff }));
|
||||
nextMultilevel = eff === 'multilevel';
|
||||
}
|
||||
nextCycle = childCycle(listCycle, node.attrs.listStyleType as string | null, name === 'orderedList');
|
||||
nextInList = true;
|
||||
} else if (name === 'listItem') {
|
||||
nextCycle = cycle;
|
||||
nextMultilevel = multilevel;
|
||||
nextInList = inList;
|
||||
}
|
||||
let p = pos + 1;
|
||||
node.forEach((child) => {
|
||||
walk(child, p, nextCycle, nextMultilevel, nextInList);
|
||||
p += child.nodeSize;
|
||||
});
|
||||
};
|
||||
walk(doc, -1, ROOT_ORDERED_CYCLE, false, false);
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => S
|
||||
name: 'paragraphStyle',
|
||||
|
||||
addOptions() {
|
||||
return { types: ['paragraph', 'heading'], sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
return { types: ['paragraph', 'heading'], sheet: () => ({ paragraph: {}, character: {}, table: {}, list: {} }) };
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
|
||||
@@ -282,7 +282,7 @@ export const SearchReplace = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
name: 'searchReplace',
|
||||
|
||||
addOptions() {
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {}, list: {} }) };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
|
||||
@@ -46,6 +46,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
|
||||
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
|
||||
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
|
||||
- **`applyListStyleNames`** — a list carrying an unoverridden `listStyleName` is repointed from its `L#` automatic style to the named `<text:list-style>` `applyNamedStyles` writes into styles.xml (its nested lists stay bare and inherit); runs after every `L#`-keyed pass. The list collectors resolve attrs through `effectiveListLevel`, so an overridden styled list keeps a fully resolved `L#` clone — see `src/lib/styles/CLAUDE.md`.
|
||||
- **`applyListStartValues`** — odf-kit drops an ordered list's `start` attr; a Word list continued across an intervening paragraph (same numId, split into separate `orderedList` nodes with `start` > 1 by the importer) gets `text:start-value` on its first `<text:list-item>` so numbering keeps counting instead of restarting at 1.
|
||||
- **`applyTableRowHeights`** — dragged row heights → `style:min-row-height` automatic table-row styles.
|
||||
- **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
|
||||
|
||||
+131
-35
@@ -16,6 +16,7 @@ import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type T
|
||||
import {
|
||||
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
|
||||
} from '../styles/tableStyles';
|
||||
import { effectiveListLevel, listStyleMarginCm, listStyleOverridden, MAX_LIST_LEVELS, type ListStyle } from '../styles/listStyles';
|
||||
|
||||
// A table's named style plus the conditional areas it opts into (Word's Table Style
|
||||
// Options), collected by exportTable in document order.
|
||||
@@ -1894,7 +1895,7 @@ function stripManagedProps(block: string): string {
|
||||
// Write the document's named paragraph styles into styles.xml: merge into the blocks
|
||||
// odf-kit already emits (Standard, Heading, Heading_20_N), append the rest. The parent
|
||||
// chain is preserved, so LibreOffice/Word show them as real, inheriting styles.
|
||||
function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set()): string {
|
||||
function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), usedLists: Set<string> = new Set()): string {
|
||||
const added: string[] = [];
|
||||
// Table styles: ODF's table family carries no banding, so the name is all that travels
|
||||
// (the look is baked into the cells). An empty style block is enough to make it real.
|
||||
@@ -1905,6 +1906,13 @@ function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>,
|
||||
'style:name': odfName, 'style:family': 'table', 'style:display-name': name,
|
||||
}));
|
||||
}
|
||||
// List styles are their own element, not a style:style family; the referenced ones
|
||||
// get their full 10-level definition (there is no parent chain to lean on).
|
||||
for (const name of usedLists) {
|
||||
const style = sheet.list?.[name];
|
||||
if (!style || styles.includes(`<text:list-style style:name="${escapeXml(odfStyleName(name))}"`)) continue;
|
||||
added.push(buildNamedListStyleXml(style));
|
||||
}
|
||||
// Character styles are the same shape, family "text" and text props only.
|
||||
for (const style of Object.values(sheet.character ?? {})) {
|
||||
const { text } = ownStyleAttrs(style);
|
||||
@@ -2071,24 +2079,35 @@ const MULTILEVEL_FMTS: OrderedFmt[] = Array.from({ length: 6 }, () => ({ numForm
|
||||
// Effective numbering of an ordered list at a cycle position: its attr, else the
|
||||
// cycle default (re-anchoring slot + suffix at explicit styles; 'multilevel' handled
|
||||
// by the callers).
|
||||
function effOrderedFmt(list: TiptapNode, cycle: OrderedCycle): OrderedFmt {
|
||||
const key = list.attrs?.listStyleType as string | null | undefined;
|
||||
// The named list style governing a top-level list (null = none, or an unknown name).
|
||||
function listStyleOf(list: TiptapNode): ListStyle | null {
|
||||
const name = list.attrs?.listStyleName;
|
||||
return typeof name === 'string' && name ? exportSheet.list?.[name] ?? null : null;
|
||||
}
|
||||
|
||||
// The list's numbering key after its style: node attr ?? style level ?? null.
|
||||
function effOrderedKey(list: TiptapNode, style: ListStyle | null, depth: number): string | null {
|
||||
return effectiveListLevel(list.attrs ?? {}, list.type === 'orderedList', style, depth).listStyleType;
|
||||
}
|
||||
|
||||
function effOrderedFmt(list: TiptapNode, cycle: OrderedCycle, style: ListStyle | null = null, depth = 0): OrderedFmt {
|
||||
const key = effOrderedKey(list, style, depth);
|
||||
const def = effectiveOrderedDefAt(key === 'multilevel' ? 'decimal' : key, cycle);
|
||||
return { numFormat: def.numFormat, numSuffix: def.numSuffix };
|
||||
}
|
||||
|
||||
// Per-level numbering for a list subtree — same DFS-first model as bulletCharVector,
|
||||
// threading each list's cycle position down so nested defaults advance past their parent.
|
||||
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[], startCycle: OrderedCycle): OrderedFmt[] {
|
||||
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[], startCycle: OrderedCycle, style: ListStyle | null = null): OrderedFmt[] {
|
||||
const vec = [...base];
|
||||
const seen = new Set<number>();
|
||||
const walk = (node: TiptapNode, depth: number, cycle: OrderedCycle) => {
|
||||
if (depth > vec.length) return;
|
||||
if (node.type === 'orderedList' && !seen.has(depth)) {
|
||||
seen.add(depth);
|
||||
vec[depth - 1] = effOrderedFmt(node, cycle);
|
||||
vec[depth - 1] = effOrderedFmt(node, cycle, style, depth);
|
||||
}
|
||||
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
|
||||
const cChild = childCycle(cycle, effOrderedKey(node, style, depth), node.type === 'orderedList');
|
||||
for (const item of node.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
for (const child of item.content ?? []) {
|
||||
@@ -2105,10 +2124,11 @@ function collectOrderedListFormats(node: TiptapNode, result: (OlStyleFix | null)
|
||||
if (child.type === 'bulletList') {
|
||||
result.push(null);
|
||||
} else if (child.type === 'orderedList') {
|
||||
if (child.attrs?.listStyleType === 'multilevel') {
|
||||
const style = listStyleOf(child);
|
||||
if (effOrderedKey(child, style, 1) === 'multilevel') {
|
||||
result.push({ fmts: MULTILEVEL_FMTS, multilevel: true });
|
||||
} else {
|
||||
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS, ROOT_ORDERED_CYCLE);
|
||||
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS, ROOT_ORDERED_CYCLE, style);
|
||||
// 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 });
|
||||
}
|
||||
@@ -2181,22 +2201,22 @@ function applyListStartValues(odtBytes: Uint8Array, starts: (number | null)[]):
|
||||
|
||||
// Effective marker char of a bullet list at a 1-based depth: its bulletChar attr,
|
||||
// else the default cycle.
|
||||
function effBulletChar(list: TiptapNode, depth: number): string {
|
||||
const ch = list.attrs?.bulletChar;
|
||||
return typeof ch === 'string' && ch ? ch : defaultBulletChar(depth - 1);
|
||||
function effBulletChar(list: TiptapNode, depth: number, style: ListStyle | null = null): string {
|
||||
const ch = effectiveListLevel(list.attrs ?? {}, false, style, depth).bulletChar;
|
||||
return ch ?? defaultBulletChar(depth - 1);
|
||||
}
|
||||
|
||||
// Per-level marker chars for a list subtree: slot d-1 = the DFS-first bullet list
|
||||
// at depth d (one list style governs the whole tree, so the first list met at a depth
|
||||
// defines that level; differing siblings get NL mints; unvisited depths keep `base`).
|
||||
function bulletCharVector(list: TiptapNode, startDepth: number, base: string[]): string[] {
|
||||
function bulletCharVector(list: TiptapNode, startDepth: number, base: string[], style: ListStyle | null = null): string[] {
|
||||
const vec = [...base];
|
||||
const seen = new Set<number>();
|
||||
const walk = (node: TiptapNode, depth: number) => {
|
||||
if (depth > vec.length) return;
|
||||
if (node.type === 'bulletList' && !seen.has(depth)) {
|
||||
seen.add(depth);
|
||||
vec[depth - 1] = effBulletChar(node, depth);
|
||||
vec[depth - 1] = effBulletChar(node, depth, style);
|
||||
}
|
||||
for (const item of node.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
@@ -2217,7 +2237,7 @@ function collectBulletListChars(node: TiptapNode, result: (string[] | null)[]):
|
||||
if (child.type === 'orderedList') {
|
||||
result.push(null);
|
||||
} else if (child.type === 'bulletList') {
|
||||
const vec = bulletCharVector(child, 1, DEFAULT_BULLET_CYCLE);
|
||||
const vec = bulletCharVector(child, 1, DEFAULT_BULLET_CYCLE, listStyleOf(child));
|
||||
result.push(vec.every((c, i) => c === DEFAULT_BULLET_CYCLE[i]) ? null : vec);
|
||||
}
|
||||
}
|
||||
@@ -2257,11 +2277,12 @@ type ListLevelProps = { indent: number; right: boolean };
|
||||
function collectListLevelProps(node: TiptapNode, result: ListLevelProps[][]): void {
|
||||
for (const child of node.content ?? []) {
|
||||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||||
const style = listStyleOf(child);
|
||||
const levels: ListLevelProps[] = [];
|
||||
const visit = (list: TiptapNode, depth: number) => {
|
||||
const ind = list.attrs?.indent;
|
||||
if (levels[depth - 1] === undefined) {
|
||||
levels[depth - 1] = { indent: typeof ind === 'number' ? ind : 0, right: list.attrs?.markerAlign === 'right' };
|
||||
const eff = effectiveListLevel(list.attrs ?? {}, list.type === 'orderedList', style, depth);
|
||||
levels[depth - 1] = { indent: eff.indent, right: eff.markerAlign === 'right' };
|
||||
}
|
||||
for (const item of list.content ?? []) {
|
||||
for (const block of item.content ?? []) {
|
||||
@@ -2388,20 +2409,22 @@ function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | n
|
||||
// 6-level list style (same pattern applyCellBlocks uses for cell lists).
|
||||
type ListDef = { ordered: boolean; multilevel: boolean; fmts: OrderedFmt[]; bulletChars: string[] };
|
||||
|
||||
function listDefOf(node: TiptapNode, depth: number, cycle: OrderedCycle, baseChars: string[], baseFmts: OrderedFmt[]): ListDef {
|
||||
const multilevel = node.attrs?.listStyleType === 'multilevel';
|
||||
function listDefOf(node: TiptapNode, depth: number, cycle: OrderedCycle, baseChars: string[], baseFmts: OrderedFmt[], style: ListStyle | null = null): ListDef {
|
||||
const multilevel = effOrderedKey(node, style, depth) === 'multilevel';
|
||||
return {
|
||||
ordered: node.type === 'orderedList',
|
||||
multilevel,
|
||||
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts, cycle),
|
||||
bulletChars: bulletCharVector(node, depth, baseChars),
|
||||
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts, cycle, style),
|
||||
bulletChars: bulletCharVector(node, depth, baseChars, style),
|
||||
};
|
||||
}
|
||||
|
||||
// One entry per nested <text:list> in DFS order (null = inherits its governing
|
||||
// style correctly). Only walks top-level lists — cell lists never emit bare tags.
|
||||
function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): void {
|
||||
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number, cycle: OrderedCycle) => {
|
||||
// `repoints` marks lists later pointed at their named list style: their nested tags
|
||||
// must stay bare, so the whole 10-level named definition governs every depth.
|
||||
function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[], repoints: (string | null)[] = []): void {
|
||||
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number, cycle: OrderedCycle, style: ListStyle | null) => {
|
||||
let gov = governing;
|
||||
if (!isTop) {
|
||||
const ordered = list.type === 'orderedList';
|
||||
@@ -2409,31 +2432,41 @@ function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): vo
|
||||
if (ordered !== governing.ordered) {
|
||||
differs = true;
|
||||
} else if (!ordered) {
|
||||
differs = effBulletChar(list, depth) !== governing.bulletChars[(depth - 1) % governing.bulletChars.length];
|
||||
differs = effBulletChar(list, depth, style) !== 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;
|
||||
const attr = effOrderedKey(list, style, depth);
|
||||
differs = !!attr && attr !== 'multilevel';
|
||||
} else {
|
||||
const f = effOrderedFmt(list, cycle);
|
||||
const f = effOrderedFmt(list, cycle, style, 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, cycle, governing.bulletChars, governing.fmts) : null;
|
||||
const def = differs ? listDefOf(list, depth, cycle, governing.bulletChars, governing.fmts, style) : null;
|
||||
result.push(def);
|
||||
if (def) gov = def; // restyled list governs its own descendants
|
||||
}
|
||||
const cChild = childCycle(cycle, list.attrs?.listStyleType as string | null | undefined, list.type === 'orderedList');
|
||||
const cChild = childCycle(cycle, effOrderedKey(list, style, depth), list.type === 'orderedList');
|
||||
for (const item of list.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
for (const child of item.content ?? []) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1, cChild);
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1, cChild, style);
|
||||
}
|
||||
}
|
||||
};
|
||||
const skipNested = (list: TiptapNode) => {
|
||||
for (const item of list.content ?? []) {
|
||||
for (const child of item.content ?? []) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') { result.push(null); skipNested(child); }
|
||||
}
|
||||
}
|
||||
};
|
||||
let listIdx = 0;
|
||||
for (const child of doc.content ?? []) {
|
||||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||||
walkList(child, listDefOf(child, 1, ROOT_ORDERED_CYCLE, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS), true, 1, ROOT_ORDERED_CYCLE);
|
||||
if (repoints[listIdx++]) { skipNested(child); continue; }
|
||||
const style = listStyleOf(child);
|
||||
walkList(child, listDefOf(child, 1, ROOT_ORDERED_CYCLE, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS, style), true, 1, ROOT_ORDERED_CYCLE, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2475,6 +2508,59 @@ function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]):
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// One candidate per top-level list: its named list style when nothing in the subtree
|
||||
// overrides it — ODF list styles have no parent chain, so an overridden list keeps
|
||||
// its fully resolved automatic clone (which the passes above produce) and no name.
|
||||
function collectListStyleCandidates(doc: TiptapNode, result: (string | null)[]): void {
|
||||
for (const child of doc.content ?? []) {
|
||||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||||
const style = listStyleOf(child);
|
||||
result.push(style && !listStyleOverridden(child) ? style.name : null);
|
||||
}
|
||||
}
|
||||
|
||||
// Point each candidate's <text:list> at the named style applyNamedStyles writes into
|
||||
// styles.xml. Runs after every pass keyed on the L# block names.
|
||||
function applyListStyleNames(odtBytes: Uint8Array, names: (string | null)[]): Uint8Array {
|
||||
if (names.every(n => n === null)) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
names.forEach((name, i) => {
|
||||
if (!name) return;
|
||||
content = content.replace(`<text:list text:style-name="L${i + 1}">`, `<text:list text:style-name="${escapeXml(odfStyleName(name))}">`);
|
||||
});
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// The full 10-level definition of a named list style for styles.xml. Undefined levels
|
||||
// fall back to decimal, as LibreOffice fills them in (probed); the hanging indent is
|
||||
// the flat 0.635cm every list export uses.
|
||||
function buildNamedListStyleXml(style: ListStyle): string {
|
||||
let levels = '';
|
||||
for (let level = 1; level <= MAX_LIST_LEVELS; level++) {
|
||||
const def = style.levels[level - 1];
|
||||
const margin = `${listStyleMarginCm(style, level).toFixed(3)}cm`;
|
||||
const right = def?.markerAlign === 'right' ? ' fo:text-align="end"' : '';
|
||||
const labelAlign = `<style:list-level-properties text:list-level-position-and-space-mode="label-alignment"${right}><style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="${margin}" fo:text-indent="-0.635cm" fo:margin-left="${margin}"/></style:list-level-properties>`;
|
||||
if (def?.kind === 'bullet') {
|
||||
const ch = escapeXml(def.bulletChar ?? defaultBulletChar(level - 1));
|
||||
levels += `<text:list-level-style-bullet text:level="${level}" text:bullet-char="${ch}">${labelAlign}</text:list-level-style-bullet>`;
|
||||
} else {
|
||||
const t = orderedTypeDef(style.multilevel ? 'decimal' : def?.numType ?? 'decimal');
|
||||
const disp = style.multilevel && level > 1 ? ` text:display-levels="${level}"` : '';
|
||||
const start = def?.startAt && def.startAt !== 1 ? ` text:start-value="${def.startAt}"` : '';
|
||||
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${t.numFormat}" style:num-suffix="${t.numSuffix}"${disp}${start}>${labelAlign}</text:list-level-style-number>`;
|
||||
}
|
||||
}
|
||||
return `<text:list-style style:name="${escapeXml(odfStyleName(style.name))}" style:display-name="${escapeXml(style.name)}">${levels}</text:list-style>`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -2833,7 +2919,7 @@ function lengthOfPageLayout(styles: string): number {
|
||||
return width ? width - num('fo:margin-left') - num('fo:margin-right') : 17;
|
||||
}
|
||||
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Uint8Array {
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), usedLists: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
@@ -2952,7 +3038,7 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
|
||||
|
||||
// The document's named paragraph styles (Standard, Heading, Heading_20_N, Title, …)
|
||||
// with their parent chain: merged into odf-kit's own blocks, appended when new.
|
||||
styles = applyNamedStyles(styles, sheet, used, usedTables);
|
||||
styles = applyNamedStyles(styles, sheet, used, usedTables, usedLists);
|
||||
|
||||
files['styles.xml'] = strToU8(styles);
|
||||
return rezipOdt(files);
|
||||
@@ -4742,8 +4828,16 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
collectBulletListChars(raw, blChars);
|
||||
numberedOdt = applyBulletListChars(numberedOdt, blChars);
|
||||
|
||||
// Which lists point at their named list style: unoverridden, and without a marker
|
||||
// format (that rides the L# levels, so such a list keeps the automatic clone).
|
||||
const markerFormats: (MarkerFormat | null)[][] = [];
|
||||
collectListMarkerFormats(raw, markerFormats);
|
||||
const styleCandidates: (string | null)[] = [];
|
||||
collectListStyleCandidates(raw, styleCandidates);
|
||||
const listStyleRepoints = styleCandidates.map((n, i) => (n && !markerFormats[i]?.some(Boolean) ? n : null));
|
||||
|
||||
const nestedFixes: (ListDef | null)[] = [];
|
||||
collectNestedListFixes(raw, nestedFixes);
|
||||
collectNestedListFixes(raw, nestedFixes, listStyleRepoints);
|
||||
numberedOdt = applyNestedListTypes(numberedOdt, nestedFixes);
|
||||
|
||||
const listStyles: ParaStyle[] = [];
|
||||
@@ -4756,10 +4850,12 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
let indentedLists = applyListLevelProps(styledLists, listLevels);
|
||||
|
||||
// Marker formatting → a minted character style on each L# level definition.
|
||||
const markerFormats: (MarkerFormat | null)[][] = [];
|
||||
collectListMarkerFormats(raw, markerFormats);
|
||||
indentedLists = applyListMarkerFormats(indentedLists, markerFormats);
|
||||
|
||||
// Point the unoverridden styled lists at the named list styles rewriteStylesXml
|
||||
// writes; runs after every pass keyed on the L# names.
|
||||
indentedLists = applyListStyleNames(indentedLists, listStyleRepoints);
|
||||
|
||||
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
|
||||
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
|
||||
const styledCells = applyCellFormulas(applyCellBlocks(indentedLists, cellBlocks), cellFormulas, langTag);
|
||||
@@ -4791,7 +4887,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// Effects first: applyCharacterStyles then clones the style that already carries them.
|
||||
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
|
||||
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering);
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, new Set(listStyleRepoints.filter((n): n is string => !!n)), tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering);
|
||||
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||||
const withWatermark = applyFoldMarksOdf(applyWatermarkOdf(withHf, decor.watermark), foldMarks);
|
||||
// Sections past the first get their own master page, which is where ODF keeps a
|
||||
|
||||
@@ -22,7 +22,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **`style:contextual-spacing`** drops a paragraph's own spacing towards a neighbour of the same style (LibreOffice's `List` styles carry it; Word's `w:contextualSpacing` is the same rule). The editor has no such mode, so the suppressed side becomes an explicit 0 — and in a list, where each item wraps its own paragraph, the neighbour is the adjacent *item's*.
|
||||
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
|
||||
- **A heading is bold by default only where the bold built-in still renders it.** `blockDefaults` suppresses a run mark the block's style supplies, and the editor's `Heading` parent supplies bold — but a file that declares its own heading style re-parents it to `Standard` (`collectStyleSheet`), so nothing supplies bold there and an explicit `w:b` / `fo:font-weight` has to stay a mark or the heading renders regular. Gated on the style's own `w:basedOn` / `style:parent-style-name` (`styleBasedOn`, `styleParent`), which is exactly what decides whether the built-in's parent survives.
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
|
||||
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
|
||||
- **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `<chart:chart>` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`.
|
||||
|
||||
+64
-9
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import { LIST_LEVEL_STEP_CM, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { fitInlineImage, framePx } from '../editor/extensions/image';
|
||||
import { odfChartDataUrl } from './chart';
|
||||
@@ -133,6 +134,8 @@ type Ctx = {
|
||||
usedStyles: Set<string>;
|
||||
charStyleNames: Map<string, string>;
|
||||
usedCharStyles: Set<string>;
|
||||
// Named list styles (Listenformatvorlagen) lists reference, by ODF name.
|
||||
usedListStyles: Set<string>;
|
||||
warnings: Set<string>;
|
||||
files: Record<string, Uint8Array>;
|
||||
imageCache: Map<string, string>;
|
||||
@@ -749,7 +752,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
const contentWidthCm = geo
|
||||
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
|
||||
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
|
||||
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
|
||||
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
|
||||
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
|
||||
@@ -1529,9 +1532,49 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet {
|
||||
para: {}, text: textPropsFromOdf(resolver.spanTextProps(odfName), resolver),
|
||||
};
|
||||
}
|
||||
for (const odfName of ctx.usedListStyles) {
|
||||
const el = resolver.listStyle(odfName);
|
||||
if (!el) continue;
|
||||
const name = displayStyleName(odfName, resolver.listStyleDisplayName(odfName));
|
||||
sheet.list[name] = listStyleFromOdf(name, el, sheet.list[name]?.builtin);
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
// A named <text:list-style> element → the registry's shape. Each level's indentCm is
|
||||
// its margin step past the level above minus the 1.27cm base (the export's inverse).
|
||||
function listStyleFromOdf(name: string, el: Element, builtin?: boolean): ListStyle {
|
||||
const levels: ListLevelStyle[] = [];
|
||||
let multilevel = false;
|
||||
let prevMargin = 0;
|
||||
for (let d = 1; d <= MAX_LIST_LEVELS; d++) {
|
||||
const def = listLevelDef(el, d);
|
||||
if (!def) break;
|
||||
const ordered = def.localName === 'list-level-style-number';
|
||||
const level: ListLevelStyle = { kind: ordered ? 'number' : 'bullet' };
|
||||
const margin = listLevelMarginLeftCm(def) ?? prevMargin + LIST_LEVEL_STEP_CM;
|
||||
const extra = Math.round((margin - prevMargin - LIST_LEVEL_STEP_CM) * 100) / 100;
|
||||
if (extra) level.indentCm = extra;
|
||||
prevMargin = margin;
|
||||
if (listLevelRightAligned(def)) level.markerAlign = 'right';
|
||||
if (ordered) {
|
||||
if (parseInt(def.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) > 1) multilevel = true;
|
||||
const key = orderedTypeFromFormat(def.getAttributeNS(NS.style, 'num-format'), def.getAttributeNS(NS.style, 'num-suffix'));
|
||||
level.numType = key === 'multilevel' ? 'decimal' : key;
|
||||
const sv = parseInt(def.getAttributeNS(NS.text, 'start-value') ?? '', 10);
|
||||
if (Number.isFinite(sv) && sv > 1) level.startAt = sv;
|
||||
} else {
|
||||
const ch = bulletCharFromOdf(def.getAttributeNS(NS.text, 'bullet-char'), listLevelFontName(def));
|
||||
if (ch) level.bulletChar = ch;
|
||||
}
|
||||
levels.push(level);
|
||||
}
|
||||
const style: ListStyle = { name, levels };
|
||||
if (builtin) style.builtin = true;
|
||||
if (multilevel) style.multilevel = true;
|
||||
return style;
|
||||
}
|
||||
|
||||
// The block's yardstick plus what the run's character style provides.
|
||||
function charDefaults(ctx: Ctx, base: BlockDefaults, odfName: string): BlockDefaults {
|
||||
const props = ctx.resolver.spanTextProps(odfName);
|
||||
@@ -2453,6 +2496,10 @@ function outlineHeadingEls(listEl: Element): Element[] | null {
|
||||
// display-levels chain, so an explicit numbering here is never suppressed (null = rejoin).
|
||||
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false, baseCycle: OrderedCycle = ROOT_ORDERED_CYCLE): Node | null {
|
||||
const styleName = el.getAttributeNS(NS.text, 'style-name') ?? inheritedStyleName;
|
||||
// A named list style travels as `listStyleName` on the outermost list; everything the
|
||||
// style's levels say stays in the registry, so no per-level attrs are derived.
|
||||
const named = ctx.resolver.isNamedListStyle(styleName);
|
||||
if (named && depth === 1) ctx.usedListStyles.add(styleName!);
|
||||
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);
|
||||
@@ -2507,12 +2554,16 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
|
||||
}
|
||||
|
||||
if (!ordered) {
|
||||
const raw = levelDef?.getAttributeNS(NS.text, 'bullet-char') ?? null;
|
||||
const bulletChar = bulletCharAttr(bulletCharFromOdf(raw, listLevelFontName(levelDef)), depth - 1);
|
||||
const attrs: Record<string, unknown> = {};
|
||||
if (bulletChar) attrs.bulletChar = bulletChar;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
if (named) {
|
||||
if (depth === 1) attrs.listStyleName = displayStyleName(styleName!, ctx.resolver.listStyleDisplayName(styleName!));
|
||||
} else {
|
||||
const raw = levelDef?.getAttributeNS(NS.text, 'bullet-char') ?? null;
|
||||
const bulletChar = bulletCharAttr(bulletCharFromOdf(raw, listLevelFontName(levelDef)), depth - 1);
|
||||
if (bulletChar) attrs.bulletChar = bulletChar;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
}
|
||||
const node: Node = { type: 'bulletList', content: items };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
@@ -2531,9 +2582,13 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
|
||||
}
|
||||
const attrs: Record<string, unknown> = {};
|
||||
if (start != null) attrs.start = start;
|
||||
if (listStyleType) attrs.listStyleType = listStyleType;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
if (named) {
|
||||
if (depth === 1) attrs.listStyleName = displayStyleName(styleName!, ctx.resolver.listStyleDisplayName(styleName!));
|
||||
} else {
|
||||
if (listStyleType) attrs.listStyleType = listStyleType;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
}
|
||||
const node: Node = { type: 'orderedList', content: items };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
|
||||
@@ -175,6 +175,7 @@ export class StyleResolver {
|
||||
private namedParagraphNames = new Set<string>();
|
||||
private namedTextNames = new Set<string>();
|
||||
private namedTableNames = new Set<string>();
|
||||
private namedListNames = new Set<string>();
|
||||
private displayNames = new Map<string, string>();
|
||||
|
||||
constructor(contentDoc: Document, stylesDoc: Document | null) {
|
||||
@@ -282,6 +283,13 @@ export class StyleResolver {
|
||||
} else if (el.namespaceURI === NS.text && el.localName === 'list-style') {
|
||||
const name = el.getAttributeNS(NS.style, 'name');
|
||||
if (name) this.listStyles.set(name, el);
|
||||
// A list style in <office:styles> is a named one (an automatic style is the
|
||||
// direct formatting of one list); its display name decodes like the others'.
|
||||
if (name && named) {
|
||||
this.namedListNames.add(name);
|
||||
const display = el.getAttributeNS(NS.style, 'display-name');
|
||||
if (display) this.displayNames.set(name, display);
|
||||
}
|
||||
} else if (el.namespaceURI === NS.number
|
||||
&& ['date-style', 'time-style', 'number-style', 'percentage-style', 'currency-style'].includes(el.localName)) {
|
||||
const name = el.getAttributeNS(NS.style, 'name');
|
||||
@@ -467,6 +475,18 @@ export class StyleResolver {
|
||||
return name ? this.listStyles.get(name) ?? null : null;
|
||||
}
|
||||
|
||||
// Whether a list style came from <office:styles> — a named Listenformatvorlage
|
||||
// rather than one list's direct formatting.
|
||||
isNamedListStyle(name: string | null): boolean {
|
||||
return !!name && this.namedListNames.has(name);
|
||||
}
|
||||
|
||||
// Its display name ("List_20_Number" → the file's own "List Number"), decoded like
|
||||
// the paragraph families' — the caller decodes _20_ itself when the file names none.
|
||||
listStyleDisplayName(name: string): string | undefined {
|
||||
return this.displayNames.get(name);
|
||||
}
|
||||
|
||||
// The <number:date-style>/<number:time-style> element a date/time field references.
|
||||
numberStyle(name: string | null): Element | null {
|
||||
return name ? this.numberStyles.get(name) ?? null : null;
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
# `src/lib/styles/` — named styles
|
||||
|
||||
Three families: paragraph and character styles here, **table styles in
|
||||
Four families: paragraph and character styles here, **table styles in
|
||||
`docs/architecture/tables.md`** (model in `tableStyles.ts`, editor half in
|
||||
`editor/extensions/tableStyle.ts`).
|
||||
`editor/extensions/tableStyle.ts`), **list styles below**.
|
||||
|
||||
## List styles (`listStyles.ts`, `editor/extensions/listStyle.ts`)
|
||||
|
||||
LibreOffice's Listenformatvorlagen: a flat family of up-to-10-level definitions
|
||||
(`ListLevelStyle` — bullet/number kind, marker, relative `indentCm`, `markerAlign`,
|
||||
`startAt`; `multilevel` is style-wide). Assignment is `listStyleName` on the **outermost**
|
||||
list only — nested lists inherit, as an ODF `<text:list>` does. Precedence per level:
|
||||
node attr > style level > depth-cycle default, resolved by `effectiveListLevel` — the one
|
||||
source the decoration walk (`listStyleDecos`, which also owns `data-eff-list-style` for
|
||||
plain lists) and both exporters share. The five built-ins are **probed LibreOffice
|
||||
definitions** (List 1/2, Numbering 123/ABC/IVX; their margins are LO's own metric values).
|
||||
Export writes the full named `<text:list-style>` into styles.xml and points the list at it
|
||||
**only when nothing overrides** — ODF list styles have no parent chain, so an overridden
|
||||
list keeps the fully resolved automatic clone and drops the name (marker formats count as
|
||||
an override: they ride the L# levels). Probed: LibreOffice keeps a named reference on
|
||||
`<text:list>` across a re-save, preserves `style:display-name`, normalizes `style:name` to
|
||||
the `_20_`-encoded display name, and fills undefined levels with decimal — so the export
|
||||
always writes all 10 levels.
|
||||
|
||||
## Paragraph styles (`styleSheet.ts`, `sheet.svelte.ts`, `editor/extensions/paragraphStyle.ts`)
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Named list styles, the fourth family — LibreOffice's Listenformatvorlagen / Word's
|
||||
// numbering styles. A style defines up to MAX_LIST_LEVELS levels; a list node's own
|
||||
// attrs (bulletChar, listStyleType, indent, markerAlign) still override its level.
|
||||
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import type { OrderedListType } from '../utils/orderedListTypes';
|
||||
|
||||
// One level. `indentCm` is this level's extra step past the level above (signed),
|
||||
// the same relative model the node attr uses; absolute margins are derived.
|
||||
export type ListLevelStyle = {
|
||||
kind: 'bullet' | 'number';
|
||||
bulletChar?: string;
|
||||
numType?: Exclude<OrderedListType, 'multilevel'>;
|
||||
indentCm?: number;
|
||||
markerAlign?: 'right';
|
||||
startAt?: number;
|
||||
};
|
||||
|
||||
export type ListStyle = {
|
||||
name: string;
|
||||
builtin?: boolean;
|
||||
// Every number level shows the parent chain (ODF text:display-levels, DOCX "%1.%2.").
|
||||
multilevel?: boolean;
|
||||
levels: ListLevelStyle[]; // levels[0] = depth 1
|
||||
};
|
||||
|
||||
export const MAX_LIST_LEVELS = 10; // ODF's ceiling; the DOCX writer emits the first 9
|
||||
|
||||
// A depth past the defined levels falls back to the depth-cycle default, like the
|
||||
// exporters' modulo geometry; depth is 1-based.
|
||||
export function listLevelOf(style: ListStyle | null | undefined, depth: number): ListLevelStyle | null {
|
||||
return style?.levels[depth - 1] ?? null;
|
||||
}
|
||||
|
||||
// What a list node at `depth` renders under `style` after its own attrs: node attr ??
|
||||
// style level ?? null (= the depth-cycle default the walk resolves). Single source of
|
||||
// truth for the decoration walk, both exporters and both importers' suppression.
|
||||
export type EffectiveListLevel = {
|
||||
bulletChar: string | null;
|
||||
listStyleType: OrderedListType | null;
|
||||
indent: number;
|
||||
markerAlign: 'right' | null;
|
||||
startAt: number | null;
|
||||
};
|
||||
|
||||
export function effectiveListLevel(
|
||||
attrs: Record<string, unknown> | null | undefined,
|
||||
ordered: boolean,
|
||||
style: ListStyle | null | undefined,
|
||||
depth: number,
|
||||
): EffectiveListLevel {
|
||||
const level = listLevelOf(style, depth);
|
||||
// A level only speaks for its own kind: an ordered node over a bullet level (or the
|
||||
// reverse) keeps the cycle default rather than a marker of the wrong species.
|
||||
const matches = level != null && (level.kind === 'number') === ordered;
|
||||
const own = (key: string): unknown => (attrs ?? {})[key] ?? null;
|
||||
return {
|
||||
bulletChar: !ordered ? (own('bulletChar') as string | null) ?? (matches ? level.bulletChar ?? null : null) : null,
|
||||
listStyleType: ordered
|
||||
? (own('listStyleType') as OrderedListType | null) ?? (style?.multilevel ? 'multilevel' : matches ? level.numType ?? null : null)
|
||||
: null,
|
||||
indent: typeof attrs?.indent === 'number' ? attrs.indent : (level?.indentCm ?? 0),
|
||||
markerAlign: (own('markerAlign') as 'right' | null) ?? (level?.markerAlign ?? null),
|
||||
startAt: matches && ordered ? level.startAt ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Absolute left margin of a level (1-based): one 1.27cm step per level plus each
|
||||
// level's own extra, the sum both file formats store.
|
||||
export const LIST_LEVEL_STEP_CM = 1.27;
|
||||
|
||||
export function listStyleMarginCm(style: ListStyle, depth: number): number {
|
||||
let cm = 0;
|
||||
for (let d = 1; d <= depth; d++) cm += LIST_LEVEL_STEP_CM + (listLevelOf(style, d)?.indentCm ?? 0);
|
||||
return cm;
|
||||
}
|
||||
|
||||
type JsonNode = { type?: string; attrs?: Record<string, unknown> | null; content?: JsonNode[] };
|
||||
|
||||
// Whether any list node in the subtree carries direct formatting over the style —
|
||||
// then the export keeps the fully resolved automatic style and drops the name (ODF
|
||||
// list styles have no parent chain to hang an override on). `start` is content, not
|
||||
// formatting: a split list's continuation must not count.
|
||||
export function listStyleOverridden(list: JsonNode): boolean {
|
||||
if (list.type === 'bulletList' || list.type === 'orderedList') {
|
||||
const a = list.attrs ?? {};
|
||||
if (a.bulletChar || a.listStyleType || a.markerAlign || (typeof a.indent === 'number' && a.indent !== 0)) return true;
|
||||
}
|
||||
return (list.content ?? []).some(listStyleOverridden);
|
||||
}
|
||||
|
||||
// The default marker a style level would need to differ from before it must be written
|
||||
// out — mirrors bulletCharAttr/orderedTypeAttr for the per-depth cycles.
|
||||
export function defaultLevelBullet(depth: number): string {
|
||||
return defaultBulletChar(depth - 1);
|
||||
}
|
||||
|
||||
export function builtinListStyles(): Record<string, ListStyle> {
|
||||
const out: Record<string, ListStyle> = {};
|
||||
for (const s of LIST_BUILTINS) out[s.name] = structuredClone(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
const levels = (n: number, make: (i: number) => ListLevelStyle): ListLevelStyle[] =>
|
||||
Array.from({ length: n }, (_, i) => make(i));
|
||||
|
||||
// LibreOffice's own list styles (probed from a 25.2 save; margins are its round metric
|
||||
// values — List 1/2 step 0.4/0.3cm, the numberings 1.33cm then 0.7cm per level, IVX
|
||||
// 1.33cm throughout with a right-set label). indentCm = the step minus the 1.27 base.
|
||||
const LIST_BUILTINS: ListStyle[] = [
|
||||
{ name: 'List 1', builtin: true,
|
||||
levels: levels(MAX_LIST_LEVELS, () => ({ kind: 'bullet', bulletChar: '•', indentCm: -0.87 })) },
|
||||
{ name: 'List 2', builtin: true,
|
||||
levels: levels(MAX_LIST_LEVELS, () => ({ kind: 'bullet', bulletChar: '–', indentCm: -0.97 })) },
|
||||
{ name: 'Numbering 123', builtin: true,
|
||||
levels: levels(MAX_LIST_LEVELS, (i) => ({ kind: 'number', numType: 'decimal', indentCm: i === 0 ? 0.06 : -0.57 })) },
|
||||
{ name: 'Numbering ABC', builtin: true,
|
||||
levels: levels(MAX_LIST_LEVELS, (i) => ({ kind: 'number', numType: 'upper-alpha', indentCm: i === 0 ? 0.06 : -0.57 })) },
|
||||
{ name: 'Numbering IVX', builtin: true,
|
||||
levels: levels(MAX_LIST_LEVELS, () => ({ kind: 'number', numType: 'upper-roman', markerAlign: 'right', indentCm: 0.06 })) },
|
||||
];
|
||||
@@ -3,6 +3,7 @@
|
||||
// formatting on the block or run still overrides the result.
|
||||
|
||||
import { builtinTableStyles, tableStyleCss, type TableStyle } from './tableStyles';
|
||||
import { builtinListStyles, type ListStyle } from './listStyles';
|
||||
import type { CapsMode } from '../editor/extensions/textEffects';
|
||||
|
||||
export type ParaProps = {
|
||||
@@ -49,14 +50,15 @@ export type Style = {
|
||||
text: TextProps;
|
||||
};
|
||||
|
||||
// Three families, as in LibreOffice/Word: paragraph styles govern whole blocks, character
|
||||
// Four families, as in LibreOffice/Word: paragraph styles govern whole blocks, character
|
||||
// styles a run of text inside one (same Style shape; a character style only uses `text`),
|
||||
// table styles a whole table (their own shape, no inheritance — see tableStyles.ts).
|
||||
export type StyleFamily = 'paragraph' | 'character' | 'table';
|
||||
// table and list styles their own whole node (own shapes, no inheritance).
|
||||
export type StyleFamily = 'paragraph' | 'character' | 'table' | 'list';
|
||||
export type StyleSheet = {
|
||||
paragraph: Record<string, Style>;
|
||||
character: Record<string, Style>;
|
||||
table: Record<string, TableStyle>;
|
||||
list: Record<string, ListStyle>;
|
||||
};
|
||||
|
||||
export const DEFAULT_STYLE = 'Standard';
|
||||
@@ -98,14 +100,15 @@ const CHAR_BUILTINS: Style[] = [
|
||||
|
||||
// Bumped whenever the built-in definitions change: a stored sheet from an older version
|
||||
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
|
||||
export const STYLE_SHEET_VERSION = 10;
|
||||
export const STYLE_SHEET_VERSION = 11;
|
||||
|
||||
// A persisted sheet merged onto the current built-ins. Same version: stored entries win
|
||||
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
|
||||
export function mergeStoredSheet(stored: unknown): StyleSheet {
|
||||
const sheet = builtinStyleSheet();
|
||||
const data = stored as
|
||||
| { v?: number; paragraph?: Record<string, Style>; character?: Record<string, Style>; table?: Record<string, TableStyle> }
|
||||
| { v?: number; paragraph?: Record<string, Style>; character?: Record<string, Style>;
|
||||
table?: Record<string, TableStyle>; list?: Record<string, ListStyle> }
|
||||
| null;
|
||||
if (!data?.paragraph || typeof data.paragraph !== 'object') return sheet;
|
||||
const current = data.v === STYLE_SHEET_VERSION;
|
||||
@@ -116,11 +119,13 @@ export function mergeStoredSheet(stored: unknown): StyleSheet {
|
||||
sheet[family][name] = style;
|
||||
}
|
||||
}
|
||||
// Table styles have their own shape, so they merge separately.
|
||||
for (const [name, style] of Object.entries(data.table ?? {})) {
|
||||
if (!style || typeof style !== 'object') continue;
|
||||
if (!current && style.builtin) continue;
|
||||
sheet.table[name] = style;
|
||||
// Table and list styles have their own shapes, so they merge separately.
|
||||
for (const family of ['table', 'list'] as const) {
|
||||
for (const [name, style] of Object.entries(data[family] ?? {})) {
|
||||
if (!style || typeof style !== 'object') continue;
|
||||
if (!current && style.builtin) continue;
|
||||
(sheet[family] as Record<string, TableStyle | ListStyle>)[name] = style;
|
||||
}
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
@@ -130,14 +135,14 @@ export function builtinStyleSheet(): StyleSheet {
|
||||
for (const s of BUILTINS) paragraph[s.name] = structuredClone(s);
|
||||
const character: Record<string, Style> = {};
|
||||
for (const s of CHAR_BUILTINS) character[s.name] = structuredClone(s);
|
||||
return { paragraph, character, table: builtinTableStyles() };
|
||||
return { paragraph, character, table: builtinTableStyles(), list: builtinListStyles() };
|
||||
}
|
||||
|
||||
// Inheritance order: every style directly followed by its own children, so the manager's
|
||||
// indent matches the tree. Siblings sort built-ins first (as listed above), then by name.
|
||||
// `Heading` is abstract (never assignable), so only withAbstract callers see it.
|
||||
export function styleOrder(sheet: StyleSheet, withAbstract = false, family: StyleFamily = 'paragraph'): Style[] {
|
||||
if (family === 'table') return []; // table styles have no inheritance — listed flat
|
||||
if (family === 'table' || family === 'list') return []; // no inheritance — listed flat
|
||||
const styles = family === 'character' ? sheet.character : sheet.paragraph;
|
||||
const rank = new Map((family === 'character' ? CHAR_BUILTINS : BUILTINS).map((b, i) => [b.name, i]));
|
||||
const order = (a: string, b: string) => (rank.get(a) ?? 1e9) - (rank.get(b) ?? 1e9) || a.localeCompare(b);
|
||||
|
||||
@@ -409,6 +409,52 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
|
||||
index?.attrs?.entries);
|
||||
});
|
||||
|
||||
it('survives a `soffice` re-save of named list styles', { timeout: 180000 }, async () => {
|
||||
const { builtinStyleSheet } = await import('../src/lib/styles/styleSheet');
|
||||
const sheet = builtinStyleSheet();
|
||||
sheet.list['Prüfliste'] = {
|
||||
name: 'Prüfliste',
|
||||
levels: [
|
||||
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
|
||||
{ kind: 'bullet', bulletChar: '✓' },
|
||||
{ kind: 'number', numType: 'lower-alpha' },
|
||||
],
|
||||
};
|
||||
const doc: N = { type: 'doc', content: [
|
||||
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [
|
||||
LI(P(null, T('one')), { type: 'bulletList', content: [LI(P(null, T('sub')))] }),
|
||||
LI(P(null, T('two'))),
|
||||
] },
|
||||
P(null, T('between')),
|
||||
{ type: 'bulletList', attrs: { listStyleName: 'List 2' }, content: [LI(P(null, T('dash')))] },
|
||||
] };
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/ls.odt', await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet));
|
||||
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/lsout /tmp/lo-rt/ls.odt', { stdio: 'pipe', timeout: 120000 });
|
||||
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/lsout/ls.odt'));
|
||||
const files = unzipSync(resaved);
|
||||
const xml = strFromU8(files['content.xml']);
|
||||
const stylesXml = strFromU8(files['styles.xml']);
|
||||
|
||||
// LibreOffice keeps the named reference and the definition (probed) — this is
|
||||
// the assignment surviving a real editor, not just our own reader.
|
||||
check('LO lists: the definition stays named in styles.xml',
|
||||
/<text:list-style style:name="Prüfliste"/.test(stylesXml), stylesXml.match(/<text:list-style[^>]*>/g));
|
||||
check('LO lists: the list still references it', xml.includes('text:style-name="Prüfliste"'), xml.match(/<text:list [^>]*>/g));
|
||||
|
||||
const res = importOdt(resaved);
|
||||
const lists = (res.content.content ?? []).filter((n: N) => n.type === 'orderedList' || n.type === 'bulletList');
|
||||
check('LO lists: the assignment comes back', lists[0]?.attrs?.listStyleName === 'Prüfliste', lists[0]?.attrs);
|
||||
check('LO lists: the built-in assignment comes back', lists[1]?.attrs?.listStyleName === 'List 2', lists[1]?.attrs);
|
||||
check('LO lists: no per-level attrs accrete',
|
||||
!lists[0]?.attrs?.listStyleType && !lists[0]?.attrs?.indent && !lists[0]?.attrs?.markerAlign, lists[0]?.attrs);
|
||||
const imported = res.styles.list['Prüfliste'];
|
||||
check('LO lists: the definition comes back', imported?.levels[0]?.numType === 'upper-roman-paren'
|
||||
&& imported?.levels[0]?.markerAlign === 'right' && imported?.levels[1]?.bulletChar === '✓', imported?.levels?.slice(0, 2));
|
||||
check('LO lists: the level indent survives the unit round-trip',
|
||||
Math.abs((imported?.levels[0]?.indentCm ?? 0) - 0.5) < 0.02, imported?.levels[0]);
|
||||
});
|
||||
|
||||
it('survives a `soffice` re-save of the record-changes flag', { timeout: 180000 }, async () => {
|
||||
const doc: N = { type: 'doc', content: [P(null, T('Ein Satz.'))] };
|
||||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', undefined,
|
||||
|
||||
@@ -1670,6 +1670,121 @@ describe('Leg 13: named character styles (ODF)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 14: named list styles (ODF)', () => {
|
||||
const sheet = builtinStyleSheet();
|
||||
sheet.list['Prüfliste'] = {
|
||||
name: 'Prüfliste',
|
||||
levels: [
|
||||
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
|
||||
{ kind: 'bullet', bulletChar: '✓' },
|
||||
{ kind: 'number', numType: 'lower-alpha' },
|
||||
],
|
||||
};
|
||||
|
||||
// Two clean assignments (user style with a nested level, a built-in) — these
|
||||
// round-trip byte-for-byte. The overridden list is asserted separately.
|
||||
const cleanDoc: N = { type: 'doc', content: [
|
||||
P(null, T('before')),
|
||||
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [
|
||||
LI(P(null, T('one')), { type: 'bulletList', content: [LI(P(null, T('sub')))] }),
|
||||
LI(P(null, T('two'))),
|
||||
] },
|
||||
P(null, T('between')),
|
||||
{ type: 'bulletList', attrs: { listStyleName: 'List 2' }, content: [LI(P(null, T('dash')))] },
|
||||
] };
|
||||
|
||||
it('writes named text:list-style definitions and round-trips the assignment', async () => {
|
||||
const bytes = await buildOdt(cleanDoc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||||
const files = unzipSync(bytes);
|
||||
const styles = strFromU8(files['styles.xml']);
|
||||
const content = strFromU8(files['content.xml']);
|
||||
|
||||
const def = styles.match(/<text:list-style style:name="Prüfliste"[\s\S]*?<\/text:list-style>/)?.[0] ?? '';
|
||||
check('mints the named definition with its display name', def.includes('style:display-name="Prüfliste"'), styles.slice(0, 300));
|
||||
check('level 1 carries the numbering and the right-set label',
|
||||
/text:level="1" style:num-format="I" style:num-suffix="\)"/.test(def) && def.includes('fo:text-align="end"'), def);
|
||||
check('level 2 is the bullet level', /text:level="2" text:bullet-char="✓"/.test(def), def);
|
||||
check('level 1 margin carries the extra step (1.77cm)', def.includes('fo:margin-left="1.770cm"'), def);
|
||||
check('all ten levels are written', (def.match(/text:level="/g) ?? []).length === 10, def);
|
||||
check('the built-in List 2 is minted too', /<text:list-style style:name="List_20_2" style:display-name="List 2">/.test(styles));
|
||||
check('the list references the named style', content.includes('<text:list text:style-name="Prüfliste">'), content.match(/<text:list [^>]*>/g));
|
||||
check('the bullet list references List 2', content.includes('<text:list text:style-name="List_20_2">'));
|
||||
check('nested lists stay bare (they inherit the style)', /<text:list>\s*<text:list-item>/.test(content), content.match(/<text:list[^>]*>/g));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||||
const diff = firstDiff(normalize(cleanDoc), normalize(res.content));
|
||||
check('document JSON round-trips without accreted attrs', diff === null, diff);
|
||||
|
||||
const imported = res.styles.list['Prüfliste'];
|
||||
check('the style itself round-trips', !!imported, Object.keys(res.styles.list));
|
||||
check('level 1 survives whole', imported?.levels[0]?.numType === 'upper-roman-paren'
|
||||
&& imported?.levels[0]?.markerAlign === 'right' && imported?.levels[0]?.indentCm === 0.5, imported?.levels[0]);
|
||||
check('level 2 keeps its bullet', imported?.levels[1]?.bulletChar === '✓', imported?.levels[1]);
|
||||
expect.soft(res.styles.list['List 2'], 'the built-in comes back as itself').toEqual(sheet.list['List 2']);
|
||||
});
|
||||
|
||||
it('an overridden list keeps the resolved automatic clone and drops the name', async () => {
|
||||
const doc: N = { type: 'doc', content: [
|
||||
{ type: 'orderedList', attrs: { listStyleName: 'Numbering ABC', listStyleType: 'lower-roman' }, content: [LI(P(null, T('broken out')))] },
|
||||
] };
|
||||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||||
const files = unzipSync(bytes);
|
||||
const content = strFromU8(files['content.xml']);
|
||||
|
||||
check('the list keeps its automatic style', content.includes('<text:list text:style-name="L1">'), content.match(/<text:list [^>]*>/g));
|
||||
check('no named definition is written', !strFromU8(files['styles.xml']).includes('Numbering_20_ABC'));
|
||||
check('the clone resolves the override', /<text:list-style style:name="L1">[\s\S]*?text:level="1" style:num-format="i" style:num-suffix="\."/.test(content), content.match(/<text:list-style[\s\S]*?<\/text:list-style>/)?.[0]);
|
||||
|
||||
const list = (importOdt(bytes).content.content ?? []).find((n: N) => n.type === 'orderedList');
|
||||
check('the look survives as direct formatting', list?.attrs?.listStyleType === 'lower-roman', list?.attrs);
|
||||
check('the name is gone', !list?.attrs?.listStyleName, list?.attrs);
|
||||
});
|
||||
|
||||
it('a foreign named list style is read back (display name decoded)', async () => {
|
||||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.2">
|
||||
<office:styles>
|
||||
<text:list-style style:name="Foreign_20_List" style:display-name="Foreign List">
|
||||
<text:list-level-style-number text:level="1" style:num-format="A" style:num-suffix=")" text:start-value="5">
|
||||
<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="2cm" fo:text-indent="-0.635cm" fo:margin-left="2cm"/>
|
||||
</style:list-level-properties>
|
||||
</text:list-level-style-number>
|
||||
<text:list-level-style-bullet text:level="2" text:bullet-char="◦">
|
||||
<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="3cm" fo:text-indent="-0.635cm" fo:margin-left="3cm"/>
|
||||
</style:list-level-properties>
|
||||
</text:list-level-style-bullet>
|
||||
</text:list-style>
|
||||
</office:styles>
|
||||
</office:document-styles>`;
|
||||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.2">
|
||||
<office:body><office:text>
|
||||
<text:list text:style-name="Foreign_20_List">
|
||||
<text:list-item><text:p>alpha</text:p>
|
||||
<text:list><text:list-item><text:p>circle</text:p></text:list-item></text:list>
|
||||
</text:list-item>
|
||||
</text:list>
|
||||
</office:text></office:body>
|
||||
</office:document-content>`;
|
||||
const bytes = zipSync({
|
||||
mimetype: strToU8('application/vnd.oasis.opendocument.text'),
|
||||
'content.xml': strToU8(contentXml),
|
||||
'styles.xml': strToU8(stylesXml),
|
||||
});
|
||||
const res = importOdt(bytes);
|
||||
const list = (res.content.content ?? []).find((n: N) => n.type === 'orderedList');
|
||||
check('the list references the decoded display name', list?.attrs?.listStyleName === 'Foreign List', list?.attrs);
|
||||
check('no per-level attrs accrete on the nested list', !list?.content?.[0]?.content?.[1]?.attrs, list?.content?.[0]?.content?.[1]);
|
||||
const imported = res.styles.list['Foreign List'];
|
||||
check('the definition lands in the registry', imported?.levels[0]?.numType === 'upper-alpha-paren'
|
||||
&& imported?.levels[0]?.startAt === 5 && imported?.levels[1]?.kind === 'bullet', imported);
|
||||
check('the level indent derives from the margin step (2cm → +0.73)', imported?.levels[0]?.indentCm === 0.73, imported?.levels[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 12: per-section page margins (ODT + DOCX)', () => {
|
||||
const secDoc: N = {
|
||||
type: 'doc',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
builtinListStyles, effectiveListLevel, listStyleMarginCm, listStyleOverridden,
|
||||
MAX_LIST_LEVELS, type ListStyle,
|
||||
} from '../../src/lib/styles/listStyles';
|
||||
|
||||
const style: ListStyle = {
|
||||
name: 'Test',
|
||||
levels: [
|
||||
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
|
||||
{ kind: 'bullet', bulletChar: '✓' },
|
||||
{ kind: 'number', numType: 'lower-alpha', startAt: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('effectiveListLevel (attr ?? style level ?? cycle default)', () => {
|
||||
it('takes the style level where the node has no attr', () => {
|
||||
const eff = effectiveListLevel({}, true, style, 1);
|
||||
expect(eff.listStyleType).toBe('upper-roman-paren');
|
||||
expect(eff.markerAlign).toBe('right');
|
||||
expect(eff.indent).toBe(0.5);
|
||||
});
|
||||
|
||||
it('lets a node attr override its level', () => {
|
||||
const eff = effectiveListLevel({ listStyleType: 'decimal', indent: 1 }, true, style, 1);
|
||||
expect(eff.listStyleType).toBe('decimal');
|
||||
expect(eff.indent).toBe(1);
|
||||
expect(eff.markerAlign).toBe('right'); // untouched channel still comes from the style
|
||||
});
|
||||
|
||||
it('serves the bullet level to a bullet node only', () => {
|
||||
expect(effectiveListLevel({}, false, style, 2).bulletChar).toBe('✓');
|
||||
// An ordered node over the bullet level keeps the cycle default (null).
|
||||
expect(effectiveListLevel({}, true, style, 2).listStyleType).toBeNull();
|
||||
expect(effectiveListLevel({}, false, style, 1).bulletChar).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the cycle default past the defined levels and without a style', () => {
|
||||
expect(effectiveListLevel({}, true, style, 5).listStyleType).toBeNull();
|
||||
expect(effectiveListLevel({}, true, null, 1).listStyleType).toBeNull();
|
||||
});
|
||||
|
||||
it('a multilevel style renders the chain on every ordered level', () => {
|
||||
const ml: ListStyle = { name: 'ML', multilevel: true, levels: [{ kind: 'number', numType: 'decimal' }] };
|
||||
expect(effectiveListLevel({}, true, ml, 1).listStyleType).toBe('multilevel');
|
||||
expect(effectiveListLevel({}, true, ml, 3).listStyleType).toBe('multilevel');
|
||||
});
|
||||
|
||||
it('carries the level start value for the matching kind', () => {
|
||||
expect(effectiveListLevel({}, true, style, 3).startAt).toBe(3);
|
||||
expect(effectiveListLevel({}, false, style, 2).startAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listStyleOverridden', () => {
|
||||
const li = (...c: unknown[]) => ({ type: 'listItem', content: [{ type: 'paragraph' }, ...c] });
|
||||
it('a clean subtree is not overridden; `start` is content, not formatting', () => {
|
||||
expect(listStyleOverridden({ type: 'orderedList', attrs: { listStyleName: 'Test', start: 4 }, content: [li()] })).toBe(false);
|
||||
});
|
||||
it('any marker/indent attr anywhere in the subtree overrides', () => {
|
||||
expect(listStyleOverridden({ type: 'orderedList', attrs: { listStyleType: 'decimal' }, content: [li()] })).toBe(true);
|
||||
expect(listStyleOverridden({
|
||||
type: 'orderedList', content: [li({ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [li()] })],
|
||||
})).toBe(true);
|
||||
expect(listStyleOverridden({ type: 'bulletList', attrs: { indent: 1.5 }, content: [li()] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the built-ins (probed LibreOffice definitions)', () => {
|
||||
const builtins = builtinListStyles();
|
||||
it('ship LibreOffice\'s five, ten levels each', () => {
|
||||
expect(Object.keys(builtins).sort()).toEqual(['List 1', 'List 2', 'Numbering 123', 'Numbering ABC', 'Numbering IVX']);
|
||||
for (const s of Object.values(builtins)) expect(s.levels).toHaveLength(MAX_LIST_LEVELS);
|
||||
});
|
||||
it('carry LibreOffice\'s level geometry', () => {
|
||||
// List 1: 0.4cm per level; Numbering 123: 1.33cm then 0.7cm steps.
|
||||
expect(listStyleMarginCm(builtins['List 1'], 1)).toBeCloseTo(0.4, 3);
|
||||
expect(listStyleMarginCm(builtins['List 1'], 2)).toBeCloseTo(0.8, 3);
|
||||
expect(listStyleMarginCm(builtins['Numbering 123'], 1)).toBeCloseTo(1.33, 3);
|
||||
expect(listStyleMarginCm(builtins['Numbering 123'], 2)).toBeCloseTo(2.03, 3);
|
||||
});
|
||||
it('Numbering IVX sets its wide labels against the right edge', () => {
|
||||
expect(builtins['Numbering IVX'].levels[0]).toMatchObject({ kind: 'number', numType: 'upper-roman', markerAlign: 'right' });
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import ListItem from '@tiptap/extension-list-item';
|
||||
import { OrderedList, orderedListStyleDecos } from '../../src/lib/editor/extensions/orderedList';
|
||||
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
|
||||
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
|
||||
import { listStyleDecos } from '../../src/lib/editor/extensions/listStyle';
|
||||
import { builtinStyleSheet } from '../../src/lib/styles/styleSheet';
|
||||
|
||||
const schema = getSchema([Document, Paragraph, Text, ListItem, OrderedList, BulletList]);
|
||||
|
||||
@@ -18,7 +20,7 @@ const doc = (...content: PmNode[]) => schema.nodes.doc.create(null, content);
|
||||
|
||||
// The effective data-eff-list-style the plugin tags each <ol> with, in document order.
|
||||
function effStyles(d: PmNode): (string | null)[] {
|
||||
const set = orderedListStyleDecos(d);
|
||||
const set = listStyleDecos(d, builtinStyleSheet());
|
||||
const out: (string | null)[] = [];
|
||||
d.descendants((node, pos) => {
|
||||
if (node.type.name !== 'orderedList') return;
|
||||
|
||||
Reference in new issue
Block a user