feat: an index the file writes without page numbers keeps its rows text-only

Word's TOC \n and an ODF entry template naming no index-entry-page-number both
say the rows are text alone; we drew leaders and live numbers over them either
way. Carried as the node's own pageNumbers attr, off only — the node view had
the page-less row already, for the bibliography. The exports write the switch
back out: \n over the whole level range, and the ODF template drops the tab
stop with the number, which is the pair LibreOffice's dialog switches together.
This commit is contained in:
Steffen Becker committed 2026-08-16 23:49:53 +02:00
1 parent 8a9b391d76
commit ac5b05e0fe
8 files changed
+98 -12

No files matched your search

+2 -2
View File
@@ -86,7 +86,7 @@ First public release, deployed on GitHub Pages.
- Special characters picker
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
- Page numbering options (Layout ▸ Page numbers): the five formats both word processors offer (1 / i / I / a / A) and a start value. Round-trips to ODF `style:num-format` on the page layout plus `style:page-number` on the first paragraph — where LibreOffice keeps the start, ODF having no document-level one — and to Word's `w:pgNumType`
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field. An index the file writes **without** page numbers (Word's `TOC \n`, an ODF entry template naming no `<text:index-entry-page-number/>`) keeps its rows text-only, leader and all
- Alphabetical index (References ▸ Index entry, then the Index in the same menu): mark a word — under a key of its own where the index should file it elsewhere — and the index lists every term once, sorted case-insensitively, with all the pages it was marked on. Round-trips as ODF `text:alphabetical-index-mark` + `text:alphabetical-index` (LibreOffice reads both back unchanged) and as Word's `XE` and `INDEX` fields
- Bibliography (References ▸ Citation, then the Bibliography in the index menu): a citation carries its whole source record — short name, type and its fields — so the document needs no database beside it, and the list prints one row per source cited, in document order and without page numbers, as both word processors do. Round-trips as ODF `text:bibliography-mark` + `text:bibliography` (LibreOffice reads every field back and regenerates the list from our entry templates) and as Word's `CITATION` and `BIBLIOGRAPHY` fields over a `b:Sources` custom-XML part — which LibreOffice's own Word export drops, so the record survives our leg and not its
- Section page setup: a section carries its own paper (Layout ▸ Orientation ▸ This section), so a wide table gets its landscape page amid portrait ones. The editor lays it out — the page grid is a table of per-page boxes rather than one repeating cycle, and every page below a section moves with it — and it round-trips as an ODF page layout of its own per master page and a `w:pgSz` per `w:sectPr`
@@ -167,7 +167,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15.
**Content an imported document loses**
- 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
- Two drawings still drop with the "Drawings were removed" warning: a **text box in the header/footer** (the zone is a one-paragraph document, so no block node fits in it — whatever the box carried, a letterhead logo included, goes with it) and a **shape group** (ODF `draw:g`: unwrapping it would anchor every child on its own, and a box cannot be put at a free point — see the limitation below)
- A table of contents is **regenerated** on load, never read from the field's cached rows — so an index a producer built with other settings comes back with every heading in the document, dot leaders and live page numbers. The one switch worth reading is Word's `TOC \n` / an ODF entry template with no `<text:index-entry-page-number/>`: an index that deliberately shows no page numbers is a layout, not a stale cache
- A table of contents is **regenerated** on load, never read from the field's cached rows — so an index whose producer left headings out of its cache comes back listing every heading in the document. Reading the rows instead is the opposite of a live index; what the file says *about* the rows (depth, leader, tab stop, entry styles, page numbers) is read
- A drawing tool: a freeform, a polygon or a connector **imports, draws and saves** (see below), but there is no way to author one here. A Word connector preset (`bentConnector3`) is also still dropped — Word resolves that geometry and writes no path for it
**Missing while writing**
+1 -1
View File
@@ -39,7 +39,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`outline.ts`** — the document's chapters for `NavigatorPane.svelte`: every **top-level** heading owning the blocks up to the next heading of its level or above (one inside a cell or a text box is not a chapter — moving it would move it out of its container). The two operations are pure functions the commands only apply: `chapterMove` returns the range to lift and where to put it back — the insert position for a move down is already corrected for the delete — and `chapterLevels` the headings a promote/demote rewrites, null when any of the subtree would leave 1…`maxLevel`. A move swaps with the adjacent chapter at the **same** level and stops at a heading above it, so a subheading never reparents itself; the level shift rewrites `styleName` too, or the paragraph would keep the old heading's look.
- **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale.
- **`bibliographyEntry.ts`** — one citation, an inline **atom** carrying the whole source record (`identifier`, `type`, and every remaining ODF field in one `fields` record, so a file's fields we have no UI for still round-trip). ODF puts that record on the mark itself, which is what lets the document travel without a database; Word keeps it in a custom-XML part and cites it by tag, so the same attrs feed both. The **citation style** (`utils/citationStyle.ts` — LibreOffice's cite-by-key, numbered, APA, MLA, Chicago) rides the bibliography index's node, which is where both formats keep it: a row is a token list, exactly what an ODF entry template is, so LibreOffice regenerates the same rows (it fills the types the document does not cite with its own default, so the import takes the first template reading as anything but `key`). Word gets the style as `b:Sources StyleName`. Each citation's label is derived, kept true by an `appendTransaction` off the undo stack like the caption numbers. `bibliographyRows` is the list: one row per source, **in document order** — LibreOffice's own default is document position, not alphabetical — reading "key: author, title, year", the shape its entry templates produce. `DOCX_SOURCE_TYPE`/`DOCX_BIB_FIELD` map onto Word's smaller set, and the fields it has no slot for survive only the ODF leg.
- **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family with **no page number and no leader** — a bibliography row is the source and nothing else, which is how both word processors print one.
- **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family that **never** has a page number or a leader — a bibliography row is the source and nothing else, which is how both word processors print one. Any other family drops both where the file says so (`pageNumbers: false` — Word's `TOC \n`, an ODF entry template naming no `<text:index-entry-page-number/>`; the export writes the stop out with the number, which is the pair LibreOffice's own dialog switches together).
- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go).
- **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
+9 -1
View File
@@ -98,6 +98,13 @@ export const TableOfContents = Node.create({
parseHTML: el => Number((el as HTMLElement).getAttribute('data-toc-tab')) || null,
renderHTML: attrs => (attrs.tabPosCm ? { 'data-toc-tab': String(attrs.tabPosCm) } : {}),
},
// Whether the rows carry a page number at all: Word's `TOC \n`, an ODF entry
// template naming no <text:index-entry-page-number/>. false = the text alone.
pageNumbers: {
default: true,
parseHTML: el => (el as HTMLElement).getAttribute('data-toc-pages') !== 'off',
renderHTML: attrs => (attrs.pageNumbers === false ? { 'data-toc-pages': 'off' } : {}),
},
// The named paragraph style of each level's entries (ODF's per-level entry
// template). The rows carry it as data-style, so the document stylesheet gives
// them the file's own indent, spacing and font.
@@ -348,7 +355,8 @@ class TocView {
return;
}
const noPage = indexKindOf(this.node()?.attrs?.index) === 'bibliography';
const noPage = indexKindOf(this.node()?.attrs?.index) === 'bibliography'
|| this.node()?.attrs?.pageNumbers === false;
const fill = !noPage && typeof this.node()?.attrs?.leader === 'string' ? String(this.node()!.attrs.leader) : '';
const levelStyles = this.node()?.attrs?.levelStyles as (string | null)[] | null | undefined;
entries.forEach((e, i) => {
+6 -2
View File
@@ -1936,11 +1936,15 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
out.push(new Paragraph({ children: [new SimpleField('BIBLIOGRAPHY', '')] }));
continue;
}
// `\n` over the whole range: Word's switch takes levels, the editor's index is
// all-or-nothing.
const noPages = node.attrs?.pageNumbers === false
? { pageNumbersEntryLevelsRange: `1-${maxLevel}` } : {};
out.push(new TableOfContents(tocTitle, kind === 'toc'
? { hyperlink: true, headingStyleRange: `1-${maxLevel}` }
? { hyperlink: true, headingStyleRange: `1-${maxLevel}`, ...noPages }
// `\c`, not `\a`: Word's own Insert Table of Figures keeps the label and number
// in the entry, which is what the editor's cached entries already read.
: { hyperlink: true, captionLabelIncludingNumbers: DOCX_SEQ_NAME[kind === 'tables' ? 'table' : 'figure'] }));
: { hyperlink: true, captionLabelIncludingNumbers: DOCX_SEQ_NAME[kind === 'tables' ? 'table' : 'figure'], ...noPages }));
}
}
return out;
+10 -5
View File
@@ -1141,7 +1141,7 @@ function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode {
// One generated table of contents, collected by replaceTableOfContents and emitted by
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
type TocEntry = { text: string; level: number; page: number; pages?: number[] };
type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | null; citationStyle: CitationStyle };
type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; pageNumbers: boolean; levelStyles: (string | null)[] | null; citationStyle: CitationStyle };
// Swap each top-level tableOfContents node for a marker paragraph carrying the TOC
// sentinel and collect its cached entries. Top-level only (like replacePageBreaks): a
@@ -1170,6 +1170,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
maxLevel: depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL,
leader: normalizeLeader(child.attrs?.leader),
tabPosCm: typeof child.attrs?.tabPosCm === 'number' ? child.attrs.tabPosCm : null,
pageNumbers: child.attrs?.pageNumbers !== false,
levelStyles: Array.isArray(child.attrs?.levelStyles) ? (child.attrs!.levelStyles as (string | null)[]) : null,
citationStyle: isCitationStyle(child.attrs?.citationStyle) ? child.attrs!.citationStyle : 'key',
});
@@ -4227,10 +4228,13 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): string {
`<text:index-entry-tab-stop style:type="right"` +
`${toc.tabPosCm ? ` style:position="${toc.tabPosCm}cm"` : ''}` +
`${toc.leader ? ` style:leader-char="${escapeXml(toc.leader)}"` : ''}/>`;
// An index with no page numbers has nothing to lead to either, so the stop goes with
// them — that pair is what LibreOffice's own dialog switches off together.
const pageCol = toc.pageNumbers ? `${stop}<text:index-entry-page-number/>` : '';
const entry = (levelAttr: string, style: string) =>
`<text:${spec.el}-entry-template${levelAttr} text:style-name="${style}">` +
`<text:index-entry-link-start/><text:index-entry-text/>${stop}` +
`<text:index-entry-page-number/><text:index-entry-link-end/>` +
`<text:index-entry-link-start/><text:index-entry-text/>${pageCol}` +
`<text:index-entry-link-end/>` +
`</text:${spec.el}-entry-template>`;
// An alphabetical index is fed by its marks, is single-level, and merges the pages of
// a term the reader marked more than once — LibreOffice's text:combine-entries.
@@ -4266,8 +4270,9 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): string {
: '') +
toc.entries
.map(e => `<text:p text:style-name="${tocLevelStyle(toc, e.level)}">${escapeXml(e.text).replace(/\n/g, '<text:line-break/>')}`
// A bibliography row is the source, nothing else: no tab, no page number.
+ (toc.kind === 'bibliography' ? '' : `<text:tab/>${e.pages?.join(', ') ?? e.page}`) + '</text:p>')
// A bibliography row is the source, nothing else: no tab, no page number. An
// index switched to text alone says the same about its rows.
+ (toc.kind === 'bibliography' || !toc.pageNumbers ? '' : `<text:tab/>${e.pages?.join(', ') ?? e.page}`) + '</text:p>')
.join('') +
`</text:index-body>`;
return `<text:${spec.el} text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:${spec.el}>`;
+8 -1
View File
@@ -355,6 +355,12 @@ function tocMaxLevel(instr: string): number {
return n >= 1 ? Math.min(MAX_HEADING_LEVEL, n) : MAX_HEADING_LEVEL;
}
// `\n` drops the page numbers. Word names a level range with it; the editor's index is
// all-or-nothing, so any range reads as an index of text alone. Only the off state is
// carried, as on the ODF side, where it is the entry template's missing page number.
const tocPageNumbers = (instr: string): { pageNumbers?: false } =>
(/\\n\b/.test(instr) ? { pageNumbers: false } : {});
// The TOC field's \c switch names a caption label, which makes it a list of figures or
// tables rather than a contents. Word's own labels and LibreOffice's travel alike.
function tocIndexKind(instr: string): IndexKind {
@@ -546,7 +552,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
flush();
// The field carries no heading of its own — Word's sits in a separate paragraph.
const instr = instrTextOf(el);
out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instr), index: tocIndexKind(instr) } });
out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: tocMaxLevel(instr), index: tocIndexKind(instr), ...tocPageNumbers(instr) } });
}
if (startedInToc || emit) continue;
const num = paragraphNum(el, ctx);
@@ -599,6 +605,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
title: tocHeading(content) ?? '',
maxLevel: tocMaxLevel(instr),
index: tocIndexKind(instr),
...tocPageNumbers(instr),
} });
}
} else {
+6
View File
@@ -1244,6 +1244,12 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node {
levelStyles[level - 1] = display;
}
const attrs: Record<string, unknown> = { entries, title, maxLevel, leader, tabPosCm, index: indexKind };
// A template that names no page number is an index of text alone (Word's TOC \n). A
// bibliography row never has one, so its own template says nothing about this.
if (indexKind !== 'bibliography' && template
&& !template.getElementsByTagNameNS(NS.text, 'index-entry-page-number')[0]) {
attrs.pageNumbers = false;
}
if (levelStyles.some(Boolean)) attrs.levelStyles = Array.from(levelStyles, (s) => s ?? null);
// A bibliography's citation style is its entry template: the fields it names, in order.
// A numbered index says so on the source instead, whatever its template reads.
+56
View File
@@ -0,0 +1,56 @@
// An index that deliberately shows no page numbers: Word's TOC \n, an ODF entry template
// naming no <text:index-entry-page-number/>.
import { describe, it, expect } from 'vitest';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
import { importDocx } from '../../src/lib/import/docx';
import { importOdt } from '../../src/lib/import/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { buildOdt } from '../../src/lib/export/odt';
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const MARGINS = { top: 2, bottom: 2, left: 2, right: 2 };
const docx = (instr: string) => zipSync({
'word/document.xml': strToU8(`<?xml version="1.0"?>
<w:document xmlns:w="${W}"><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Chapter</w:t></w:r></w:p>
<w:p>
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText xml:space="preserve">${instr}</w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:t>Chapter</w:t></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p>
</w:body></w:document>`),
});
const toc = (doc: any) => doc.content.content.find((n: any) => n.type === 'tableOfContents');
describe('an index without page numbers', () => {
it('reads Word\'s \\n switch', () => {
expect(toc(importDocx(docx(' TOC \\h \\z \\n \\o "1-3" '))).attrs.pageNumbers).toBe(false);
expect(toc(importDocx(docx(' TOC \\h \\z \\o "1-3" '))).attrs.pageNumbers).not.toBe(false);
});
it('round-trips through DOCX', async () => {
const res: any = importDocx(docx(' TOC \\h \\z \\n \\o "1-3" '));
const back: any = importDocx(await buildDocx(res.content, MARGINS, 'portrait'));
expect(toc(back).attrs.pageNumbers).toBe(false);
});
it('round-trips through ODF as an entry template with no page number', async () => {
const res: any = importDocx(docx(' TOC \\h \\z \\n \\o "1-3" '));
const bytes = await buildOdt(res.content, MARGINS, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
expect(xml).toContain('<text:table-of-content-entry-template');
expect(xml).not.toContain('<text:index-entry-page-number/>');
expect(toc(importOdt(bytes)).attrs.pageNumbers).toBe(false);
});
it('keeps the page numbers of an ordinary index', async () => {
const res: any = importDocx(docx(' TOC \\h \\z \\o "1-3" '));
const bytes = await buildOdt(res.content, MARGINS, 'portrait');
expect(strFromU8(unzipSync(bytes)['content.xml'])).toContain('<text:index-entry-page-number/>');
expect(toc(importOdt(bytes)).attrs.pageNumbers).not.toBe(false);
});
});