feat: page numbering in five formats, from a start value

Layout > Page numbers: 1 / i / I / a / A and a first-page number, both
round-tripping. ODF has no document-level start — probed, LibreOffice
puts style:page-number on the first paragraph's own style — so the
export mints one there and the page layout carries style:num-format;
Word takes both in the first section's w:pgNumType. The page *count*
field stays decimal, as the one both of them write does.
This commit is contained in:
Steffen Becker committed 2026-08-14 01:11:42 +02:00
1 parent 40efc73f86
commit a7092cfd1d
17 files changed
+272 -16

No files matched your search

+2 -1
View File
@@ -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)
+14 -3
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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<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);
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}
+5 -1
View File
@@ -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}
/>
</div>
</div>
+14 -2
View File
@@ -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;
+23 -2
View File
@@ -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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('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 @@
</button>
{#if layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">{t().ribbon.pageNumberFormat}</div>
<div class="pagenum-row">
<select class="format-select" bind:value={pageNumbering.format}>
{#each PAGE_NUM_FORMATS as f}
<option value={f}>{[1, 2, 3].map((n) => formatOrdinal(n, f)).join(', ')}</option>
{/each}
</select>
<input
type="number"
min="1"
max="9999"
title={t().ribbon.pageNumberStart}
value={pageNumbering.start}
onchange={(e) => (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })}
/>
</div>
<div class="lh-section-label">{t().ribbon.hyphenation}</div>
<label class="hyphen-row" title={t().ribbon.hyphenationHint}>
<input type="checkbox" bind:checked={hyphenate} />
@@ -2440,6 +2458,9 @@
background: var(--color-btn-hover);
}
.pagenum-row { display: flex; gap: 6px; align-items: center; }
.pagenum-row input { width: 70px; }
.hyphen-row { display: flex; align-items: center; gap: 6px; padding: 2px 0 4px; cursor: pointer; }
.lh-section-label {
+4 -1
View File
@@ -27,6 +27,7 @@
import type { HfZone } from '../../storage/headerFooter';
import type { DocumentLanguage } from '../../storage/documentLanguage';
import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../../storage/pageNumbering';
let {
editor,
@@ -45,6 +46,7 @@
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
hyphenate = $bindable(false),
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
hfActive = null,
onManageStyles,
onManageTableStyles,
@@ -74,6 +76,7 @@
pageOrientation?: Orientation;
pageFormat?: PageFormat;
hyphenate?: boolean;
pageNumbering?: PageNumbering;
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
@@ -291,7 +294,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 onParagraphDialog={() => (paragraphDialogOpen = true)} />
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:hyphenate bind:pageNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
@@ -9,6 +9,8 @@
import { DEFAULT_MARGINS, type PageMargins } from '../../../storage/pageMargins';
import type { Orientation } from '../../../storage/pageOrientation';
import type { HfZone } from '../../../storage/headerFooter';
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 } from '../../../editor/shortcuts';
@@ -18,6 +20,7 @@
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
hyphenate = $bindable(false),
pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING),
onParagraphDialog,
}: {
editor: Editor | null;
@@ -27,6 +30,7 @@
pageOrientation?: Orientation;
pageFormat?: PageFormat;
hyphenate?: boolean;
pageNumbering?: PageNumbering;
onParagraphDialog?: () => void;
} = $props();
@@ -200,6 +204,37 @@
active={hyphenate}
onclick={() => (hyphenate = !hyphenate)}
/>
<div class="rb-menu-wrap" use:clickOutside={'pageNum'}>
<RibbonButton
icon="pageNumber"
label={t().ribbon.pageNumberFormat}
title={t().ribbon.pageNumberFormat}
caret
active={isMenuOpen('pageNum')}
onclick={() => toggleMenu('pageNum')}
/>
{#if isMenuOpen('pageNum')}
<div class="ribbon-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().ribbon.pageNumberFormat}</div>
{#each PAGE_NUM_FORMATS as f}
<button class:selected={pageNumbering.format === f} onclick={() => (pageNumbering = { ...pageNumbering, format: f })}>
{[1, 2, 3].map((n) => formatOrdinal(n, f)).join(', ')}
</button>
{/each}
<div class="rb-menu-label">{t().ribbon.pageNumberStart}</div>
<label class="num-row">
<input
type="number"
min="1"
max="9999"
value={pageNumbering.start}
onchange={(e) => (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })}
/>
</label>
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
@@ -247,6 +282,9 @@
{/snippet}
<style>
.num-row { display: flex; padding: 2px 12px 6px; }
.num-row input { width: 84px; }
.rb-menu-wrap { position: relative; }
.col-preview { flex-shrink: 0; }
+14 -1
View File
@@ -4,7 +4,7 @@ import {
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType, NumberFormat,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType, LeaderType,
} from 'docx';
@@ -31,6 +31,13 @@ import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, t
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
// The five page-number formats both word processors offer → Word's own names.
const DOCX_PAGE_NUM_FORMAT = {
'1': NumberFormat.DECIMAL, i: NumberFormat.LOWER_ROMAN, I: NumberFormat.UPPER_ROMAN,
a: NumberFormat.LOWER_LETTER, A: NumberFormat.UPPER_LETTER,
} as const;
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyles';
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
@@ -1309,6 +1316,7 @@ export async function buildDocx(
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
props: DocProperties = EMPTY_DOC_PROPERTIES,
hyphenate = false,
pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -1373,6 +1381,11 @@ export async function buildDocx(
left: cmToTwip(m.left), right: cmToTwip(m.right),
header: cmToTwip(Math.min(headerDist, m.top)), footer: cmToTwip(Math.min(footerDist, m.bottom)),
},
// w:pgNumType. Word restarts numbering at every section carrying it, so only the
// first gets it — the rest continue.
...(i === 0 && (pageNumbering.format !== '1' || pageNumbering.start !== 1)
? { pageNumbers: { formatType: DOCX_PAGE_NUM_FORMAT[pageNumbering.format], ...(pageNumbering.start !== 1 ? { start: pageNumbering.start } : {}) } }
: {}),
};
};
// Fresh instances per section (Word's per-sectPr references, i.e. no "Link to
+44 -4
View File
@@ -8,6 +8,7 @@ import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
import {
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
@@ -2212,7 +2213,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
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): 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): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
@@ -2238,6 +2239,15 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
styles = styles.replace(/<style:page-layout-properties /, '<style:page-layout-properties style:writing-mode="rl-tb" ');
}
// 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') {
styles = styles.replace(
/<style:page-layout-properties /,
`<style:page-layout-properties style:num-format="${pageNumbering.format}" `,
);
}
// Document spell-check language: set fo:language/fo:country on the base
// Standard paragraph style, which every paragraph inherits from. LibreOffice
// and Word read this as the document default language.
@@ -3558,7 +3568,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): 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): 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).
@@ -3786,12 +3796,42 @@ 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);
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering);
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins);
return applyDocProperties(applySpacingModel(withSections, spacingModel), props);
return applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props);
}
// The document's first page number. ODF has no document-level start: LibreOffice puts
// `style:page-number` on the first paragraph's own style (probed), beside the master page
// it names.
function applyPageNumberStart(odtBytes: Uint8Array, start: number): Uint8Array {
if (start <= 1) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
const content = strFromU8(contentBytes);
const body = /<office:text\b[^>]*>/.exec(content);
if (!body) return odtBytes;
const first = /<text:(p|h)\b([^>]*)>/.exec(content.slice(body.index + body[0].length));
if (!first) return odtBytes;
const at = body.index + body[0].length + first.index;
const srcM = /text:style-name="([^"]*)"/.exec(first[2]);
const source = srcM ? srcM[1] : 'Standard';
const name = 'PgNumStart';
const def = findAutoStyle(content, source);
const minted = def
? cloneStyleWithParaProps(def, name, `style:page-number="${start}"`)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source}"><style:paragraph-properties style:page-number="${start}"/></style:style>`;
const tag = srcM
? first[0].replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
: `<text:${first[1]} text:style-name="${name}"${first[2]}>`;
files['content.xml'] = strToU8(
injectAutomaticStyles(content.slice(0, at) + tag + content.slice(at + first[0].length), minted),
);
return rezipOdt(files);
}
// The descriptive metadata → meta.xml, which odf-kit already writes (generator +
+2
View File
@@ -623,6 +623,8 @@ const de: Messages = {
noteOptions: 'Optionen',
autoCorrect: 'AutoKorrektur',
hyphenation: 'Silbentrennung',
pageNumberFormat: 'Seitenzahlen',
pageNumberStart: 'Beginnen bei',
hyphenationHint: 'Automatisch trennen',
debugDump: 'Dump',
cellMargins: 'Zellenränder',
+2
View File
@@ -627,6 +627,8 @@ const en = {
noteOptions: 'Options',
autoCorrect: 'AutoCorrect',
hyphenation: 'Hyphenation',
pageNumberFormat: 'Page numbers',
pageNumberStart: 'Start at',
hyphenationHint: 'Hyphenate automatically',
debugDump: 'Dump',
cellMargins: 'Cell margins',
+17
View File
@@ -20,6 +20,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
@@ -228,6 +229,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
// A right-to-left section (w:bidi): the columns fill from the right.
rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(),
hyphenate: docAutoHyphenation(files),
pageNumbering: docxPageNumbering(sectPr),
orientation: sect.orientation,
format: sect.format,
tabIntervalCm: docTabInterval(files),
@@ -2217,6 +2219,21 @@ function docTabInterval(files: Record<string, Uint8Array>): number {
}
}
// w:pgNumType on the first section: how the page-number field counts.
const DOCX_PAGE_NUM_FORMAT: Record<string, NoteNumFormat> = {
decimal: '1', lowerRoman: 'i', upperRoman: 'I', lowerLetter: 'a', upperLetter: 'A',
};
function docxPageNumbering(sectPr: Element | null): PageNumbering {
const el = fc(sectPr, 'pgNumType');
if (!el) return { ...DEFAULT_PAGE_NUMBERING };
const start = intAttr(el, W, 'start');
return {
format: DOCX_PAGE_NUM_FORMAT[el.getAttributeNS(W, 'fmt') ?? ''] ?? '1',
start: start != null ? clampPageStart(start) : 1,
};
}
// Word's Layout ▸ Hyphenation, from settings.xml (absent = off, as in Word).
function docAutoHyphenation(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
+16
View File
@@ -22,6 +22,7 @@ import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/
import type { HfDoc, HfSet } from '../storage/headerFooter';
import type { NoteKind, NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
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';
@@ -52,6 +53,8 @@ export interface OdtImportResult {
rtl: boolean;
// 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).
pageNumbering: PageNumbering;
// The grid every tab past the last custom stop falls on; the format's own fallback
// when the file declares none.
tabIntervalCm: number;
@@ -676,6 +679,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
format: geometry?.format ?? null,
rtl: geometry?.rtl ?? false,
hyphenate: resolver.documentHyphenation(),
pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) },
tabIntervalCm: resolver.defaultTabInterval(),
spacingModel: odfSpacingModel(files),
header,
@@ -821,6 +825,18 @@ function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
return out;
}
// The document's first page number: ODF has no document-level start, so LibreOffice puts
// `style:page-number` on the first paragraph's own style.
function odfPageNumberStart(resolver: StyleResolver, body: Element): number {
for (const el of Array.from(body.children)) {
if (el.namespaceURI !== NS.text || (el.localName !== 'p' && el.localName !== 'h')) continue;
const style = el.getAttributeNS(NS.text, 'style-name');
const n = style ? Number(resolver.paraProps(style)['style:page-number']) : NaN;
return Number.isFinite(n) ? clampPageStart(n) : 1;
}
return 1;
}
// meta.xml → the document's descriptive properties (LibreOffice's File ▸ Properties).
function odfDocProperties(files: Record<string, Uint8Array>): DocProperties {
const bytes = files['meta.xml'];
+9 -1
View File
@@ -3,7 +3,7 @@ import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
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 NoteSettings } from '../storage/noteSettings';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
// Word) spread formatting across named/automatic styles and parent-style-name chains.
@@ -345,6 +345,14 @@ export class StyleResolver {
return this.merged('paragraph', 'Standard').text['fo:hyphenate'] === 'true';
}
// The page-number format, from the page layout of the governing master page.
// ODF's own default is decimal, which is `1` here.
pageNumberFormat(): NoteNumFormat {
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
const f = props?.getAttributeNS(NS.style, 'num-format') ?? '';
return (['1', 'i', 'I', 'a', 'A'] as const).includes(f as NoteNumFormat) ? (f as NoteNumFormat) : '1';
}
// The grid every tab past the last custom stop falls on, from the paragraph
// default-style (Standard may override it). A file that declares none gets ODF's own
// fallback, which is what LibreOffice renders it at.
+1
View File
@@ -11,6 +11,7 @@
`editor.css` turns `--space-before` into padding or margin accordingly (CSS margins can
only collapse, so `'add'` cannot be a margin).
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
- **`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.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
+38
View File
@@ -0,0 +1,38 @@
import type { NoteNumFormat } from './noteSettings';
// How the page-number field counts: Word's Insert ▸ Page Number ▸ Format Page Numbers,
// LibreOffice's page style ▸ Layout Settings. ODF keeps the format on the page layout
// (`style:num-format`) and the start on the first paragraph (`style:page-number`);
// Word keeps both in the section's `w:pgNumType`.
export type PageNumbering = { format: NoteNumFormat; start: number };
const KEY = 'edentext-page-numbering';
export const DEFAULT_PAGE_NUMBERING: PageNumbering = { format: '1', start: 1 };
// The five both word processors offer, in their own order.
export const PAGE_NUM_FORMATS: NoteNumFormat[] = ['1', 'i', 'I', 'a', 'A'];
export function clampPageStart(n: number): number {
return Number.isFinite(n) ? Math.min(9999, Math.max(1, Math.round(n))) : 1;
}
export function loadPageNumbering(): PageNumbering {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_PAGE_NUMBERING };
const data = JSON.parse(raw) as Partial<PageNumbering>;
return {
format: PAGE_NUM_FORMATS.includes(data.format as NoteNumFormat) ? data.format as NoteNumFormat : '1',
start: clampPageStart(Number(data.start)),
};
} catch {
return { ...DEFAULT_PAGE_NUMBERING };
}
}
export function savePageNumbering(value: PageNumbering): void {
if (value.format === '1' && value.start === 1) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, JSON.stringify(value));
}
+29
View File
@@ -1808,3 +1808,32 @@ describe('Leg 14: automatic hyphenation', () => {
check('off round-trips', importDocx(off).hyphenate === false);
});
});
describe('Leg 15: page numbering (format + start value)', () => {
const pnDoc: N = { type: 'doc', content: [P(null, T('erste Seite'))] };
const args = [margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false] as const;
const roman = { format: 'i' as const, start: 7 };
it('ODT: num-format rides the page layout, the start the first paragraph', async () => {
const bytes = await buildOdt(pnDoc, ...args, roman);
const files = unzipSync(bytes);
check('num-format on the layout', strFromU8(files['styles.xml']).includes('style:num-format="i"'));
check('start on the first paragraph', strFromU8(files['content.xml']).includes('style:page-number="7"'));
const back = importOdt(bytes).pageNumbering;
check('round-trips whole', JSON.stringify(back) === JSON.stringify(roman), back);
});
it('DOCX: w:pgNumType carries both', async () => {
const bytes = await buildDocx(pnDoc, ...args, roman);
check('in the sectPr', strFromU8(unzipSync(bytes)['word/document.xml']).includes('lowerRoman'));
const back = importDocx(bytes).pageNumbering;
check('round-trips whole', JSON.stringify(back) === JSON.stringify(roman), back);
});
it('the defaults write nothing', async () => {
const bytes = await buildOdt(pnDoc, ...args, { format: '1', start: 1 });
const files = unzipSync(bytes);
check('no num-format on the layout', !/<style:page-layout-properties[^>]*style:num-format=/.test(strFromU8(files['styles.xml'])));
check('no page-number', !strFromU8(files['content.xml']).includes('style:page-number='));
});
});