diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb4088..7961035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ - Charts are drawn from the file — DrawingML `chartN.xml` and ODF `chart:chart`, bar / line / area / scatter / pie, with their titles, axis titles, gridlines, axis bounds and series colours (read-only; see the limitations below) - Special characters picker - Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font +- Page numbering options (Layout ▸ Page numbers): the five formats both word processors offer (1 / i / I / a / A) and a start value. Round-trips to ODF `style:num-format` on the page layout plus `style:page-number` on the first paragraph — where LibreOffice keeps the start, ODF having no document-level one — and to Word's `w:pgNumType` - Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field - Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr` - Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (`draw:object` + `Formula{n}/content.xml`, our LaTeX kept in the MathML `annotation`) and as Word's `m:oMath`. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform @@ -130,7 +131,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. **Missing while writing** - Hyphenation beyond the document switch: a per-paragraph "don't hyphenate", and the zone / ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither - Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not -- Page numbering options: the page field is decimal-only — no start value, no roman/alpha format, no restart per section +- 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) diff --git a/src/App.svelte b/src/App.svelte index 17cea5b..07e0788 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -37,6 +37,7 @@ import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties'; import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation'; + import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; import LanguagePicker from './lib/components/LanguagePicker.svelte'; @@ -154,6 +155,7 @@ let docProps: DocProperties = $state(loadDocProperties()); let docPropsOpen = $state(false); let hyphenate = $state(loadHyphenation()); + let pageNumbering: PageNumbering = $state(loadPageNumbering()); let autoCorrectOpen = $state(false); // Width of the hidden mirror span (below), so the title input grows/shrinks @@ -221,6 +223,10 @@ saveHyphenation(hyphenate); }); + $effect(() => { + savePageNumbering(pageNumbering); + }); + $effect(() => { saveDocumentLanguage(documentLanguage); void spellController.setLanguage(documentLanguage); @@ -496,6 +502,7 @@ extraHfSections = []; documentName = ''; hyphenate = false; + pageNumbering = { ...DEFAULT_PAGE_NUMBERING }; docProps = { ...EMPTY_DOC_PROPERTIES }; saveDocProperties(docProps); fileHandle = null; @@ -557,6 +564,7 @@ spacingModel = result.spacingModel; pageRtl = result.rtl; hyphenate = result.hyphenate; + pageNumbering = result.pageNumbering; // 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; @@ -638,7 +646,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); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -654,7 +662,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); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -672,7 +680,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); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -887,6 +895,7 @@ bind:pageOrientation bind:pageFormat bind:hyphenate + bind:pageNumbering {hfActive} onManageStyles={openStyleManager} onManageTableStyles={() => openStyleManager('table')} @@ -1129,6 +1138,7 @@ bind:pageOrientation bind:pageFormat bind:hyphenate + bind:pageNumbering bind:hfDistances bind:differentFirstPage bind:differentOddEven @@ -1178,6 +1188,7 @@ {spacingModel} {hyphenate} {documentLanguage} + {pageNumbering} bind:extraHfSections {zoom} onZoom={setZoom} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 74bc22e..5347f1f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -23,6 +23,7 @@ import { type Orientation } from '../storage/pageOrientation'; import { type SpacingModel } from '../storage/spacingModel'; import { NO_LANGUAGE } from '../storage/documentLanguage'; + import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; import { applyNoteVars } from '../storage/noteSettings'; import { noteSettings } from '../storage/notes.svelte'; import { RESYNC_NOTES } from '../editor/extensions/notes'; @@ -43,7 +44,7 @@ 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', + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_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, @@ -59,6 +60,8 @@ pageRtl?: boolean; /** Automatic hyphenation; the browser needs the document language to pick its patterns. */ hyphenate?: boolean; documentLanguage?: string; + /** How the page-number field counts (format + start value). */ + pageNumbering?: PageNumbering; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -1022,6 +1025,7 @@ bind:extraHfSections {sectionStartPages} {chapterStarts} + {pageNumbering} /> diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index a9f640e..26af861 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -6,6 +6,8 @@ import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins'; import { type Orientation } from '../storage/pageOrientation'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; + import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; + import { formatOrdinal } from '../utils/orderedListTypes'; import { t } from '../i18n/i18n.svelte'; let { @@ -29,6 +31,7 @@ extraHfSections = $bindable([]), sectionStartPages = [], chapterStarts = [], + pageNumbering = DEFAULT_PAGE_NUMBERING, }: { headerDoc: HfDoc; footerDoc: HfDoc; @@ -40,6 +43,8 @@ differentOddEven?: boolean; numPages: number; currentPage: number; + /** How the page-number field counts (format + start value). */ + pageNumbering?: PageNumbering; pageMargins: PageMargins; orientation: Orientation; pageFormat?: PageFormat; @@ -219,6 +224,12 @@ return { update: apply }; } + // The number a page shows: its position offset by the document's start value, in the + // document's own format. + function pageLabel(page: number): string { + return formatOrdinal(page + pageNumbering.start - 1, pageNumbering.format); + } + // Replace the placeholder text in every page-field span with the real value: // current page number, or the total page count. Re-runs when its param changes. function patchFields(node: HTMLElement, params: ZoneParams) { @@ -226,7 +237,8 @@ for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) { const kind = el.getAttribute('data-page-field'); if (kind === 'chapter') el.textContent = chapterOn(page, Number(el.getAttribute('data-level')) || 1); - else el.textContent = String(kind === 'count' ? total : page); + // The count stays decimal, as the field LibreOffice and Word write does. + else el.textContent = kind === 'count' ? String(total) : pageLabel(page); } }; apply(params); @@ -334,7 +346,7 @@ for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) { const kind = el.getAttribute('data-page-field'); if (kind === 'chapter') el.textContent = chapterOn(editingPage, Number(el.getAttribute('data-level')) || 1); - else el.textContent = String(kind === 'count' ? numPages : editingPage); + else el.textContent = kind === 'count' ? String(numPages) : pageLabel(editingPage); } // Content height (unscaled by the zoom transform) drives the active zone's frame. const tt = liveMount.querySelector('.tiptap') as HTMLElement | null; diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 6d04c60..ca7f22f 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -27,11 +27,13 @@ import { listContext } from '../editor/extensions/indent'; import { stepFontSize } from '../editor/extensions/shortcuts'; import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns'; + import { DEFAULT_PAGE_NUMBERING, PAGE_NUM_FORMATS, clampPageStart, type PageNumbering } from '../storage/pageNumbering'; + import { formatOrdinal } from '../utils/orderedListTypes'; import { t } from '../i18n/i18n.svelte'; import { shortcutHint, type ShortcutId } from '../editor/shortcuts'; - let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hyphenate = $bindable(false), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }: - { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props(); + let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }: + { editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props(); const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -1417,6 +1419,22 @@ {#if layoutOpen}
+ +
+ + (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })} + /> +