feat: heading level 6, with LibreOffice's italic on 4 and 6

LibreOffice's style pool, probed: Heading 6 is 12pt bold italic and
Heading 4 italic too. MAX_HEADING_LEVEL drives the extension registry,
the shortcut plugin, the TOC depth and both importers' yardstick, so
only the constant table and the CSS selector lists had to grow.
Bookmarks/cross-references move to Implemented — they already are.
This commit is contained in:
Steffen Becker committed 2026-08-14 00:38:49 +02:00
1 parent f7d9d38204
commit 71c7b53281
18 files changed
+76 -57

No files matched your search

+3 -3
View File
@@ -27,7 +27,7 @@
- Font family picker (lists only fonts installed on the machine; remembers recently used fonts)
- Font size
- Font color and highlight color (color picker with custom colors)
- Headings H1–H5, including un-bolding a heading
- Headings H1–H6 with LibreOffice's sizes (18/16/14/13/12/12 pt, levels 4 and 6 italic), including un-bolding a heading
**Styles**
- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–5 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12 pt headings)
@@ -71,6 +71,7 @@
- Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr`
- Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (`draw:object` + `Formula{n}/content.xml`, our LaTeX kept in the MathML `annotation`) and as Word's `m:oMath`. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- Bookmarks and cross-references: name a selection, then insert a reference to it (its text, its number or its page); the reference follows the text it points at. Round-trips to ODF `text:bookmark-start`/`text:bookmark-ref` and Word's `Bookmark` + `REF`/`PAGEREF` fields
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
- Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too)
@@ -118,7 +119,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Content an imported document loses**
- Comments / annotations: dropped on import (`office:annotation`, `w:comment`)
- Track changes / revisions: no recording, no accept/reject, no author colors (`text:tracked-changes`, `w:ins`/`w:del`); imported revisions are flattened to their current state
- Bookmarks and cross-references: no anchor, no reference field ("see chapter 3 on page 7"), none survive an import — this also blocks internal hyperlinks
- Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application
- Shapes beyond rect / round-rect / ellipse: lines, arrows, connectors, polygons, freeform, and rotated shape text (dropped on import with a warning)
- Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation
@@ -129,7 +129,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
- Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not
- Page numbering options: the page field is decimal-only — no start value, no roman/alpha format, no restart per section
- Document properties: title, author, subject, keywords. No `meta.xml` is written and the DOCX creator is hard-coded, so an exported file carries no authorship
- Heading level H6 (clamped to H5; Word and LibreOffice go to 10)
- Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`)
- Alphabetical index and bibliography
- Line numbering (LibreOffice Tools ▸ Line numbering / Word Layout ▸ Line numbers)
- Section-level page **size and orientation**: only the margins are per section. A file whose sections disagree on the page size keeps the last one's
+3 -2
View File
@@ -10,6 +10,7 @@
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../editor/extensions/characterStyle';
import { DEFAULT_STYLE, headingStyleName, resolveStyle, styleOrder, type StyleFamily } from '../styles/styleSheet';
import { MAX_HEADING_LEVEL } from '../export/odt';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
@@ -53,7 +54,7 @@
Standard: s.default, Title: s.docTitle, Subtitle: s.subtitle, Quotations: s.quote,
'Heading 1': t().toolbar.heading1, 'Heading 2': t().toolbar.heading2,
'Heading 3': t().toolbar.heading3, 'Heading 4': t().toolbar.heading4,
'Heading 5': t().toolbar.heading5,
'Heading 5': t().toolbar.heading5, 'Heading 6': t().toolbar.heading6,
} as Record<string, string>)[name] ?? name;
}
@@ -79,7 +80,7 @@
if (name === DEFAULT_STYLE) return shortcutHint('styleStandard');
const level = sheet.paragraph[name]?.outlineLevel;
// The keys apply the built-in heading styles, not any style at that level.
if (!level || level > 5 || name !== headingStyleName(level)) return undefined;
if (!level || level > MAX_HEADING_LEVEL || name !== headingStyleName(level)) return undefined;
return shortcutHint(`heading${level}` as ShortcutId);
}
@@ -5,6 +5,7 @@
import { blockStyleName } from '../../../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../../../editor/extensions/characterStyle';
import { DEFAULT_STYLE, headingStyleName, resolveStyle, styleOrder, type StyleFamily } from '../../../styles/styleSheet';
import { MAX_HEADING_LEVEL } from '../../../export/odt';
import { styleSheet } from '../../../styles/sheet.svelte';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts';
@@ -38,7 +39,7 @@
Standard: s.default, Title: s.docTitle, Subtitle: s.subtitle, Quotations: s.quote,
'Heading 1': t().toolbar.heading1, 'Heading 2': t().toolbar.heading2,
'Heading 3': t().toolbar.heading3, 'Heading 4': t().toolbar.heading4,
'Heading 5': t().toolbar.heading5,
'Heading 5': t().toolbar.heading5, 'Heading 6': t().toolbar.heading6,
} as Record<string, string>)[name] ?? name;
}
@@ -46,7 +47,7 @@
function styleShortcut(name: string): string | undefined {
if (name === DEFAULT_STYLE) return shortcutHint('styleStandard');
const level = sheet.paragraph[name]?.outlineLevel;
if (!level || level > 5 || name !== headingStyleName(level)) return undefined;
if (!level || level > MAX_HEADING_LEVEL || name !== headingStyleName(level)) return undefined;
return shortcutHint(`heading${level}` as ShortcutId);
}
@@ -6,6 +6,7 @@
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
import { HEADING_LEVELS } from '../../../export/odt';
let { editor, tick, hfActive = null, onNoteOptions }: {
editor: Editor | null;
@@ -24,7 +25,7 @@
});
return found;
});
const LEVELS = [1, 2, 3, 4, 5];
const LEVELS = HEADING_LEVELS;
function setMaxLevel(level: number) {
closeMenu();
+2 -2
View File
@@ -23,8 +23,8 @@ the tooltips (via `withShortcut`, which localizes Ctrl/Shift/Alt and swaps in
NBSP, soft hyphen).
- **`Mod-Alt-<digit>` can't go through the keymap**: Windows reads Ctrl+Alt as AltGr, so
`event.key` is layout-dependent (German AltGr+2 = `²`) and prosemirror-keymap skips its
keyCode fallback for exactly that modifier pair. Those six run in `addProseMirrorPlugins`
off `event.code` (`Digit0`–`Digit5`) instead.
keyCode fallback for exactly that modifier pair. Those seven run in `addProseMirrorPlugins`
off `event.code` (`Digit0`–`Digit6`) instead.
- Deliberate resolutions: `Mod-m` stays LibreOffice's *clear formatting* (Word's
increase-indent is Tab); `Ctrl+1/2/5` are Word's **line spacing**, so headings are
Ctrl+Alt+N only; `Mod-Shift-s` stays strikethrough, so there's no Save-As key;
+3 -2
View File
@@ -13,7 +13,7 @@ import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
import { FontWeight } from './extensions/fontWeight';
import { FontColor } from './extensions/fontColor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import Heading, { type Level } from '@tiptap/extension-heading';
import { BulletList } from './extensions/bulletList';
import { OrderedList } from './extensions/orderedList';
import ListItem from '@tiptap/extension-list-item';
@@ -57,6 +57,7 @@ import { ColumnsFlow } from './extensions/columnsFlow';
import { TableOfContents } from './extensions/tableOfContents';
import { Note, NoteRef, NoteSection, Notes } from './extensions/notes';
import { Shortcuts } from './extensions/shortcuts';
import { HEADING_LEVELS } from '../export/odt';
import { styleSheet } from '../styles/sheet.svelte';
import { noteSettings } from '../storage/notes.svelte';
@@ -135,7 +136,7 @@ export const extensions = [
PageBreak,
Indent,
TabStops,
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
Heading.configure({ levels: HEADING_LEVELS as Level[] }),
BulletList,
OrderedList,
ListItem,
+2 -1
View File
@@ -4,6 +4,7 @@ import type { Mark } from '@tiptap/pm/model';
import { DEFAULT_SHORTCUTS, type ShortcutId } from '../shortcuts';
import { FONT_SIZES, blockFontSize, coversWholeBlock } from '../../utils/fontSize';
import { headingStyleName } from '../../styles/styleSheet';
import { MAX_HEADING_LEVEL } from '../../export/odt';
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../../utils/dateTime';
// The Word/LibreOffice shortcuts that aren't already a TipTap default, bound from
@@ -104,7 +105,7 @@ export const Shortcuts = Extension.create<{ body: boolean }>({
// skips its keyCode fallback for exactly this combination.
handleKeyDown(_view, event) {
if (!(event.ctrlKey || event.metaKey) || !event.altKey || event.shiftKey) return false;
const digit = /^Digit([0-5])$/.exec(event.code);
const digit = new RegExp(`^Digit([0-${MAX_HEADING_LEVEL}])$`).exec(event.code);
if (!digit) return false;
const level = Number(digit[1]);
event.preventDefault();
+1
View File
@@ -27,6 +27,7 @@ export const DEFAULT_SHORTCUTS = {
heading3: 'Mod-Alt-3',
heading4: 'Mod-Alt-4',
heading5: 'Mod-Alt-5',
heading6: 'Mod-Alt-6',
styleStandard: 'Mod-Shift-n',
indentMore: 'Tab',
indentLess: 'Shift-Tab',
+2 -2
View File
@@ -5,10 +5,10 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
## Heading defaults (`HEADING_STYLE_OVERRIDES`, `export/odt.ts`)
- **Headings** follow LibreOffice too (`HEADING_STYLE_OVERRIDES`): 18 / 16 / 14 / 13 / 12pt with
- **Headings** follow LibreOffice too (`HEADING_STYLE_OVERRIDES`): 18 / 16 / 14 / 13 / 12 / 12pt (levels 4 and 6 italic) with
the Heading style's 0.423cm/0.212cm margins on every level, bold, and **sans** (`HEADING_FONT` = Arial; on screen the bundled
`@font-face` maps it to Liberation Sans, metric-identical, mirroring Liberation Serif →
Times New Roman for the body). Levels 1–5 (`MAX_HEADING_LEVEL` = `HEADING_STYLE_OVERRIDES.length`);
Times New Roman for the body). Levels 1–6 (`MAX_HEADING_LEVEL` = `HEADING_STYLE_OVERRIDES.length`);
both constants live in `export/odt.ts` and feed the importers, the TOC, the DOCX heading styles,
`utils/fontSize.ts`, and `editor.css` (kept in sync by `tests/unit/font-size-display.test.ts`).
The importers no longer override a file's own heading formatting: they only suppress values
+4 -2
View File
@@ -187,12 +187,14 @@ const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
// LibreOffice's heading defaults, shown in the editor (editor.css) and written over
// odf-kit's Heading_20_N styles on export: its sizes plus the Heading style's margins.
// The importers use them as the fallback yardstick when a file declares no style.
export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
// Levels 4 and 6 are italic, as they are in LibreOffice (probed against its style pool).
export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string; italic?: true }[] = [
{ name: 'Heading_20_1', fontSize: '18pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_4', fontSize: '13pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_4', fontSize: '13pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
{ name: 'Heading_20_5', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_6', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
];
// Headings are sans (LibreOffice's Heading style). On screen the bundled 'Arial'
+1
View File
@@ -45,6 +45,7 @@ const de: Messages = {
heading3: 'Überschrift 3',
heading4: 'Überschrift 4',
heading5: 'Überschrift 5',
heading6: 'Überschrift 6',
styles: {
title: 'Formatvorlagen',
default: 'Standard',
+1
View File
@@ -47,6 +47,7 @@ const en = {
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
heading6: 'Heading 6',
// Style gallery (Word/LibreOffice "Styles"): presets of direct formatting.
styles: {
title: 'Styles',
+2 -1
View File
@@ -102,6 +102,7 @@ const BODY_FONT_SIZE_PT = 12;
// Rounded to half points: that is all Word can store, so it is what our own export
// writes and what an imported heading must be compared against.
const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => Math.round(lengthToPt(h.fontSize)! * 2) / 2);
const HEADING_ITALIC = HEADING_STYLE_OVERRIDES.map((h) => h.italic === true);
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
// Headings render sans (HEADING_FONT); Word writes Arial, LibreOffice Liberation Sans.
const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']);
@@ -661,7 +662,7 @@ function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDef
boldByDefault: baseRun.bold ?? (headingLevel != null || boldByDefault),
fonts,
color: hexColor(baseRun.color) ?? '#000000',
italic: !!baseRun.italic,
italic: baseRun.italic ?? (headingLevel != null && HEADING_ITALIC[headingLevel - 1]),
underline: !!baseRun.underline,
strike: !!baseRun.strike,
caps: baseRun.caps || null,
+2 -1
View File
@@ -538,6 +538,7 @@ const HEADING_DEFAULTS = HEADING_STYLE_OVERRIDES.map(h => ({
fontSizePt: lengthToPt(h.fontSize)!,
marginTopPt: lengthToPt(h.marginTop)!,
marginBottomPt: lengthToPt(h.marginBottom)!,
italic: h.italic === true,
}));
// The editor's on-screen default (Liberation Serif) and what the export declares
@@ -1092,7 +1093,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
boldByDefault: headingLevel != null || boldByDefault,
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
color: '#000000',
italic: false,
italic: hdef ? hdef.italic : false,
underline: false,
strike: false,
caps: null,
+2 -2
View File
@@ -8,7 +8,7 @@ Three families: paragraph and character styles here, **table styles in
LibreOffice's model: a style has a name, a **parent** it inherits from, a follow-on style, and two
property groups (`ParaProps` layout / `TextProps` text). `styleSheet.ts` is framework-free — the
built-ins (Standard → Heading → Heading 1–5 / Title / Subtitle, plus Quotations), `resolveStyle`
built-ins (Standard → Heading → Heading 1–6 / Title / Subtitle, plus Quotations), `resolveStyle`
(parent chain root-first, nearest wins, cycle-safe), `styleOrder`, and `styleCss`. `sheet.svelte.ts`
holds it as a reactive singleton persisted to `edentext-styles` (same shape as `i18n.svelte.ts`).
@@ -35,7 +35,7 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as `
rule for the list item carrying the block** (`li:has(> …)`): a list marker inherits the item's
own font, never its paragraph's, so without it the number renders in the editor default while
the text it labels follows the style (see `listMarker.ts`). `editor.css` only keeps
a neutral `font: inherit; margin: 0` reset for `h1`–`h5`; all heading typography comes from the
a neutral `font: inherit; margin: 0` reset for `h1`–`h6`; all heading typography comes from the
styles. Inline attrs/marks still win — the Word/LO precedence.
- **ODF I/O is style-aware.** *Export*: `buildOdt(…, styles)` takes the sheet;
`applyNamedStyles` (inside `rewriteStylesXml`) writes every built-in plus the user styles the
+4 -3
View File
@@ -70,10 +70,11 @@ const BUILTINS: Style[] = [
para: { spaceBefore: 0, spaceAfter: 0 }, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
{ name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Liberation Sans', bold: true } },
...[18, 16, 14, 13, 12].map((size, i) => ({
// Sizes and the italic on 4 and 6 are LibreOffice's; the margins come from the Heading parent.
...([[18], [16], [14], [13, true], [12], [12, true]] as [number, boolean?][]).map(([size, italic], i) => ({
name: `Heading ${i + 1}`, parent: HEADING_PARENT, next: DEFAULT_STYLE,
outlineLevel: i + 1, builtin: true,
para: {}, text: { fontSizePt: size },
para: {}, text: italic ? { fontSizePt: size, italic: true } : { fontSizePt: size },
})),
{ name: 'Title', parent: HEADING_PARENT, next: DEFAULT_STYLE, builtin: true,
para: { textAlign: 'center' }, text: { fontSizePt: 28 } },
@@ -92,7 +93,7 @@ const CHAR_BUILTINS: Style[] = [
// Bumped whenever the built-in definitions change: a stored sheet from an older version
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
export const STYLE_SHEET_VERSION = 7;
export const STYLE_SHEET_VERSION = 8;
// A persisted sheet merged onto the current built-ins. Same version: stored entries win
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
+37 -31
View File
@@ -588,7 +588,8 @@
.paper .tiptap h2,
.paper .tiptap h3,
.paper .tiptap h4,
.paper .tiptap h5 {
.paper .tiptap h5,
.paper .tiptap h6 {
/* Neutral base: font, size and margins come from the named paragraph styles
(styles/styleSheet.ts → the #document-styles element Editor.svelte writes). */
font: inherit;
@@ -603,10 +604,10 @@
inherits: false;
initial-value: 0;
}
.paper .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title, .toc-entry) {
.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, .toc, .toc-title, .toc-entry) {
padding-top: var(--space-before);
}
.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title, .toc-entry) {
.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, h6, .toc, .toc-title, .toc-entry) {
padding-top: 0;
margin-top: var(--space-before);
}
@@ -620,14 +621,14 @@
/* The line box: the paragraph's spacing factor times its font's natural line height.
Either half may come from the style rule or, nearer, from the block's own attrs
(lineHeight.ts / blockFontSize.ts) — multiplying them here is what keeps both. */
.paper .tiptap :is(p, h1, h2, h3, h4, h5, li, .toc-entry) {
.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, li, .toc-entry) {
line-height: calc(var(--line-factor) * var(--natural-line));
}
/* A spacing above single puts all of its extra leading *below* each line (measured in
LibreOffice); CSS splits it half above. Shifting the block up by that half draws its
lines where LibreOffice does and leaves the flow — so pagination — untouched. */
.paper .tiptap :is(p, h1, h2, h3, h4, h5) {
.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6) {
position: relative;
top: calc((1 - var(--line-factor)) * var(--natural-line) * 0.5em);
}
@@ -654,11 +655,11 @@
/* Out of flow, so the marker cannot touch the line it labels — in flow it grew every
list line by a pixel, which cost the thesis fixture a page. `top: 0` seats it on the
first line, since the pseudo takes the block's own line-height. */
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5) {
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6) {
position: relative;
}
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5)::before {
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5, h6)::before {
position: absolute;
top: 0;
left: -0.635cm;
@@ -675,7 +676,7 @@
/* A label set against the far end of the hanging indent grows into the margin instead
of into the text — what keeps Word's Roman numerals off the words they label. */
.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5)::before {
.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before {
left: auto;
right: calc(100% + 0.635cm);
}
@@ -683,21 +684,21 @@
/* Ordered numbering, keyed by data-eff-list-style — the effective numbering
orderedList.ts's plugin resolves per <ol> from its attr + ancestor chain. The
undecorated <ol> is the plain decimal fallback and must come first. */
.paper .tiptap ol > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-roman"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-roman) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-roman"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-roman) ") "; }
.paper .tiptap ol > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, decimal) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, lower-roman) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-roman"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: counter(list-item, upper-roman) ") "; }
/* Legal/outline numbering (1., 1.1., 1.2.1. …): the plugin tags the multilevel list and
every attr-less member of its chain with data-eff-list-style="multilevel". */
.paper .tiptap ol[data-eff-list-style="multilevel"] > li > :is(p, h1, h2, h3, h4, h5)::before {
.paper .tiptap ol[data-eff-list-style="multilevel"] > li > :is(p, h1, h2, h3, h4, h5, h6)::before {
content: counters(list-item, ".") ". ";
}
@@ -705,25 +706,25 @@
(DEFAULT_BULLET_CYCLE in bulletListTypes.ts). The bundled symbol shim comes first: it
holds only the glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those stay compact and
platform-independent while every other char falls through to the text font. */
.paper .tiptap ul > li > :is(p, h1, h2, h3, h4, h5)::before {
.paper .tiptap ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before {
content: '• ';
font-family: 'EdenText Symbols', var(--marker-family, var(--font-serif)), serif;
}
.paper .tiptap ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '◦ '; }
.paper .tiptap ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '▪ '; }
.paper .tiptap ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '▸ '; }
.paper .tiptap ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '– '; }
.paper .tiptap ul ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '· '; }
.paper .tiptap ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '◦ '; }
.paper .tiptap ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '▪ '; }
.paper .tiptap ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '▸ '; }
.paper .tiptap ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '– '; }
.paper .tiptap ul ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5, h6)::before { content: '· '; }
/* A list's own `bulletChar` beats the cycle at any depth — an attribute outranks the
descendant chain. A nested list without one keeps its depth's char, which is what both
exports write, even though --bullet inherits into it. */
.paper .tiptap ul[data-bullet] > li > :is(p, h1, h2, h3, h4, h5)::before {
.paper .tiptap ul[data-bullet] > li > :is(p, h1, h2, h3, h4, h5, h6)::before {
content: var(--bullet) ' ';
}
/* The PDF clone (the .paper itself) replaces every marker with a real span (pdf.ts). */
.paper.pdf-list-markers li > :is(p, h1, h2, h3, h4, h5)::before {
.paper.pdf-list-markers li > :is(p, h1, h2, h3, h4, h5, h6)::before {
content: none;
}
@@ -886,7 +887,8 @@
.paper .tiptap h2::after,
.paper .tiptap h3::after,
.paper .tiptap h4::after,
.paper .tiptap h5::after {
.paper .tiptap h5::after,
.paper .tiptap h6::after {
content: '\00A0¶';
position: absolute;
display: inline-block;
@@ -910,7 +912,8 @@
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
.paper.show-formatting-marks .tiptap h5::after,
.paper.show-formatting-marks .tiptap h6::after {
color: var(--color-primary);
}
@@ -929,6 +932,7 @@
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h6:has(> br.ProseMirror-trailingBreak),
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak) {
position: relative;
}
@@ -939,6 +943,7 @@
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h6:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak)::after {
/* The static position would land past the BR, a line below the block; name the last
line instead — for an empty block that is also its first. */
@@ -1044,7 +1049,8 @@
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
.paper.show-formatting-marks .tiptap h5::after,
.paper.show-formatting-marks .tiptap h6::after {
color: transparent;
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { getSchema } from '@tiptap/core';
import { Node as PMNode } from '@tiptap/pm/model';
import { buildOdt } from '../src/lib/export/odt';
import { buildOdt, MAX_HEADING_LEVEL } from '../src/lib/export/odt';
import { importOdt } from '../src/lib/import/odt';
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
@@ -1516,7 +1516,7 @@ describe('Leg 5: table of contents (text:table-of-content)', () => {
const bytes = await buildOdt(tocDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
check('source spans all heading levels', content.includes('<text:table-of-content-source text:outline-level="5"'));
check('source spans all heading levels', content.includes(`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}"`));
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
check('ampersand in entry text is escaped', content.includes('Background &amp; Aims'));