From da438a7655864b9bf06f5edaf2127df689828a19 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 06:56:20 +0200 Subject: [PATCH] feat: line numbering in the left margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- src/App.svelte | 13 +- src/lib/components/CLAUDE.md | 8 ++ src/lib/components/Editor.svelte | 9 +- src/lib/components/LineNumberLayer.svelte | 127 ++++++++++++++++++ src/lib/components/ribbon/Ribbon.svelte | 5 +- src/lib/components/ribbon/icons.ts | 1 + .../components/ribbon/tabs/LayoutTab.svelte | 40 ++++++ src/lib/export/docx.ts | 12 +- src/lib/export/odt.ts | 20 ++- src/lib/i18n/locales/de.ts | 9 ++ src/lib/i18n/locales/en.ts | 9 ++ src/lib/import/docx.ts | 17 +++ src/lib/import/odt.ts | 3 + src/lib/import/styleResolver.ts | 16 +++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/lineNumbering.ts | 52 +++++++ tests/roundtrip.test.ts | 37 +++++ 18 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/LineNumberLayer.svelte create mode 100644 src/lib/storage/lineNumbering.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 808044c..c6078e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` (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 diff --git a/src/App.svelte b/src/App.svelte index 83f57e6..e9e691a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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[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} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 13811c9..0eefd90 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -82,6 +82,14 @@ per page in unscaled document px. The watermark is an `` 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). diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index df8eb2e..4e314ae 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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'; {/if} + {#if lineNumbering.on} + + {/if} + 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(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(); + 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; + } + + + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 59a10e2..02e1afc 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -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'} {:else if tab === 'layout'} - (paragraphDialogOpen = true)} /> + (paragraphDialogOpen = true)} /> {:else if tab === 'references'} {:else if tab === 'review'} diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index f58bb6f..bb2451e 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -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'], diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index eda0dd7..79b29cc 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -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} +
+ toggleMenu('lineNum')} + /> + {#if isMenuOpen('lineNum')} + + {/if} +
+ .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; } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 423b8c8..2d43dbb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 { 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 diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 9a294c4..de7aaf2 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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, usedTables: Set = 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, usedTables: Set = 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(/`; + styles = styles.includes('') + ? styles.replace('', `${cfg}`) + : styles.replace(/${cfg} { +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 { // 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 diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index e252919..7aac5cf 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index ab31573..d8275fc 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index b64394d..01d5027 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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): unknown { const decodeXml = (s: string) => s.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); +// 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 { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index ff61aa4..ad92648 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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(), diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index f90b2b1..b0f5f59 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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 { }; } + // 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 { diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index e246d49..20d4f3a 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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` `` 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 `` in `office:styles`, Word a `` 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. diff --git a/src/lib/storage/lineNumbering.ts b/src/lib/storage/lineNumbering.ts new file mode 100644 index 0000000..87d1cb9 --- /dev/null +++ b/src/lib/storage/lineNumbering.ts @@ -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 in office:styles, +// Word as 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 | 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)); +} diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 43911dc..7d2bfd5 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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(/]*>/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(/]*>/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:countBy="5"[^>]*\/>/.test(xml), xml.match(/]*>/g)); + check('restarts per page', /w:restart="newPage"/.test(xml), xml.match(/]*>/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('