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:
1 parent
8a9b391d76
commit
ac5b05e0fe
8 files changed
+98
-12
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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}>`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user