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

+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));
}