feat: a section can restart the page numbering

Word's Start at, per section: ODF writes style:page-number on the
paragraph that switches master page, DOCX a w:pgNumType w:start. That
section is no longer exported as a continuous break — probed, LibreOffice
drops the start off a continuous one, and neither word processor honours
it there. The footer counts from the nearest restart at or above the page.
This commit is contained in:
Steffen Becker committed 2026-08-15 09:49:20 +02:00
1 parent 1c4dec49f3
commit 5f2e7fcbcf
12 files changed
+122 -18

No files matched your search

+1 -1
View File
@@ -131,6 +131,7 @@
- Pictures are autosaved to IndexedDB, not localStorage: the document JSON keeps a short key per picture, so an image-heavy document no longer runs into the ~5 MB quota that used to stop it saving. Each save sweeps the pictures the document has dropped, a picture used twice is stored once, and where IndexedDB is unavailable everything stays inline as before
- Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted
- List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field
- Page numbering restarts per section (Layout ▸ Page number format ▸ This section): the front matter counts i, ii, iii and the body starts at 1 again. Round-trips as ODF `style:page-number` on the paragraph that switches master page and Word's `w:pgNumType w:start` — a section that restarts is exported as a page-starting break, since both word processors ignore a start on a continuous one
- Line numbering (Layout ▸ Line numbers, LibreOffice's Tools ▸ Line Numbering): numbers in the left margin, continuous or restarting each page, every nth line, with or without empty lines. Measured off the rendered lines, so the preview and the saved file agree; round-trips as ODF `text:linenumbering-configuration` and Word's `w:lnNumType`
- Page design (Layout ▸ Page design): a page background colour, a border around the text area at a settable distance from it, and a text watermark with its own colour, angle and transparency — LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark. All three round-trip: ODF keeps them on the page layout and the master page's header, Word on `w:document` / `w:sectPr` / a VML shape in the header part
- Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `<text:sequence>` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field
@@ -151,7 +152,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
**Missing while writing**
- Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
- Restarting the page numbering **per section** (only the document's own format and start value exist)
- A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name
- A section's own **page format** is set only by the file it came from — the ribbon offers the orientation per section, the paper size document-wide. Pages of different widths are left-aligned in the scroll, where both word processors centre each
- A sort dialog's second and third key, and its choice of sort type over the detected one; a formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table
+9 -3
View File
@@ -231,10 +231,16 @@
return { update: apply };
}
// The number a page shows: its position offset by the document's start value, in the
// document's own format.
// The number a page shows, in the document's own format: counted from the nearest
// section at or above it that restarts numbering, else from the document's start.
function pageLabel(page: number): string {
return formatOrdinal(page + pageNumbering.start - 1, pageNumbering.format);
let base = pageNumbering.start;
let from = 1;
for (let i = sectionOf(page); i > 0; i--) {
const start = sets[i]?.pageNumberStart;
if (start != null) { base = start; from = sectionFirstPage(i); break; }
}
return formatOrdinal(page - from + base, pageNumbering.format);
}
// Replace the placeholder text in every page-field span with the real value:
@@ -65,13 +65,22 @@
// Word's "apply to this section": the paper rides the section's own page setup, so
// every other section keeps the document's.
function setSectionOrientation(o: Orientation | null): void {
setSectionProp({ orientation: o });
}
function setSectionProp(props: Partial<HfSet>): void {
if (currentSection < 1) return;
const out = extraHfSections.slice();
while (out.length < currentSection) out.push({ ...EMPTY_HF_SET });
out[currentSection - 1] = { ...out[currentSection - 1], orientation: o };
out[currentSection - 1] = { ...out[currentSection - 1], ...props };
extraHfSections = out;
}
// Where this section restarts the numbering, if it does (Word's "Start at").
let sectionPageStart = $derived(
currentSection > 0 ? extraHfSections[currentSection - 1]?.pageNumberStart ?? null : null,
);
const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
const EDGES = ['top', 'bottom', 'left', 'right'] as const;
@@ -279,6 +288,20 @@
onchange={(e) => (pageNumbering = { ...pageNumbering, start: clampPageStart(Number(e.currentTarget.value)) })}
/>
</label>
{#if currentSection > 0}
<div class="rb-menu-label">{t().ribbon.thisSection}</div>
<label class="num-row">
<input
type="number"
min="1"
max="9999"
placeholder={t().ribbon.pageNumberContinue}
value={sectionPageStart ?? ''}
onchange={(e) => setSectionProp({ pageNumberStart: e.currentTarget.value ? clampPageStart(Number(e.currentTarget.value)) : null })}
/>
</label>
<button onclick={() => { closeMenu(); setSectionProp({ pageNumberStart: null }); }}>{t().ribbon.pageNumberContinue}</button>
{/if}
</div>
{/if}
</div>
+12 -7
View File
@@ -1910,11 +1910,13 @@ 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 } : {}) } }
: {}),
// w:pgNumType. Word restarts numbering at every section carrying a start, so a
// later section gets one only where it really restarts; 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 } : {}) } }
: {})
: (s.pageNumberStart != null ? { pageNumbers: { start: s.pageNumberStart } } : {})),
};
};
// Fresh instances per section (Word's per-sectPr references, i.e. no "Link to
@@ -1968,7 +1970,9 @@ export async function buildDocx(
})) } }
: {}),
// A columns group is its own section; w:type=continuous describes the break
// BEFORE a section, so it goes on every section but the first.
// BEFORE a section, so it goes on every section but the first — except one that
// restarts the page numbering, which both word processors only honour on a section
// that begins a page (probed: LibreOffice drops w:start off a continuous one).
sections: groups.map((g, i) => ({
properties: {
page: pagePropsFor(g.section),
@@ -1981,7 +1985,8 @@ export async function buildDocx(
} }
: {}),
...(setAt(g.section).differentFirstPage ? { titlePage: true } : {}),
...(i > 0 ? { type: SectionType.CONTINUOUS } : {}),
...(i > 0 && !(setAt(g.section).pageNumberStart != null && groups.findIndex((x) => x.section === g.section) === i)
? { type: SectionType.CONTINUOUS } : {}),
...(g.columns
? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } }
: {}),
+7 -2
View File
@@ -4786,9 +4786,14 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount:
const def = source ? findAutoStyle(content, source) : null;
const name = `MP${++counter}`;
const master = ` style:master-page-name="Section${index + 1}"`;
minted.push(def
let style = def
? def.replace(/style:name="[^"]*"/, `style:name="${name}"`).replace(/(<style:style\b[^>]*?)(\/?>)/, `$1${master}$2`)
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"${master}/>`);
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"${master}/>`;
// A section restarting the numbering says so on this same paragraph — ODF has no
// other place for it (applyPageNumberStart writes the document's own that way).
const restart = sets[index]?.pageNumberStart;
if (restart != null) style = upsertProps(style, 'paragraph', { 'style:page-number': String(restart) });
minted.push(style);
const rest = attrs.replace(/\s*text:style-name="[^"]*"/, '');
return `<text:${tag}${rest} text:style-name="${name}">`;
},
+1
View File
@@ -768,6 +768,7 @@ const de: Messages = {
repeatHeaderRowHint: 'Die erste Zeile oben auf jeder Seite wiederholen',
pageNumberFormat: 'Seitenzahlen',
pageNumberStart: 'Beginnen bei',
pageNumberContinue: 'Vom vorherigen Abschnitt fortsetzen',
lineNumbers: 'Zeilennummern',
pageDecor: 'Seitengestaltung',
hyphenationHint: 'Automatisch trennen',
+1
View File
@@ -772,6 +772,7 @@ const en = {
repeatHeaderRowHint: 'Repeat the first row at the top of every page',
pageNumberFormat: 'Page numbers',
pageNumberStart: 'Start at',
pageNumberContinue: 'Continue from the previous section',
lineNumbers: 'Line numbers',
pageDecor: 'Page design',
hyphenationHint: 'Hyphenate automatically',
+4
View File
@@ -2685,8 +2685,12 @@ function sectionHfSets(
// Only a section disagreeing with the document carries its own paper: matching it is
// inheritance, not formatting, exactly as for the margins.
const paper = sectPaper(sect);
// w:pgNumType w:start restarts the numbering at this section; without it it counts
// on. The first section's is the document's own start (read separately).
const pgStart = sect ? intAttr(fc(sect, 'pgNumType'), W, 'start') : null;
out.push({
margins: sectMargins(sect),
pageNumberStart: out.length && pgStart != null ? clampPageStart(pgStart) : null,
format: paper.format && paper.format !== doc.format ? paper.format : null,
orientation: paper.orientation && paper.orientation !== doc.orientation ? paper.orientation : null,
header: zone('header', 'default', prev.header),
+8 -2
View File
@@ -133,6 +133,9 @@ type Ctx = {
pageRtl: boolean;
// Master pages the body switches to, in order — one section each past the first.
masterPages: string[];
// The page number each of those sections restarts at, in the same order (null = it
// counts on). ODF writes it on the paragraph that switches master page.
masterPageStarts: (number | null)[];
// How many body blocks each of them governs: the largest count is the document's own
// page geometry, which is not per section.
masterBlocks: Map<string, number>;
@@ -644,7 +647,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [] };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [] };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -738,7 +741,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
differentOddEven,
hfSections: [
{ header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven },
...ctx.masterPages.map((name) => hfSetOfMasterPage(name, ctx, geometry)),
...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry), pageNumberStart: ctx.masterPageStarts[i] ?? null })),
],
headerDistanceCm: hasHeader ? edge?.top ?? null : null,
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
@@ -1437,6 +1440,9 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
if (ctx.masterBlocks.size) attrs.breakBefore = 'page';
if (ctx.masterPages[ctx.masterPages.length - 1] !== master) {
ctx.masterPages.push(master);
// The same paragraph carries the number the section restarts at, if it does.
const restart = Number(paraProps['style:page-number']);
ctx.masterPageStarts.push(Number.isFinite(restart) ? clampPageStart(restart) : null);
attrs.sectionBreak = true;
}
}
+1 -1
View File
@@ -11,7 +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`.
- **`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`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it).
- **`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. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true.
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
+4 -1
View File
@@ -31,13 +31,16 @@ export type HfSet = {
// document's own.
format?: PageFormat | null;
orientation?: Orientation | null;
// The page number this section restarts at (Word's w:pgNumType start, ODF's
// style:page-number on the paragraph that switches master page). null = it counts on.
pageNumberStart?: number | null;
};
export const EMPTY_HF_SET: HfSet = {
header: null, footer: null,
headerFirst: null, footerFirst: null, differentFirstPage: false,
headerEven: null, footerEven: null, differentOddEven: false,
margins: null, marginsFirst: null, format: null, orientation: null,
margins: null, marginsFirst: null, format: null, orientation: null, pageNumberStart: null,
};
export function hfSetIsEmpty(s: HfSet): boolean {
+50
View File
@@ -0,0 +1,50 @@
// A section of its own can restart the page numbering (Word's w:pgNumType start,
// LibreOffice's style:page-number on the paragraph that switches master page). Only a
// section that really restarts carries one; the rest count on.
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
import { EMPTY_HF_SET } from '../../src/lib/storage/headerFooter';
type N = any;
const P = (text: string, attrs?: N): N =>
({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] });
// Three sections; only the third restarts, at page 1 (a front matter / body split).
const doc: N = {
type: 'doc',
content: [P('front matter'), P('body', { sectionBreak: true }), P('appendix', { sectionBreak: true })],
};
const zone: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'pageNumber' }] }] };
const sets = [
{ ...EMPTY_HF_SET, footer: zone },
{ ...EMPTY_HF_SET, footer: zone },
{ ...EMPTY_HF_SET, footer: zone, pageNumberStart: 1 },
];
const hf = { header: null, footer: zone, sections: sets, pageCount: 3 };
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const starts = (r: N) => (r.hfSections ?? []).map((s: N) => s.pageNumberStart ?? null);
describe('per-section page numbering', () => {
it('round-trips through ODF', async () => {
const bytes = await buildOdt(doc, margins, 'portrait', hf as never);
const xml = strFromU8(unzipSync(bytes)['content.xml']);
expect(xml).toContain('style:page-number="1"');
expect(starts(importOdt(bytes))).toEqual([null, null, 1]);
});
it('round-trips through DOCX', async () => {
const bytes = await buildDocx(doc, margins, 'portrait', hf as never);
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toContain('<w:pgNumType w:start="1"');
// A continuous section is one both word processors ignore the start on (probed),
// so the restarting section is the one break that is not continuous.
expect(xml.match(/<w:type w:val="continuous"\/>/g)).toHaveLength(1);
expect(starts(importDocx(bytes))).toEqual([null, null, 1]);
});
});