feat: line numbering in the left margin

Continuous or per page, every nth line, empty lines counted or not —
LibreOffice's Tools ▸ Line Numbering, round-tripping as its
text:linenumbering-configuration and Word's w:lnNumType. CSS exposes no line
boxes, so the layer Range-selects each block and takes the distinct tops of
its client rects as its lines; LibreOffice renders the exported file with the
numbers in the same places.
This commit is contained in:
Steffen Becker committed 2026-08-14 06:56:20 +02:00
1 parent 96d827b2c7
commit da438a7655
18 files changed
+371 -10

No files matched your search

+1 -1
View File
@@ -116,6 +116,7 @@
- Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior)
- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted
- List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field
- Line numbering (Layout ▸ Line numbers, LibreOffice's Tools ▸ Line Numbering): numbers in the left margin, continuous or restarting each page, every nth line, with or without empty lines. Measured off the rendered lines, so the preview and the saved file agree; round-trips as ODF `text:linenumbering-configuration` and Word's `w:lnNumType`
- Page design (Layout ▸ Page design): a page background colour, a border around the text area at a settable distance from it, and a text watermark with its own colour, angle and transparency — LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark. All three round-trip: ODF keeps them on the page layout and the master page's header, Word on `w:document` / `w:sectPr` / a VML shape in the header part
- Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `<text:sequence>` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
@@ -138,7 +139,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
- Restarting the page numbering **per section** (only the document's own format and start value exist)
- 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
- Table extras: sort, sum/formula in a cell, number recognition
- A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it
+10 -3
View File
@@ -39,6 +39,7 @@
import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation';
import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering';
import { loadPageDecor, savePageDecor, EMPTY_PAGE_DECOR, type PageDecor } from './lib/storage/pageDecor';
import { loadLineNumbering, saveLineNumbering, DEFAULT_LINE_NUMBERING, type LineNumbering } from './lib/storage/lineNumbering';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
import { spellController } from './lib/spell/controller';
import LanguagePicker from './lib/components/LanguagePicker.svelte';
@@ -160,6 +161,7 @@
let hyphenate = $state(loadHyphenation());
let pageNumbering: PageNumbering = $state(loadPageNumbering());
let pageDecor: PageDecor = $state(loadPageDecor());
let lineNumbering: LineNumbering = $state(loadLineNumbering());
let autoCorrectOpen = $state(false);
let commentsOpen = $state(false);
@@ -231,6 +233,7 @@
$effect(() => {
savePageNumbering(pageNumbering);
savePageDecor(pageDecor);
saveLineNumbering(lineNumbering);
});
$effect(() => {
@@ -510,6 +513,7 @@
hyphenate = false;
pageNumbering = { ...DEFAULT_PAGE_NUMBERING };
pageDecor = { ...EMPTY_PAGE_DECOR };
lineNumbering = { ...DEFAULT_LINE_NUMBERING };
docProps = { ...EMPTY_DOC_PROPERTIES };
saveDocProperties(docProps);
fileHandle = null;
@@ -573,6 +577,7 @@
hyphenate = result.hyphenate;
pageNumbering = result.pageNumbering;
pageDecor = result.decor;
lineNumbering = result.lineNumbering;
// Adopt the document's spell-check language (the $effect switches the
// controller + loads its dictionary). null = file declared none; keep ours.
if (result.language) documentLanguage = result.language;
@@ -670,7 +675,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -686,7 +691,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -704,7 +709,7 @@
try {
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering);
await saveAsDocx(bytes, suggestedFilenameDocx(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -921,6 +926,7 @@
bind:hyphenate
bind:pageNumbering
bind:pageDecor
bind:lineNumbering
{hfActive}
onManageStyles={openStyleManager}
onManageTableStyles={() => openStyleManager('table')}
@@ -1219,6 +1225,7 @@
{documentLanguage}
{pageNumbering}
{pageDecor}
{lineNumbering}
bind:extraHfSections
{zoom}
onZoom={setZoom}
+8
View File
@@ -82,6 +82,14 @@ per page in unscaled document px. The watermark is an `<svg><text textLength>` b
fontwork shape **stretches** its text to its box instead of setting it at a size — a font
size alone makes a short word far too small and a long one overflow the page.
## Line numbering (`LineNumberLayer.svelte`, `storage/lineNumbering.ts`)
One number per rendered line in the left margin. There is no CSS line box to read, so
each block's contents are Range-selected and the distinct tops of its client rects are
its lines; the count runs from the document start (or restarts per page). Re-measured on
each edit and each pagination settle — the whole document each time, which is the
ceiling noted in the file.
## Debug tooling (dev only)
In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry), `getTableCellDebug(view)` (per cell: the `verticalAlign` attr beside the computed value, cell/row height, the gap above and below the content, and every block's margins/`--space-before` — cell alignment reads as broken whenever the content fills the box, so the numbers that decide that travel with it), the style sheet + spacing model, and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans).
+8 -1
View File
@@ -17,6 +17,8 @@
import ContextMenu from './ContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import PageDecorLayer from './PageDecorLayer.svelte';
import LineNumberLayer from './LineNumberLayer.svelte';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import Ruler from './Ruler.svelte';
import { saveDocument, loadDocument, markDocumentLoaded } from '../storage/autosave';
@@ -46,7 +48,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR,
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING,
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false,
@@ -66,6 +68,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
pageNumbering?: PageNumbering;
/** Page background, page border and watermark. */
pageDecor?: PageDecor;
/** Numbers in the left margin, one per rendered line. */
lineNumbering?: LineNumbering;
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean;
headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean;
@@ -1009,6 +1013,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
</div>
{/if}
<PageDecorLayer decor={pageDecor} {numPages} {pageMargins} {pageFormat} {orientation} />
{#if lineNumbering.on}
<LineNumberLayer {editor} {tick} {lineNumbering} {numPages} {pageMargins} {pageFormat} {orientation} />
{/if}
<HeaderFooterLayer
bind:headerDoc
bind:footerDoc
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import type { Orientation } from '../storage/pageOrientation';
import type { LineNumbering } from '../storage/lineNumbering';
// Numbers in the left margin, one per rendered line. CSS exposes no line boxes, so
// every line has to be measured: a Range over each block yields one client rect per
// run per line, and the distinct tops are the lines.
//
// ponytail: measures every block in the document on each settle. Fine while numbering
// is on and the document is ordinary; a 400-page one would want a windowed pass keyed
// to a running count per page.
let { editor, tick, lineNumbering, numPages, pageMargins, pageFormat, orientation }: {
editor: Editor | null;
tick: number;
lineNumbering: LineNumbering;
numPages: number;
pageMargins: PageMargins;
pageFormat: PageFormat;
orientation: Orientation;
} = $props();
const PAGE_GAP = 20;
let cycle = $derived(pageDimsCm(pageFormat, orientation).h * PX_PER_CM + PAGE_GAP);
let left = $derived(cmToPx(pageMargins.left) - cmToPx(lineNumbering.distanceCm));
let marks = $state<{ top: number; label: string }[]>([]);
let host = $state<HTMLElement | null>(null);
let paper: HTMLElement | null = null;
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; measure(); });
};
$effect(() => {
// Re-measure on every edit and on each pagination settle.
void tick;
void lineNumbering;
void numPages;
if (!lineNumbering.on) { marks = []; return; }
schedule();
});
$effect(() => {
const el = host;
if (!el) return;
paper = el.closest('.paper') as HTMLElement | null;
paper?.addEventListener('pm-pagecount', schedule);
return () => paper?.removeEventListener('pm-pagecount', schedule);
});
// The tops of the lines a block renders, in document px relative to .tiptap.
function lineTops(block: Element, origin: number): { top: number; height: number }[] {
const range = document.createRange();
range.selectNodeContents(block);
const rects = Array.from(range.getClientRects()).filter((r) => r.height > 0);
if (!rects.length) {
const r = block.getBoundingClientRect();
return r.height > 0 ? [{ top: r.top - origin, height: r.height }] : [];
}
// One rect per run per line; the distinct tops are the lines.
const byTop = new Map<number, { top: number; height: number }>();
for (const r of rects) {
const key = Math.round(r.top);
const seen = byTop.get(key);
if (!seen || r.height > seen.height) byTop.set(key, { top: r.top - origin, height: r.height });
}
return [...byTop.values()].sort((a, b) => a.top - b.top);
}
function measure(): void {
const view = editor?.view;
if (!view || !lineNumbering.on || !host?.isConnected) { marks = []; return; }
const origin = view.dom.getBoundingClientRect().top;
const out: { top: number; label: string }[] = [];
let count = 0;
let page = 1;
for (const block of Array.from(view.dom.children)) {
// A table, an image frame or an index is not a numbered line in either word
// processor; only text blocks count.
if (!/^(P|H1|H2|H3|H4|H5|H6|LI|BLOCKQUOTE)$/.test(block.tagName)) continue;
const empty = !block.textContent?.trim();
for (const line of lineTops(block, origin)) {
const linePage = Math.max(1, Math.floor(line.top / cycle) + 1);
if (lineNumbering.restart === 'page' && linePage !== page) { page = linePage; count = 0; }
if (empty && !lineNumbering.countEmpty) continue;
count += 1;
if (count % lineNumbering.interval === 0) {
out.push({ top: line.top + line.height / 2, label: String(count) });
}
}
}
marks = out;
}
</script>
<div class="line-number-layer" bind:this={host} aria-hidden="true">
{#each marks as m}
<span class="line-number" style="top: {m.top}px; right: calc(100% - {left}px);">{m.label}</span>
{/each}
</div>
<style>
/* A sibling of .band-layer inside the scaled .paper, so a top measured against
.tiptap is the top it draws at; the numbers hang into the left margin. */
.line-number-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.line-number {
position: absolute;
transform: translateY(-50%);
font-family: var(--font-serif);
font-size: 10pt;
line-height: 1;
color: var(--color-page-text);
white-space: nowrap;
}
</style>
+4 -1
View File
@@ -29,6 +29,7 @@
import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../../storage/lineNumbering';
let {
editor,
@@ -49,6 +50,7 @@
hyphenate = $bindable(false),
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
pageDecor = $bindable(EMPTY_PAGE_DECOR),
lineNumbering = $bindable(DEFAULT_LINE_NUMBERING),
hfActive = null,
onManageStyles,
onManageTableStyles,
@@ -80,6 +82,7 @@
hyphenate?: boolean;
pageNumbering?: PageNumbering;
pageDecor?: PageDecor;
lineNumbering?: LineNumbering;
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
@@ -300,7 +303,7 @@
{:else if tab === 'insert'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering bind:pageDecor onParagraphDialog={() => (paragraphDialogOpen = true)} />
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering bind:pageDecor bind:lineNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
+1
View File
@@ -79,6 +79,7 @@ export const STROKED = {
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'],
watermark: ['M2.5 2h11v12h-11z', 'M4.5 11.5 11.5 4.5'],
lineNumbers: ['M6 3h8.5M6 6.5h8.5M6 10h8.5M6 13.5h8.5', 'M2 2.2h1.1v3.3M1.6 5.5h1.9', 'M1.5 9.6c.2-.4.6-.5 1-.4.5.1.6.6.3 1L1.6 11.6h1.9'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
// A speech bubble with its tail, the shape both word processors use for a comment.
comment: ['M2 3.25h12v7.5H7.5L4.5 13.5v-2.75H2z', 'M4.75 6h6.5M4.75 8.25h4'],
@@ -11,6 +11,7 @@
import type { HfZone } from '../../../storage/headerFooter';
import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../../../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../../../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../../../storage/lineNumbering';
import PageDecorDialog from '../../PageDecorDialog.svelte';
import { formatOrdinal } from '../../../utils/orderedListTypes';
import { t } from '../../../i18n/i18n.svelte';
@@ -24,6 +25,7 @@
hyphenate = $bindable(false),
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
pageDecor = $bindable(EMPTY_PAGE_DECOR),
lineNumbering = $bindable(DEFAULT_LINE_NUMBERING),
onParagraphDialog,
}: {
editor: Editor | null;
@@ -35,6 +37,7 @@
hyphenate?: boolean;
pageNumbering?: PageNumbering;
pageDecor?: PageDecor;
lineNumbering?: LineNumbering;
onParagraphDialog?: () => void;
} = $props();
@@ -242,6 +245,42 @@
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'lineNum'}>
<RibbonButton
icon="lineNumbers"
label={t().ribbon.lineNumbers}
title={t().lineNumbers.title}
caret
active={isMenuOpen('lineNum') || lineNumbering.on}
onclick={() => toggleMenu('lineNum')}
/>
{#if isMenuOpen('lineNum')}
<div class="ribbon-menu" use:anchored role="menu">
<button class:selected={!lineNumbering.on} onclick={() => (lineNumbering = { ...lineNumbering, on: false })}>
{t().lineNumbers.off}
</button>
<button class:selected={lineNumbering.on && lineNumbering.restart === 'continuous'}
onclick={() => (lineNumbering = { ...lineNumbering, on: true, restart: 'continuous' })}>
{t().lineNumbers.continuous}
</button>
<button class:selected={lineNumbering.on && lineNumbering.restart === 'page'}
onclick={() => (lineNumbering = { ...lineNumbering, on: true, restart: 'page' })}>
{t().lineNumbers.perPage}
</button>
<div class="rb-menu-label">{t().lineNumbers.interval}</div>
<label class="num-row">
<input type="number" min="1" max="100" value={lineNumbering.interval}
onchange={(e) => (lineNumbering = { ...lineNumbering, interval: Math.max(1, Math.min(100, Number(e.currentTarget.value) || 1)) })} />
</label>
<label class="check-row">
<input type="checkbox" checked={lineNumbering.countEmpty}
onchange={(e) => (lineNumbering = { ...lineNumbering, countEmpty: e.currentTarget.checked })} />
{t().lineNumbers.countEmpty}
</label>
</div>
{/if}
</div>
<RibbonButton
icon="watermark"
label={t().ribbon.pageDecor}
@@ -300,6 +339,7 @@
<style>
.num-row { display: flex; padding: 2px 12px 6px; }
.num-row input { width: 84px; }
.check-row { display: flex; align-items: center; gap: 6px; padding: 2px 12px 6px; white-space: nowrap; }
.rb-menu-wrap { position: relative; }
+11 -1
View File
@@ -5,7 +5,7 @@ import {
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
CommentRangeStart, CommentRangeEnd, CommentReference,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType, NumberFormat,
WidthType, HeightRule, PageOrientation, LineRuleType, LineNumberRestartFormat, TableLayoutType, SectionType, NumberFormat,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType, LeaderType,
} from 'docx';
@@ -36,6 +36,7 @@ import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, isEmptyPageDecor, type PageDecor, type Watermark } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
// The five page-number formats both word processors offer → Word's own names.
const DOCX_PAGE_NUM_FORMAT = {
@@ -1500,6 +1501,7 @@ export async function buildDocx(
hyphenate = false,
pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING,
decor: PageDecor = EMPTY_PAGE_DECOR,
lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -1629,6 +1631,14 @@ export async function buildDocx(
sections: groups.map((g, i) => ({
properties: {
page: pagePropsFor(g.section),
// Word's Layout ▸ Line Numbers; ODF keeps the same five values document-wide.
...(lineNumbering.on
? { lineNumbers: {
countBy: lineNumbering.interval,
restart: lineNumbering.restart === 'page' ? LineNumberRestartFormat.NEW_PAGE : LineNumberRestartFormat.CONTINUOUS,
distance: cmToTwip(lineNumbering.distanceCm),
} }
: {}),
...(setAt(g.section).differentFirstPage ? { titlePage: true } : {}),
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
...(g.columns
+17 -3
View File
@@ -10,6 +10,7 @@ import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
import {
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
@@ -2454,7 +2455,7 @@ function lengthOfPageLayout(styles: string): number {
return width ? width - num('fo:margin-left') - num('fo:margin-right') : 17;
}
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR): Uint8Array {
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
@@ -2491,6 +2492,19 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
styles = styles.replace(/<style:page-layout-properties /, `<style:page-layout-properties ${decorProps} `);
}
// Line numbering is one document-wide element in office:styles (probed). LibreOffice
// writes only what differs from the ODF defaults, so an unnumbered document has none.
if (lineNumbering.on) {
const cfg = `<text:linenumbering-configuration text:number-lines="true"`
+ ` text:offset="${round3(lineNumbering.distanceCm)}cm" style:num-format="1"`
+ ` text:number-position="left" text:increment="${lineNumbering.interval}"`
+ ` text:count-empty-lines="${lineNumbering.countEmpty}"`
+ ` text:restart-on-page="${lineNumbering.restart === 'page'}"/>`;
styles = styles.includes('</office:styles>')
? styles.replace('</office:styles>', `${cfg}</office:styles>`)
: styles.replace(/<office:automatic-styles\b/, `<office:styles>${cfg}</office:styles><office:automatic-styles`);
}
// How the page-number field counts. ODF keeps the format on the page layout; the start
// value is a property of the document's first paragraph (applyPageNumberStart).
if (pageNumbering.format !== '1') {
@@ -3880,7 +3894,7 @@ export type HfExport = {
};
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR): Promise<Uint8Array> {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Promise<Uint8Array> {
// Images become IMG sentinels before serialization; applyImages resolves them and writes
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
// (so PGB misses their blocks) and before the inline passes (which then cover them).
@@ -4111,7 +4125,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Effects first: applyCharacterStyles then clones the style that already carries them.
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor);
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering);
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
const withWatermark = applyWatermarkOdf(withHf, decor.watermark);
// Sections past the first get their own master page, which is where ODF keeps a
+9
View File
@@ -536,6 +536,14 @@ const de: Messages = {
watermarkTransparency: 'Transparenz (%)',
clear: 'Alles entfernen',
},
lineNumbers: {
title: 'Zeilennummerierung',
off: 'Keine',
continuous: 'Fortlaufend',
perPage: 'Jede Seite neu beginnen',
interval: 'Jede n-te Zeile nummerieren',
countEmpty: 'Leerzeilen mitzählen',
},
notesDialog: {
title: 'Fuß- und Endnoten',
tabFootnotes: 'Fußnoten',
@@ -673,6 +681,7 @@ const de: Messages = {
repeatHeaderRowHint: 'Die erste Zeile oben auf jeder Seite wiederholen',
pageNumberFormat: 'Seitenzahlen',
pageNumberStart: 'Beginnen bei',
lineNumbers: 'Zeilennummern',
pageDecor: 'Seitengestaltung',
hyphenationHint: 'Automatisch trennen',
debugDump: 'Dump',
+9
View File
@@ -540,6 +540,14 @@ const en = {
watermarkTransparency: 'Transparency (%)',
clear: 'Remove all',
},
lineNumbers: {
title: 'Line numbering',
off: 'None',
continuous: 'Continuous',
perPage: 'Restart each page',
interval: 'Number every nth line',
countEmpty: 'Count empty lines',
},
notesDialog: {
title: 'Footnotes and Endnotes',
tabFootnotes: 'Footnotes',
@@ -677,6 +685,7 @@ const en = {
repeatHeaderRowHint: 'Repeat the first row at the top of every page',
pageNumberFormat: 'Page numbers',
pageNumberStart: 'Start at',
lineNumbers: 'Line numbers',
pageDecor: 'Page design',
hyphenationHint: 'Hyphenate automatically',
debugDump: 'Dump',
+17
View File
@@ -28,6 +28,7 @@ import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../edit
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import { normalizePageDecor, type PageDecor } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, normalizeLineNumbering, type LineNumbering } from '../storage/lineNumbering';
import { clampColumnGap } from '../editor/extensions/columns';
import { astToLatex } from '../math/latex';
import { parseOmml, OMML_NS } from '../math/omml';
@@ -238,6 +239,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
margins: withMirror(first.margins ?? sect.margins, mirrored),
rtl: sectPrRtl(finalSectPr),
decor: docxPageDecor(docDoc, finalSectPr, files),
lineNumbering: docxLineNumbering(finalSectPr),
hyphenate: docAutoHyphenation(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: sect.orientation,
@@ -2219,6 +2221,21 @@ function docxWatermark(files: Record<string, Uint8Array>): unknown {
const decodeXml = (s: string) =>
s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// <w:lnNumType> in the section: Word's line numbers. Absent = not numbered, which is
// also ODF's meaning for a missing configuration element.
function docxLineNumbering(sectPr: Element | null): LineNumbering {
const ln = fc(sectPr, 'lnNumType');
if (!ln) return DEFAULT_LINE_NUMBERING;
return normalizeLineNumbering({
on: true,
interval: intAttr(ln, W, 'countBy') ?? DEFAULT_LINE_NUMBERING.interval,
distanceCm: twipToCm(intAttr(ln, W, 'distance') ?? 283),
restart: ln.getAttributeNS(W, 'restart') === 'newPage' ? 'page' : 'continuous',
// Word has no "count empty lines" switch; it always does, as ODF defaults to.
countEmpty: true,
});
}
// A right-to-left section (w:bidi): the columns fill from the right, and it is the
// direction a block inherits when it declares none of its own.
function sectPrRtl(sectPr: Element | null): boolean {
+3
View File
@@ -27,6 +27,7 @@ import { newCommentId } from '../editor/extensions/comment';
import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption';
import type { IndexKind } from '../editor/extensions/tableOfContents';
import type { PageDecor } from '../storage/pageDecor';
import type { LineNumbering } from '../storage/lineNumbering';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -57,6 +58,7 @@ export interface OdtImportResult {
rtl: boolean;
// Page background, page border and watermark (storage/pageDecor.ts).
decor: PageDecor;
lineNumbering: LineNumbering;
// Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation).
hyphenate: boolean;
// How the page-number field counts (ODF style:num-format + style:page-number, Word w:pgNumType).
@@ -693,6 +695,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
format: geometry?.format ?? null,
rtl: geometry?.rtl ?? false,
decor: resolver.pageDecor(),
lineNumbering: resolver.lineNumbering(),
hyphenate: resolver.documentHyphenation(),
pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) },
tabIntervalCm: resolver.defaultTabInterval(),
+16
View File
@@ -5,6 +5,7 @@ import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { DEFAULT_WATERMARK, normalizePageDecor, type PageDecor, type Watermark } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, normalizeLineNumbering, type LineNumbering } from '../storage/lineNumbering';
// LibreOffice's (and Word's) name for the watermark shape — what tells it apart from an
// ordinary drawing in the header.
@@ -760,6 +761,21 @@ export class StyleResolver {
};
}
// <text:linenumbering-configuration> in office:styles: one document-wide element, and
// its absence is what "not numbered" means. LibreOffice omits every attribute at its
// ODF default, so each one falls back to that (probed).
lineNumbering(): LineNumbering {
const cfg = this.stylesDoc?.getElementsByTagNameNS(NS.text, 'linenumbering-configuration')[0] ?? null;
if (!cfg || cfg.getAttributeNS(NS.text, 'number-lines') === 'false') return DEFAULT_LINE_NUMBERING;
return normalizeLineNumbering({
on: true,
interval: Number(cfg.getAttributeNS(NS.text, 'increment')) || DEFAULT_LINE_NUMBERING.interval,
distanceCm: lengthToCm(cfg.getAttributeNS(NS.text, 'offset')) ?? DEFAULT_LINE_NUMBERING.distanceCm,
restart: cfg.getAttributeNS(NS.text, 'restart-on-page') === 'true' ? 'page' : 'continuous',
countEmpty: cfg.getAttributeNS(NS.text, 'count-empty-lines') !== 'false',
});
}
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
// the top and footer distance from the bottom when a header/footer is present.
edgeDistancesCm(): { top: number; bottom: number } | null {
+1
View File
@@ -14,6 +14,7 @@
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`.
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
- **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview.
+52
View File
@@ -0,0 +1,52 @@
// LibreOffice's Tools ▸ Line Numbering / Word's Layout ▸ Line Numbers. Off by default in
// both, so a fresh document writes nothing and neither importer stamps it on a file that
// declares none. ODF keeps it as one <text:linenumbering-configuration> in office:styles,
// Word as <w:lnNumType> in every w:sectPr (probed).
const KEY = 'edentext-line-numbering';
export type LineNumbering = {
on: boolean;
/** Only every nth line shows its number — LibreOffice's "Interval". */
interval: number;
/** Gap from the number to the text, in cm. */
distanceCm: number;
/** Where the count starts over. */
restart: 'continuous' | 'page';
/** Whether an empty line counts, as it does in both word processors by default. */
countEmpty: boolean;
};
export const DEFAULT_LINE_NUMBERING: LineNumbering = {
on: false, interval: 5, distanceCm: 0.5, restart: 'continuous', countEmpty: true,
};
export function normalizeLineNumbering(raw: unknown): LineNumbering {
const v = raw as Partial<LineNumbering> | null | undefined;
if (!v || typeof v !== 'object') return DEFAULT_LINE_NUMBERING;
const clamp = (n: unknown, lo: number, hi: number, fallback: number) => {
const x = Number(n);
return Number.isFinite(x) ? Math.min(hi, Math.max(lo, Math.round(x * 100) / 100)) : fallback;
};
return {
on: v.on === true,
interval: Math.round(clamp(v.interval, 1, 100, DEFAULT_LINE_NUMBERING.interval)),
distanceCm: clamp(v.distanceCm, 0, 5, DEFAULT_LINE_NUMBERING.distanceCm),
restart: v.restart === 'page' ? 'page' : 'continuous',
countEmpty: v.countEmpty !== false,
};
}
export function loadLineNumbering(): LineNumbering {
try {
const raw = localStorage.getItem(KEY);
return raw ? normalizeLineNumbering(JSON.parse(raw)) : DEFAULT_LINE_NUMBERING;
} catch {
return DEFAULT_LINE_NUMBERING;
}
}
export function saveLineNumbering(ln: LineNumbering): void {
if (!ln.on) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, JSON.stringify(ln));
}
+37
View File
@@ -2104,3 +2104,40 @@ describe('Leg 21: page background, page border and watermark', () => {
Object.keys(files));
});
});
describe('Leg 22: line numbering', () => {
const ln = { on: true, interval: 5, distanceCm: 0.5, restart: 'page' as const, countEmpty: false };
const doc: N = { type: 'doc', content: [P(null, T('body'))] };
it('ODT: one configuration element in office:styles, and back', async () => {
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, undefined, ln);
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
check('emitted once', (styles.match(/<text:linenumbering-configuration/g) ?? []).length === 1,
styles.match(/<text:linenumbering-configuration[^>]*>/g));
check('every value written', /text:increment="5"/.test(styles) && /text:offset="0.5cm"/.test(styles)
&& /text:restart-on-page="true"/.test(styles) && /text:count-empty-lines="false"/.test(styles),
styles.match(/<text:linenumbering-configuration[^>]*>/g));
const back = importOdt(bytes).lineNumbering;
check('round-trips', back.on && back.interval === 5 && back.distanceCm === 0.5
&& back.restart === 'page' && back.countEmpty === false, back);
});
it('DOCX: w:lnNumType in the section, and back', async () => {
const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, undefined, ln);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
check('emitted', /<w:lnNumType[^>]*w:countBy="5"[^>]*\/>/.test(xml), xml.match(/<w:lnNumType[^>]*>/g));
check('restarts per page', /w:restart="newPage"/.test(xml), xml.match(/<w:lnNumType[^>]*>/g));
const back = importDocx(bytes).lineNumbering;
// Word has no "count empty lines" switch, so it comes back at its own default.
check('round-trips', back.on && back.interval === 5 && back.restart === 'page'
&& Math.abs(back.distanceCm - 0.5) < 0.02, back);
});
it('an unnumbered document writes nothing', async () => {
const styles = strFromU8(unzipSync(await buildOdt(doc, margins, 'portrait'))['styles.xml']);
check('no ODF configuration', !styles.includes('linenumbering-configuration'));
check('no w:lnNumType', !strFromU8(unzipSync(await buildDocx(doc, margins, 'portrait'))['word/document.xml']).includes('<w:lnNumType'));
});
});