feat: bullet symbols

This commit is contained in:
Steffen Becker committed 2026-07-04 17:14:54 +02:00
1 parent d242a515cb
commit 3b7e7606bb
20 files changed
+735 -64

No files matched your search

+1
View File
@@ -30,6 +30,7 @@
- Line spacing and paragraph spacing (space before / after)
- Increase / decrease indent (paragraphs and lists)
- Bulleted and ordered lists — nested, with multiple numbering styles (decimal, alpha, roman; `.` or `)` suffix) and whole-list indent; Tab / Shift-Tab to nest / un-nest
- Customizable bullet symbols per list level (Word-style picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF `text:bullet-char` and DOCX `w:lvlText`, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (`EdenSymbols.woff2`), so markers look compact and identical on every platform instead of a stretched OS fallback
- Tab stops (real tab character) and manual line breaks (Shift+Enter)
**Insert**
+3 -3
View File
@@ -627,11 +627,11 @@
<div class="theme-heading">Save / Export</div>
<button class="theme-option" onclick={handleSave} role="menuitem">
<span>ODT</span>
<span class="theme-option-hint">OpenDocument · fully editable</span>
<span class="theme-option-hint">OpenDocument</span>
</button>
<button class="theme-option" onclick={handleSaveDocx} disabled={docxBusy} role="menuitem">
<span>{docxBusy ? 'Exporting…' : 'Word (.docx)'}</span>
<span class="theme-option-hint">Microsoft Word · editable</span>
<span class="theme-option-hint">Microsoft Word</span>
</button>
<button class="theme-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
<span>{pdfBusy ? 'Exporting…' : 'Raster PDF'}</span>
@@ -639,7 +639,7 @@
</button>
<button class="theme-option" onclick={handlePrintPdf} role="menuitem">
<span>Vector PDF (beta)</span>
<span class="theme-option-hint">Sharp &amp; small, but problems with tables</span>
<span class="theme-option-hint">Sharp, but problems with tables</span>
</button>
</div>
{/if}
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: DejaVu fonts
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
Source: https://dejavu-fonts.github.io/
Files: *
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
License: bitstream-vera
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
.
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
.
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Files: debian/*
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
License: GPL-2+
This program is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later
version.
.
This program is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more
details.
.
You should have received a copy of the GNU General Public
License along with this package; if not, write to the Free
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
Boston, MA 02110-1301 USA
.
On Debian systems, the full text of the GNU General Public
License version 2 can be found in the file
/usr/share/common-licenses/GPL-2'.
+117 -17
View File
@@ -3,6 +3,7 @@
import HistoryButton from './HistoryButton.svelte';
import AlignButton from './AlignButton.svelte';
import { ORDERED_LIST_TYPES, DEFAULT_ORDERED_TYPE, type OrderedListType } from '../utils/orderedListTypes';
import { BULLET_TYPES } from '../utils/bulletListTypes';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -33,6 +34,30 @@
});
let olMenuOpen = $state(false);
let blMenuOpen = $state(false);
// Marker char of the innermost bullet list at the cursor (null = default cycle
// or not in a bullet list).
let currentBulletChar = $derived.by<string | null>(() => {
if (tick < 0 || !editor || !editor.isActive('bulletList')) return null;
return (editor.getAttributes('bulletList').bulletChar ?? null) as string | null;
});
// Toggle a plain bullet list — the split button's main half.
function toggleBulletList() {
blMenuOpen = false;
editor?.chain().focus().toggleBulletList().run();
}
// Apply a marker char to the innermost list level (null = default); creates the
// list first if the selection isn't in one.
function applyBulletChar(char: string | null) {
if (!editor) return;
blMenuOpen = false;
const chain = editor.chain().focus();
if (!editor.isActive('bulletList')) chain.toggleBulletList();
chain.setBulletChar(char).run();
}
// Toggle a plain (decimal) ordered list — the split button's main half.
function toggleOrderedList() {
@@ -49,9 +74,9 @@
chain.updateAttributes('orderedList', { listStyleType: key }).run();
}
function olMenuClickOutside(node: HTMLElement) {
function menuClickOutside(node: HTMLElement, close: () => void) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) olMenuOpen = false;
if (!node.contains(e.target as Node)) close();
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
@@ -132,21 +157,65 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isBulletList}
onclick={() => editor?.chain().focus().toggleBulletList().run()}
title="Bullet list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="2" cy="4" r="1.5" fill="currentColor"/>
<circle cx="2" cy="8" r="1.5" fill="currentColor"/>
<circle cx="2" cy="12" r="1.5" fill="currentColor"/>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="ol-picker" use:olMenuClickOutside>
<div class="ol-picker" use:menuClickOutside={() => (blMenuOpen = false)}>
<div class="ol-split">
<button
class="ol-main"
class:active={isBulletList}
onclick={toggleBulletList}
title="Bullet list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="2" cy="4" r="1.5" fill="currentColor"/>
<circle cx="2" cy="8" r="1.5" fill="currentColor"/>
<circle cx="2" cy="12" r="1.5" fill="currentColor"/>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class="ol-chevron"
class:active={isBulletList}
onclick={() => (blMenuOpen = !blMenuOpen)}
title="Bullet symbol"
aria-haspopup="menu"
aria-expanded={blMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if blMenuOpen}
<div class="ol-dropdown bl-dropdown" role="menu">
<div class="ol-section-label">Bullet symbol</div>
<button
class="ol-option"
class:active={isBulletList && currentBulletChar === null}
onclick={() => applyBulletChar(null)}
role="menuitemradio"
aria-checked={isBulletList && currentBulletChar === null}
>
<span class="ol-option-preview">•</span>
<span class="ol-option-label">Default (by level)</span>
</button>
<div class="bl-grid">
{#each BULLET_TYPES as t}
<button
class="bl-tile"
class:active={currentBulletChar === t.char}
onclick={() => applyBulletChar(t.char)}
title={t.label}
role="menuitemradio"
aria-checked={currentBulletChar === t.char}
>{t.char}</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="ol-picker" use:menuClickOutside={() => (olMenuOpen = false)}>
<div class="ol-split">
<button
class="ol-main"
@@ -383,4 +452,35 @@
.ol-option.active .ol-option-label {
color: white;
}
/* Bullet-symbol picker: the curated chars as a compact tile grid. */
.bl-dropdown {
min-width: 130px;
}
.bl-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
padding: 2px;
}
.bl-tile {
min-width: unset;
height: 1.9rem;
padding: 0;
border-radius: calc(var(--radius) - 2px);
/* Same symbol shim the list markers use, so tiles preview the real glyphs. */
font-family: 'EdenText Symbols', var(--font-serif, serif);
font-size: 1rem;
}
.bl-tile:hover {
background: var(--color-btn-hover);
}
.bl-tile.active {
background: var(--color-primary);
color: white;
}
</style>
+1 -1
View File
@@ -14,7 +14,7 @@ import { FontWeight } from './extensions/fontWeight';
import { FontColor } from './extensions/fontColor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import BulletList from '@tiptap/extension-bullet-list';
import { BulletList } from './extensions/bulletList';
import { OrderedList } from './extensions/orderedList';
import ListItem from '@tiptap/extension-list-item';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
+65
View File
@@ -0,0 +1,65 @@
import BulletListBase from '@tiptap/extension-bullet-list';
import { bulletCharAttr } from '../../utils/bulletListTypes';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
customBulletList: {
/** Set the marker char of the innermost bullet list at the cursor (null = default cycle). */
setBulletChar: (char: string | null) => ReturnType;
};
}
}
// BulletList with a `bulletChar` attr (a literal marker char, null = the default
// per-depth cycle), rendered as `data-bullet` + a CSS string marker on the <ul>:
// export/odt.ts maps it to text:bullet-char, export/docx.ts to w:lvlText.
export const BulletList = BulletListBase.extend({
addAttributes() {
return {
...this.parent?.(),
bulletChar: {
default: null,
parseHTML: (element) => element.getAttribute('data-bullet') || null,
renderHTML: (attributes) => {
const ch = attributes.bulletChar;
// The trailing space inside the string marker is the marker-to-text gap.
return ch ? { 'data-bullet': ch, style: `list-style-type: "${ch} "` } : {};
},
},
};
},
addCommands() {
return {
...this.parent?.(),
// setNodeMarkup on exactly the innermost list: updateAttributes would also
// rewrite every ancestor bulletList (it visits all nodes across the range).
setBulletChar:
(char) =>
({ state, tr, dispatch }) => {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
const node = $from.node(d);
if (node.type.name !== this.name) continue;
// Suppress the char when it matches the default cycle at this depth.
const value = bulletCharAttr(char, countListDepth($from, d));
if (dispatch) {
tr.setNodeMarkup($from.before(d), undefined, { ...node.attrs, bulletChar: value });
}
return true;
}
return false;
},
};
},
});
// 0-based bullet/ordered-list nesting depth of the list node at resolved depth `d`.
function countListDepth($from: { node: (d: number) => { type: { name: string } } }, d: number): number {
let depth0 = 0;
for (let i = d - 1; i > 0; i--) {
const name = $from.node(i).type.name;
if (name === 'bulletList' || name === 'orderedList') depth0++;
}
return depth0;
}
+10 -4
View File
@@ -20,6 +20,7 @@ import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { orderedTypeDef } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
@@ -33,8 +34,11 @@ type Writable<T> = { -readonly [P in keyof T]: T[P] };
const SCREEN_FONT = 'Liberation Serif';
const DOC_FONT = 'Times New Roman';
// Word's bullet glyph per nesting level (matches odt.ts CELL_BULLET_CHARS).
const BULLET_CHARS = ['•', '◦', '▪', '▸', '–', '·'];
// Effective bullet glyph of a list node: its bulletChar attr, else the default cycle.
function bulletCharOf(node: TiptapNode, depth0: number): string {
const ch = node.attrs?.bulletChar;
return typeof ch === 'string' && ch ? ch : defaultBulletChar(depth0);
}
// List geometry: 0.5in left step per level, 0.25in hanging for the marker (Word defaults).
const LIST_LEFT_STEP_CM = 1.27;
@@ -120,7 +124,9 @@ class Numbering {
levels.push({
level: depth,
format: LevelFormat.BULLET,
text: BULLET_CHARS[depth % BULLET_CHARS.length],
// The Unicode char goes into w:lvlText literally; Word renders it in the
// paragraph font (no Wingdings/Symbol rFonts needed).
text: bulletCharOf(node, depth),
alignment: AlignmentType.LEFT,
style: { paragraph: { indent } },
});
@@ -405,7 +411,7 @@ function txbxListXml(node: TiptapNode, depth: number): string {
if (child.type === 'bulletList' || child.type === 'orderedList') {
out += txbxListXml(child, depth + 1);
} else if (child.type === 'paragraph' || child.type === 'heading') {
const marker = first ? (ordered ? `${n}. ` : `${BULLET_CHARS[depth % BULLET_CHARS.length]} `) : '';
const marker = first ? (ordered ? `${n}. ` : `${bulletCharOf(node, depth)} `) : '';
out += txbxParagraphXml(child, cmToTwip((depth + 1) * LIST_HANGING_CM), marker);
first = false;
}
+137 -26
View File
@@ -7,6 +7,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -401,8 +402,9 @@ function paraStyleProps(style: ParaStyle): string[] {
// <text:p> odf-kit emits. Each paragraph/heading/list item is one segment, in DFS order.
type CellListItem = { style: ParaStyle; nested: CellListBlock | null };
// numFormat/numSuffix carry the ordered-list numbering style (orderedTypeDef);
// for bullet lists they hold the decimal defaults and are unused.
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; numFormat: string; numSuffix: string; items: CellListItem[] };
// for bullet lists they hold the decimal defaults and are unused. bulletChar is
// the bullet list's marker attr (null = default cycle).
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; numFormat: string; numSuffix: string; bulletChar: string | null; items: CellListItem[] };
type CellBlock =
| { kind: 'paragraph'; style: ParaStyle }
| { kind: 'heading'; level: number; style: ParaStyle }
@@ -738,6 +740,76 @@ function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | nu
return rezipOdt(files);
}
// 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);
}
// 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[] {
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);
}
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1);
}
}
};
walk(list, startDepth);
return vec;
}
// odf-kit emits every bullet level with the default cycle char. This rewrites the
// matching L# list style to the per-level chars of the user's bulletChar attrs;
// chars[] has one entry per top-level list (null = ordered or all-default).
function collectBulletListChars(node: TiptapNode, result: (string[] | null)[]): void {
for (const child of node.content ?? []) {
if (child.type === 'orderedList') {
result.push(null);
} else if (child.type === 'bulletList') {
const vec = bulletCharVector(child, 1, DEFAULT_BULLET_CYCLE);
result.push(vec.every((c, i) => c === DEFAULT_BULLET_CYCLE[i]) ? null : vec);
}
}
}
function applyBulletListChars(odtBytes: Uint8Array, chars: (string[] | null)[]): Uint8Array {
if (chars.every(c => c === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
chars.forEach((vec, i) => {
if (!vec) return;
// Rewrite only inside this list's own <text:list-style> block (all 6 levels).
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
content = content.replace(re, (_m, open: string, body: string, close: string) =>
open +
body.replace(
/(<text:list-level-style-bullet text:level=")(\d)(" text:bullet-char=")[^"]*(")/g,
(_mm, a: string, lvl: string, b: string, c: string) => a + lvl + b + (vec[+lvl - 1] ?? defaultBulletChar(+lvl - 1)) + c,
) +
close,
);
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Whole-list indent (cm) on a top-level bulletList/orderedList. odf-kit uses
// label-alignment mode (ignores the paragraph margin), so the shift goes onto the L#
// list-style's per-level fo:margin-left + text:list-tab-stop-position. One per list (0=none).
@@ -778,37 +850,47 @@ function applyListIndents(odtBytes: Uint8Array, indents: number[]): Uint8Array {
}
// odf-kit emits nested lists as bare <text:list> sharing the top-level L# style, so a
// nested list of a different kind/format (e.g. ordered inside bullets) loses its type.
// Mint it its own 6-level list style (same pattern applyCellBlocks uses for cell lists).
type ListDef = { ordered: boolean; numFormat: string; numSuffix: string };
// nested list of a different kind/format/marker loses its look. Mint it its own
// 6-level list style (same pattern applyCellBlocks uses for cell lists).
type ListDef = { ordered: boolean; numFormat: string; numSuffix: string; bulletChars: string[] };
function listDefOf(node: TiptapNode): ListDef {
function listDefOf(node: TiptapNode, depth: number, baseChars: string[]): ListDef {
const ordered = node.type === 'orderedList';
const def = ordered ? orderedTypeDef(node.attrs?.listStyleType as string | undefined) : null;
return { ordered, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.' };
return {
ordered,
numFormat: def?.numFormat ?? '1',
numSuffix: def?.numSuffix ?? '.',
bulletChars: bulletCharVector(node, depth, baseChars),
};
}
// 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) => {
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number) => {
let gov = governing;
if (!isTop) {
const def = listDefOf(list);
const differs = def.ordered !== governing.ordered
|| (def.ordered && (def.numFormat !== governing.numFormat || def.numSuffix !== governing.numSuffix));
result.push(differs ? def : null);
if (differs) gov = def; // restyled list governs its own descendants
const ordered = list.type === 'orderedList';
const fmt = ordered ? orderedTypeDef(list.attrs?.listStyleType as string | undefined) : null;
const differs = ordered !== governing.ordered
|| (ordered && (fmt!.numFormat !== governing.numFormat || fmt!.numSuffix !== governing.numSuffix))
|| (!ordered && effBulletChar(list, depth) !== governing.bulletChars[(depth - 1) % governing.bulletChars.length]);
const def = differs ? listDefOf(list, depth, governing.bulletChars) : null;
result.push(def);
if (def) gov = def; // restyled list governs its own descendants
}
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);
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1);
}
}
};
for (const child of doc.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, listDefOf(child), true);
if (child.type === 'bulletList' || child.type === 'orderedList') {
walkList(child, listDefOf(child, 1, DEFAULT_BULLET_CYCLE), true, 1);
}
}
}
@@ -823,12 +905,12 @@ function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]):
const minted: string[] = [];
const nameByKey = new Map<string, string>();
const styleFor = (fix: ListDef): string => {
const key = `${fix.ordered}|${fix.numFormat}|${fix.numSuffix}`;
const key = fix.ordered ? `o|${fix.numFormat}|${fix.numSuffix}` : `b|${fix.bulletChars.join('')}`;
let name = nameByKey.get(key);
if (!name) {
name = `NL${nameByKey.size + 1}`;
nameByKey.set(key, name);
minted.push(buildCellListStyle(name, fix.ordered, fix.numFormat, fix.numSuffix));
minted.push(buildCellListStyle(name, fix.ordered, fix.numFormat, fix.numSuffix, fix.bulletChars));
}
return name;
};
@@ -848,10 +930,9 @@ function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]):
}
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
// in-cell lists reference. Six nesting levels with identical bullet chars /
// in-cell lists reference. Six nesting levels with the given bullet chars /
// indents / numbering, so cell lists match the editor's top-level lists.
const CELL_BULLET_CHARS = ['•', '◦', '▪', '▸', '–', '·'];
function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1', numSuffix = '.'): string {
function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1', numSuffix = '.', bulletChars: string[] = DEFAULT_BULLET_CYCLE): string {
let levels = '';
for (let level = 1; level <= 6; level++) {
const indent = level * 0.635;
@@ -861,7 +942,7 @@ function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1'
if (ordered) {
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${numFormat}" style:num-suffix="${numSuffix}">${labelAlign}</text:list-level-style-number>`;
} else {
const ch = CELL_BULLET_CHARS[(level - 1) % CELL_BULLET_CHARS.length];
const ch = bulletChars[(level - 1) % bulletChars.length];
levels += `<text:list-level-style-bullet text:level="${level}" text:bullet-char="${ch}">${labelAlign}</text:list-level-style-bullet>`;
}
}
@@ -908,6 +989,28 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
};
let usedBulletList = false;
// Bullet cell lists with a custom marker char get their own style carrying that
// char at the list's own nesting depth (each <text:list> references its style
// directly, so only the level it sits at matters).
const bulletStyles: { name: string; chars: string[] }[] = [];
const bulletStyleByKey = new Map<string, string>();
const bulletStyleFor = (bulletChar: string | null, depth: number): string => {
const ch = bulletChar ?? defaultBulletChar(depth - 1);
if (ch === defaultBulletChar(depth - 1)) {
usedBulletList = true;
return CELL_LIST_BULLET_STYLE;
}
const key = `${ch}|${depth}`;
let name = bulletStyleByKey.get(key);
if (!name) {
name = `${CELL_LIST_BULLET_STYLE}${bulletStyleByKey.size + 1}`;
bulletStyleByKey.set(key, name);
const chars = [...DEFAULT_BULLET_CYCLE];
chars[(depth - 1) % chars.length] = ch;
bulletStyles.push({ name, chars });
}
return name;
};
// Ordered cell lists are minted one style per distinct numbering format (1. / a) /
// I. / …) so each list exports with its chosen type. The first format reuses the
// legacy CELL_LIST_NUMBER_STYLE name; further ones get a counter suffix.
@@ -927,17 +1030,16 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
// bullet/number nesting renders correctly; indent comes from nesting depth. One
// segment consumed per list item, DFS order (matching buildCellContent).
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }): string => {
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1): string => {
const isBullet = !list.ordered;
if (isBullet) usedBulletList = true;
const listStyle = isBullet ? CELL_LIST_BULLET_STYLE : numberStyleFor(list.numFormat, list.numSuffix);
const listStyle = isBullet ? bulletStyleFor(list.bulletChar, depth) : numberStyleFor(list.numFormat, list.numSuffix);
const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number';
let out = `<text:list text:style-name="${listStyle}">`;
list.items.forEach((item, idx) => {
const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : '';
const seg = segments[cur.i++] ?? '';
out += `<text:list-item${startAttr}><text:p text:style-name="${styleNameFor(paraParent, item.style)}">${seg}</text:p>`;
if (item.nested) out += buildList(item.nested, segments, cur);
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1);
out += '</text:list-item>';
});
return out + '</text:list>';
@@ -988,6 +1090,9 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${paraStyleProps(style).join(' ')}/></style:style>`,
);
if (usedBulletList) additions.push(buildCellListStyle(CELL_LIST_BULLET_STYLE, false));
for (const { name, chars } of bulletStyles) {
additions.push(buildCellListStyle(name, false, '1', '.', chars));
}
for (const { name, numFormat, numSuffix } of numberStyles) {
additions.push(buildCellListStyle(name, true, numFormat, numSuffix));
}
@@ -1205,7 +1310,8 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder, forceBold = false):
const nested = item.content?.find(x => x.type === 'bulletList' || x.type === 'orderedList');
items.push({ style: paraStyleFromAttrs(firstPara?.attrs), nested: nested ? walkList(nested) : null });
}
return { kind: 'list', ordered, start, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.', items };
const bulletChar = !ordered && typeof listNode.attrs?.bulletChar === 'string' ? (listNode.attrs.bulletChar as string) : null;
return { kind: 'list', ordered, start, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.', bulletChar, items };
};
for (const block of cell.content ?? []) {
@@ -1731,6 +1837,11 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
collectOrderedListFormats(raw, olFormats);
let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
// Rewrite odf-kit's default bullet chars into the per-level bulletChar attrs.
const blChars: (string[] | null)[] = [];
collectBulletListChars(raw, blChars);
numberedOdt = applyBulletListChars(numberedOdt, blChars);
const nestedFixes: (ListDef | null)[] = [];
collectNestedListFixes(raw, nestedFixes);
numberedOdt = applyNestedListTypes(numberedOdt, nestedFixes);
+34 -5
View File
@@ -9,6 +9,7 @@ import { hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { extensions } from '../editor/extensions';
import { columnPercents } from '../editor/extensions/tableView';
import { orderedTypeDef } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { deriveFilename } from './odt';
import globalCss from '../../styles/global.css?inline';
import editorCss from '../../styles/editor.css?inline';
@@ -121,8 +122,6 @@ function collectRuns(root: HTMLElement): Run[] {
return runs;
}
const BULLET_CHARS = ['•', '◦', '▪']; // <ul> nesting cycle (disc/circle/square)
function toRoman(n: number): string {
const map: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
let s = '';
@@ -152,11 +151,38 @@ function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
: String(n);
return body + def.numSuffix;
}
// Explicit bulletChar attr wins; otherwise the default cycle keyed by the number
// of <ul> ancestors (matching editor.css's `ul ul …` depth rules).
const explicit = parent.getAttribute('data-bullet');
if (explicit) return explicit;
let depth = 0;
for (let e: HTMLElement | null = li.parentElement; e && e !== root; e = e.parentElement) {
if (e.tagName === 'UL' || e.tagName === 'OL') depth++;
if (e.tagName === 'UL') depth++;
}
return defaultBulletChar(depth - 1);
}
// html2canvas can't paint string list-style-type values (all our <ul> markers), so
// bullets become real spans hanging left of the first line — the same markup
// relocateFloatPushedMarkers uses, which also puts the glyphs into the text layer.
function materializeBulletMarkers(root: HTMLElement): void {
for (const li of Array.from(root.querySelectorAll<HTMLLIElement>('ul > li'))) {
const cs = getComputedStyle(li);
if (cs.listStyleType === 'none') continue;
const glyph = listMarkerGlyph(li, root);
if (!glyph) continue;
li.style.listStyleType = 'none';
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3') ?? li;
const slot = document.createElement('span');
slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
const label = document.createElement('span');
label.style.cssText = `display:inline-block;white-space:pre;transform:translateX(-100%);padding-right:0.33em;color:${cs.color}`;
// Same symbol shim the editor's ::marker uses (glyphs Liberation Serif lacks).
label.style.fontFamily = `'EdenText Symbols', ${cs.fontFamily}`;
label.textContent = glyph;
slot.appendChild(label);
target.insertBefore(slot, target.firstChild);
}
return BULLET_CHARS[(depth - 1 + BULLET_CHARS.length) % BULLET_CHARS.length];
}
// html2canvas paints list markers at the item's content-box left edge, ignoring floats, so
@@ -193,7 +219,7 @@ function relocateFloatPushedMarkers(root: HTMLElement): void {
// every page (page i occupies [i*cycle, i*cycle+pageH]). Shared by the PDF download and
// the raster print path; the caller invokes cleanup() once done reading `clone`. `scale`
// is the oversampling factor @ A4 (2 ≈ 192 dpi, 3 ≈ 288 dpi).
async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<{
export async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<{
canvas: HTMLCanvasElement; clone: HTMLElement; pages: number;
pageW: number; pageH: number; cycle: number; scale: number; cleanup: () => void;
}> {
@@ -210,6 +236,9 @@ async function renderPaperToCanvas(opts: PdfOptions, scale = 2): Promise<{
try {
await document.fonts.ready;
// Bullets first: it sets list-style none on <ul> items, so the float pass
// (which skips those) only re-hangs ordered-list markers.
materializeBulletMarkers(clone);
relocateFloatPushedMarkers(clone);
const pages = Math.max(1, opts.numPages ?? Math.round((clone.offsetHeight + PAGE_GAP) / cycle));
+5 -1
View File
@@ -4,6 +4,7 @@ import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
@@ -288,7 +289,10 @@ function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
const def = ctx.styles.level(numId, ilvl);
const bullet = !def.numFmt || def.numFmt === 'bullet' || def.numFmt === 'none';
const attrs: Record<string, unknown> = {};
if (!bullet) {
if (bullet) {
const ch = bulletCharAttr(bulletCharFromDocx(def.lvlText, def.bulletFont), ilvl);
if (ch) attrs.bulletChar = ch;
} else {
const lst = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
if (lst !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = lst;
if (def.start != null && def.start > 1) attrs.start = def.start;
+5 -2
View File
@@ -27,8 +27,9 @@ export type RunProps = {
highlightFill?: string; // run-level w:shd w:fill (text highlight)
};
// A numbering level definition (numbering.xml w:lvl).
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number };
// A numbering level definition (numbering.xml w:lvl). bulletFont is the level's
// w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning).
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string };
export function wVal(el: Element): string | null {
return el.getAttributeNS(W, 'val');
@@ -162,6 +163,8 @@ export class DocxStyles {
const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; }
const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind');
if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; }
const rf = firstChild(lvl, 'rPr') && firstChild(firstChild(lvl, 'rPr')!, 'rFonts');
if (rf) { const f = rf.getAttributeNS(W, 'ascii') ?? rf.getAttributeNS(W, 'hAnsi'); if (f) def.bulletFont = f; }
levels.set(ilvl, def);
}
this.abstractLevels.set(id, levels);
+18 -1
View File
@@ -3,6 +3,7 @@ import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
@@ -788,8 +789,13 @@ 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;
const node: Node = { type: 'bulletList', content: items };
if (indent != null) node.attrs = { indent };
if (Object.keys(attrs).length) node.attrs = attrs;
return node;
}
@@ -821,6 +827,17 @@ function listLevelMarginLeftCm(levelDef: Element | null): number | null {
return null;
}
// The level def's declared font (Word-written .odt puts Wingdings/Symbol here for
// its private-use bullet chars; LibreOffice writes real Unicode chars instead).
function listLevelFontName(levelDef: Element | null): string | null {
if (!levelDef) return null;
for (const props of Array.from(levelDef.children)) {
if (props.namespaceURI !== NS.style || props.localName !== 'text-properties') continue;
return props.getAttributeNS(NS.style, 'font-name') ?? props.getAttributeNS(NS.fo, 'font-family');
}
return null;
}
function listLevelDef(listStyle: Element | null, depth: number): Element | null {
if (!listStyle) return null;
for (const child of Array.from(listStyle.children)) {
+92
View File
@@ -0,0 +1,92 @@
// Bullet-list marker characters — single source of truth for bulletList.ts (the
// `bulletChar` attr), Toolbar.svelte, editor.css, and the ODT/DOCX import/export.
// All chars here are XML-attribute-safe (no " < > &).
// Default marker per nesting depth (0-based); matches odf-kit's BULLET_CHARS, so a
// default list needs no rewrite on export.
export const DEFAULT_BULLET_CYCLE = ['•', '◦', '▪', '▸', '–', '·'];
export function defaultBulletChar(depth0: number): string {
return DEFAULT_BULLET_CYCLE[depth0 % DEFAULT_BULLET_CYCLE.length];
}
export interface BulletTypeDef {
char: string;
label: string; // tooltip in the picker grid
}
// Curated picker set (Word's bullet library), in menu order.
export const BULLET_TYPES: BulletTypeDef[] = [
{ char: '•', label: 'Disc' },
{ char: '◦', label: 'Circle' },
{ char: '▪', label: 'Square' },
{ char: '❖', label: 'Diamond' },
{ char: '➢', label: 'Arrowhead' },
{ char: '⇨', label: 'Arrow' },
{ char: '✓', label: 'Check' },
{ char: '–', label: 'Dash' },
{ char: '>', label: 'Chevron' },
];
// Attr helper: null (= inherit the default cycle) when the char is empty or equals
// the default for its depth, so round trips don't accrete explicit attrs.
export function bulletCharAttr(char: string | null | undefined, depth0: number): string | null {
if (!char) return null;
return char === defaultBulletChar(depth0) ? null : char;
}
// Word bullets are private-use codepoints (U+F0xx) in a symbol font; low byte =
// the glyph index. Unicode equivalents for the common Wingdings/Symbol bullets.
const WINGDINGS_MAP: Record<number, string> = {
0x6c: '●',
0x6e: '■',
0x75: '◆',
0x76: '❖',
0xa7: '▪',
0xa8: '□',
0xd8: '➢',
0xf0: '⇨',
0xfc: '✓',
};
const SYMBOL_MAP: Record<number, string> = {
0xb7: '•',
0xa8: '♦',
};
function symbolMapFor(font: string | null | undefined): Record<number, string> | null {
const f = (font ?? '').toLowerCase();
if (f.startsWith('wingdings')) return WINGDINGS_MAP;
if (f.startsWith('symbol')) return SYMBOL_MAP;
return null;
}
// One marker char (+ its declared font) → the Unicode char to store, or null when
// it can't be represented (unknown symbol glyph → default cycle).
function mapSymbolChar(char: string, font: string | null | undefined): string | null {
const code = char.charCodeAt(0);
const isPua = code >= 0xf000 && code <= 0xf0ff;
const map = symbolMapFor(font);
if (map) {
// Symbol fonts address glyphs by low byte whether or not the PUA offset is used.
return map[code & 0xff] ?? null;
}
if (isPua) return null;
// Word's classic hollow bullet is a Courier New "o" — render it as a real circle.
if (char === 'o' && (font ?? '').toLowerCase().startsWith('courier')) return '◦';
return char;
}
// DOCX w:lvlText + the level's w:rFonts font → Unicode char (null = default).
export function bulletCharFromDocx(lvlText: string | undefined, font: string | undefined): string | null {
if (!lvlText) return null;
return mapSymbolChar(lvlText.charAt(0), font);
}
// ODT text:bullet-char + the level def's font name → Unicode char (null = default).
// LibreOffice writes real Unicode chars (pass through); Word-written .odt keeps the
// PUA-plus-symbol-font pattern, which the maps resolve.
export function bulletCharFromOdf(ch: string | null, fontName: string | null): string | null {
if (!ch) return null;
return mapSymbolChar(ch.charAt(0), fontName);
}
+17
View File
@@ -397,6 +397,23 @@
.paper .tiptap ol[data-list-style="upper-roman"] { list-style-type: upper-roman; }
.paper .tiptap ol[data-list-style="upper-roman-paren"] { list-style-type: oledit-upper-roman-paren; }
/* Bullet markers render through the bundled symbol shim first: it holds only the
glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those come out compact and
platform-independent while every other char falls through to the text font. */
.paper .tiptap ul > li::marker {
font-family: 'EdenText Symbols', 'Liberation Serif', serif;
}
/* Default bullet markers per nesting depth — the same cycle the ODT/DOCX export
writes (DEFAULT_BULLET_CYCLE in bulletListTypes.ts). A custom `bulletChar` attr
overrides these via an inline list-style-type from bulletList.ts renderHTML. */
.paper .tiptap ul { list-style-type: "• "; }
.paper .tiptap ul ul { list-style-type: "◦ "; }
.paper .tiptap ul ul ul { list-style-type: "▪ "; }
.paper .tiptap ul ul ul ul { list-style-type: "▸ "; }
.paper .tiptap ul ul ul ul ul { list-style-type: "– "; }
.paper .tiptap ul ul ul ul ul ul { list-style-type: "· "; }
.paper .tiptap li {
margin-top: 0;
margin-bottom: 0;
+12
View File
@@ -40,6 +40,18 @@
src: url('../assets/fonts/LiberationSerif-BoldItalic.ttf') format('truetype');
}
/* Bullet-marker symbols Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), subset from DejaVu
Sans so markers render compact and identical on every platform. First in the
::marker/picker stacks; per-glyph fallback keeps all other chars in the text
font. (Bitstream Vera license — see src/assets/fonts/LICENSE-DejaVu.txt.) */
@font-face {
font-family: 'EdenText Symbols';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/EdenSymbols.woff2') format('woff2');
}
/* Metric-compatible substitutes for the common MS-Office fonts that imported
.odt files reference. Each face is keyed to the MS name but prefers a real
local copy, then a system twin, then the bundled open-source twin — so text
+21 -1
View File
@@ -41,7 +41,7 @@ describe('DOCX export → import round trip', () => {
{ type: 'paragraph', attrs: { fontSize: '22pt', textAlign: 'center' } }, // empty sized line
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', content: [li(para('one')), li(para('two'), { type: 'bulletList', content: [li(para('nested'))] })] },
{ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [li(para('one')), li(para('two'), { type: 'bulletList', attrs: { bulletChar: '➢' }, content: [li(para('nested'))] })] },
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [li(para('alpha'))] },
para([{ type: 'image', attrs: { src: PNG, width: 100, height: 80, wrap: 'left', alt: 'pic' } }]),
{ type: 'textBox', attrs: { width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }, content: [
@@ -111,6 +111,9 @@ describe('DOCX export → import round trip', () => {
const nested = top.content![1].content!.find((c) => c.type === 'bulletList')!;
expect(nested).toBeTruthy();
expect(walk(nested, 'text')[0].text).toBe('nested');
// Custom bullet chars survive via w:lvlText
expect(top.attrs?.bulletChar).toBe('❖');
expect(nested.attrs?.bulletChar).toBe('➢');
const ol = walk(doc, 'orderedList')[0];
expect(ol.attrs.listStyleType).toBe('lower-alpha');
});
@@ -181,6 +184,8 @@ describe('DOCX import of a foreign Word document', () => {
const documentXml = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Foreign Title</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>First</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Wingdings diamond</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>Courier hollow</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc><w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc></w:tr>
@@ -194,7 +199,12 @@ describe('DOCX import of a foreign Word document', () => {
</w:styles>`;
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>
<w:abstractNum w:abstractNumId="1">
<w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="&#xF076;"/><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
</w:numbering>`;
const bytes = zipSync({
@@ -214,6 +224,16 @@ describe('DOCX import of a foreign Word document', () => {
expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Arial'); // resolved from the style
});
it('maps Word symbol-font bullets and suppresses the default cycle', () => {
const bullets = walk(doc, 'bulletList');
const top = bullets.find((b) => walk(b, 'text').some((t) => t.text === 'Wingdings diamond'))!;
expect(top).toBeTruthy();
expect(top.attrs?.bulletChar).toBe('❖'); // Wingdings U+F076
const nested = walk(top, 'bulletList').find((b) => b !== top)!;
expect(walk(nested, 'text')[0].text).toBe('Courier hollow');
expect(nested.attrs?.bulletChar ?? null).toBe(null); // Courier 'o' = default ◦ at level 2
});
it('reconstructs a numbered list and a vertically-merged table', () => {
expect(walk(doc, 'orderedList').length).toBe(1);
const rows = walk(doc, 'table')[0].content!;
+23 -2
View File
@@ -91,13 +91,23 @@ const fixture: N = {
LI(P(null, T('shifted bullet a'))),
LI(P(null, T('shifted bullet b'))),
] },
// Custom bullet chars: ❖ at level 1; ✓ on the DFS-first nested list (drives the
// L# level-2 char); a default nested sibling (must NL-mint back to ◦).
{ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [
LI(P(null, T('diamond one')), { type: 'bulletList', attrs: { bulletChar: '✓' }, content: [
LI(P(null, T('check nested'))),
] }),
LI(P(null, T('diamond two')), { type: 'bulletList', content: [
LI(P(null, T('default nested'))),
] }),
] },
H({ level: 2 }, T('Un', { type: 'textStyle', attrs: { fontWeight: 'normal' } }), T('bolded')),
{ type: 'table', content: [
{ type: 'tableRow', attrs: { rowHeight: 60 }, content: [
CELL([120],
H({ level: 3 }, T('Cell head')),
P(null, T('cell para')),
{ type: 'bulletList', content: [
{ type: 'bulletList', attrs: { bulletChar: '➢' }, content: [
LI(P(null, T('cell bullet'))),
LI(P(null, T('with nested')), { type: 'orderedList', attrs: { listStyleType: 'decimal-paren' }, content: [
LI(P(null, T('cell nested 1'))),
@@ -401,6 +411,11 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
<text:list-level-style-number text:level="1" style:num-format="i" style:num-suffix=")"/>
<text:list-level-style-bullet text:level="2" text:bullet-char="•"/>
</text:list-style>
<text:list-style style:name="L2">
<text:list-level-style-bullet text:level="1" text:bullet-char="&#xF0D8;">
<style:text-properties style:font-name="Wingdings"/>
</text:list-level-style-bullet>
</text:list-style>
<style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="5cm"/></style:style>
<style:style style:name="co2" style:family="table-column"><style:table-column-properties style:column-width="10cm"/></style:style>
<style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:min-row-height="2cm"/></style:style>
@@ -416,6 +431,9 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
<text:list><text:list-item><text:p>sub bullet</text:p></text:list-item></text:list>
</text:list-item>
</text:list>
<text:list text:style-name="L2">
<text:list-item><text:p>wingdings arrow</text:p></text:list-item>
</text:list>
<table:table>
<table:table-column table:style-name="co1"/>
<table:table-column table:style-name="co2" table:number-columns-repeated="2"/>
@@ -475,7 +493,10 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
check('foreign: list → lower-roman-paren, start 3', list.type === 'orderedList' && list.attrs?.listStyleType === 'lower-roman-paren' && list.attrs?.start === 3, list.attrs);
check('foreign: level-2 def → nested bulletList', list.content![0].content![1]?.type === 'bulletList', list.content![0]);
const table = c[6];
const wdList = c.find((n: N) => n.type === 'bulletList' && n.content?.[0]?.content?.[0]?.content?.[0]?.text === 'wingdings arrow');
check('foreign: Wingdings PUA bullet-char → ➢', wdList?.attrs?.bulletChar === '➢', wdList?.attrs);
const table = c[7];
check('foreign: table present', table.type === 'table');
const [hdr, row] = table.content!;
check('foreign: header row → tableHeader ×3', hdr.content!.length === 3 && hdr.content!.every((cell: N) => cell.type === 'tableHeader'), hdr.content!.map((x: N) => x.type));
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_BULLET_CYCLE,
defaultBulletChar,
bulletCharAttr,
bulletCharFromDocx,
bulletCharFromOdf,
} from '../../src/lib/utils/bulletListTypes';
describe('defaultBulletChar', () => {
it('cycles • ◦ ▪ ▸ – · and wraps past depth 6', () => {
expect(DEFAULT_BULLET_CYCLE).toEqual(['•', '◦', '▪', '▸', '–', '·']);
expect(defaultBulletChar(0)).toBe('•');
expect(defaultBulletChar(5)).toBe('·');
expect(defaultBulletChar(6)).toBe('•');
});
});
describe('bulletCharAttr (default suppression)', () => {
it('suppresses the cycle char at its own depth', () => {
expect(bulletCharAttr('•', 0)).toBeNull();
expect(bulletCharAttr('◦', 1)).toBeNull();
expect(bulletCharAttr('▪', 2)).toBeNull();
});
it('keeps a cycle char at a foreign depth', () => {
expect(bulletCharAttr('•', 1)).toBe('•');
expect(bulletCharAttr('▪', 0)).toBe('▪');
});
it('keeps non-default chars and drops empty ones', () => {
expect(bulletCharAttr('❖', 0)).toBe('❖');
expect(bulletCharAttr(null, 0)).toBeNull();
expect(bulletCharAttr('', 3)).toBeNull();
});
});
describe('bulletCharFromDocx (Word numbering.xml patterns)', () => {
it('maps the Wingdings bullets from list1.docx', () => {
expect(bulletCharFromDocx('', 'Wingdings')).toBe('❖');
expect(bulletCharFromDocx('', 'Wingdings')).toBe('➢');
expect(bulletCharFromDocx('', 'Wingdings')).toBe('▪');
expect(bulletCharFromDocx('', 'Wingdings')).toBe('⇨');
expect(bulletCharFromDocx('', 'Wingdings')).toBe('✓');
});
it('maps the Symbol bullets', () => {
expect(bulletCharFromDocx('', 'Symbol')).toBe('•');
expect(bulletCharFromDocx('', 'Symbol')).toBe('♦');
});
it('resolves symbol fonts addressed without the PUA offset', () => {
expect(bulletCharFromDocx('v', 'Wingdings')).toBe('❖');
});
it("maps Courier New 'o' (Word's hollow bullet) to ◦", () => {
expect(bulletCharFromDocx('o', 'Courier New')).toBe('◦');
});
it('passes literal chars in text fonts through', () => {
expect(bulletCharFromDocx('>', 'Aptos')).toBe('>');
expect(bulletCharFromDocx('-', undefined)).toBe('-');
});
it('returns null for unmapped symbol glyphs and PUA chars in unknown fonts', () => {
expect(bulletCharFromDocx('', 'Wingdings')).toBeNull();
expect(bulletCharFromDocx('', 'SomeDingbats')).toBeNull();
expect(bulletCharFromDocx('', 'Wingdings')).toBeNull();
expect(bulletCharFromDocx(undefined, 'Wingdings')).toBeNull();
});
it('uses only the first char of a multi-char lvlText', () => {
expect(bulletCharFromDocx('x', 'Wingdings')).toBe('➢');
});
});
describe('bulletCharFromOdf', () => {
it('passes real Unicode chars (LibreOffice output) through', () => {
expect(bulletCharFromOdf('➢', null)).toBe('➢');
expect(bulletCharFromOdf('•', 'OpenSymbol')).toBe('•');
});
it('resolves PUA chars via the declared symbol font', () => {
expect(bulletCharFromOdf('', 'Wingdings')).toBe('➢');
expect(bulletCharFromOdf('', 'Symbol')).toBe('•');
});
it('returns null for missing or unresolvable chars', () => {
expect(bulletCharFromOdf(null, 'Wingdings')).toBeNull();
expect(bulletCharFromOdf('', null)).toBeNull();
});
});
+4 -1
View File
@@ -33,7 +33,7 @@ const fixture = {
),
para([text('a\tb')]),
para([text('line1'), { type: 'hardBreak' }, text('line2')]),
{ type: 'bulletList', attrs: { indent: 0 }, content: [
{ type: 'bulletList', attrs: { indent: 0, bulletChar: '❖' }, content: [
li(para('one')),
li(para('two'), { type: 'bulletList', content: [li(para('nested'))] }),
] },
@@ -111,6 +111,9 @@ describe('buildDocx', () => {
const numXml = strFromU8(files['word/numbering.xml']);
expect(numXml).toContain('w:val="bullet"');
expect(numXml).toContain('w:val="lowerLetter"');
// Custom bullet char → level 0 lvlText; nested default level keeps the cycle (◦)
expect(numXml).toMatch(/w:lvlText[^>]*w:val="❖"/);
expect(numXml).toMatch(/w:lvlText[^>]*w:val="◦"/);
});
it('emits a fixed-layout table with merged cells and shading', () => {