feat: bullet symbols
This commit is contained in:
1 parent
d242a515cb
commit
3b7e7606bb
20 files changed
+735
-64
No files matched your search
@@ -30,6 +30,7 @@
|
||||
- Line spacing and paragraph spacing (space before / after)
|
||||
- Increase / decrease indent (paragraphs and lists)
|
||||
- Bulleted and ordered lists — nested, with multiple numbering styles (decimal, alpha, roman; `.` or `)` suffix) and whole-list indent; Tab / Shift-Tab to nest / un-nest
|
||||
- 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
@@ -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 & small, but problems with tables</span>
|
||||
<span class="theme-option-hint">Sharp, but problems with tables</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Binary file not shown.
@@ -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'.
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=""/><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
@@ -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="">
|
||||
<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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user