From 862ba22df2d5f09b7a31d330cdfb1e8969817f44 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 2 Sep 2026 22:41:03 +0200 Subject: [PATCH] feat: a section numbers its pages in its own format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roman front matter before a decimal body is one page style before another in ODF and one w:pgNumType per section in Word, not a document setting — read as one, its pages printed 1, 3 where the reference prints i, iii. HfSet grows pageNumberFormat, both exports write it back, and the DOCX importer takes the document's own format from the first section, as it already does the paper. --- src/lib/components/HeaderFooterLayer.svelte | 7 ++++--- src/lib/export/docx.ts | 8 +++++++- src/lib/export/odt.ts | 9 ++++++++- src/lib/import/docx.ts | 13 ++++++++++--- src/lib/import/odt.ts | 12 +++++++++--- src/lib/import/styleResolver.ts | 4 ++-- src/lib/storage/headerFooter.ts | 7 ++++++- tests/roundtrip.test.ts | 20 ++++++++++++++++++++ 8 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 0f6ad85..c3094b0 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -251,8 +251,9 @@ return { update: apply }; } - // 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. + // The number a page shows, in its section's format (a roman front matter) or the + // document's: counted from the nearest section at or above it that restarts + // numbering, else from the document's start. function pageLabel(page: number): string { let base = pageNumbering.start; let from = 1; @@ -260,7 +261,7 @@ const start = sets[i]?.pageNumberStart; if (start != null) { base = start; from = sectionFirstPage(i); break; } } - return formatOrdinal(page - from + base, pageNumbering.format); + return formatOrdinal(page - from + base, sets[sectionOf(page)]?.pageNumberFormat ?? pageNumbering.format); } // Replace the placeholder text in every page-field span with the real value: diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 3f6de56..ccfc19a 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2670,11 +2670,17 @@ export async function buildDocx( }, // 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. + // w:fmt is per section as well, so a roman front matter keeps its own. ...(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 } } : {})), + : (s.pageNumberStart != null || s.pageNumberFormat + ? { pageNumbers: { + ...(s.pageNumberFormat ? { formatType: DOCX_PAGE_NUM_FORMAT[s.pageNumberFormat] } : {}), + ...(s.pageNumberStart != null ? { start: s.pageNumberStart } : {}), + } } + : {})), }; }; // Fresh instances per section (Word's per-sectPr references, i.e. no "Link to diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 9ba6777..96ac836 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -5436,7 +5436,8 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: const layoutFor = (index: number, set: HfSet): string => { const m = set.margins; const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null; - if ((!m && !paper) || !layoutXml) return layout; + const numFormat = set.pageNumberFormat ?? null; + if ((!m && !paper && !numFormat) || !layoutXml) return layout; const name = `${layout}Sec${index + 1}`; layouts.push(layoutXml .replace(`style:name="${layout}"`, `style:name="${name}"`) @@ -5455,6 +5456,12 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: .replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`) .replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`); } + // The page-number format rides the layout, which is where LibreOffice keeps it. + if (numFormat) { + p = /style:num-format="/.test(p) + ? p.replace(/style:num-format="[^"]*"/, `style:num-format="${numFormat}"`) + : p.replace(' !colsOnly[gi]).map((g) => g.sectPr ?? finalSectPr), ctx, oddEven, docPaper); + groups.filter((_, gi) => !colsOnly[gi]).map((g) => g.sectPr ?? finalSectPr), ctx, oddEven, + { ...docPaper, numFormat: docNumbering.format }); const first = hfSections[0]; // A first-page/even zone reserves the header/footer band even when its default is empty; // the distance is document-wide, so any section having one is enough. @@ -266,7 +270,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = foldMarks: docxFoldMarks(files), hyphenate: docAutoHyphenation(files), recordChanges: docRecordsChanges(files), - pageNumbering: docxPageNumbering(sectPr), + pageNumbering: docNumbering, orientation: docPaper.orientation, format: docPaper.format, tabIntervalCm: docTabInterval(files), @@ -3045,7 +3049,7 @@ function sectMargins(sect: Element | null): PageMargins | null { // section, odd/even is document-wide (settings.xml). function sectionHfSets( sectPrs: (Element | null)[], ctx: Ctx, oddEven: boolean, - doc: { format: PageFormat | null; orientation: Orientation | null }, + doc: { format: PageFormat | null; orientation: Orientation | null; numFormat: NoteNumFormat }, ): HfSet[] { const out: HfSet[] = []; for (const sect of sectPrs) { @@ -3064,9 +3068,12 @@ function sectionHfSets( // 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; + // w:fmt is per section too — a roman front matter before a decimal body. + const pgFmt = DOCX_PAGE_NUM_FORMAT[fc(sect, 'pgNumType')?.getAttributeNS(W, 'fmt') ?? ''] ?? '1'; out.push({ margins: sectMargins(sect), pageNumberStart: out.length && pgStart != null ? clampPageStart(pgStart) : null, + pageNumberFormat: out.length && pgFmt !== doc.numFormat ? pgFmt : 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), diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 435da03..b5e6995 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -27,7 +27,7 @@ import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; -import { NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings'; +import { NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings'; import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { clampPageStart, type PageNumbering } from '../storage/pageNumbering'; import { newCommentId } from '../editor/extensions/comment'; @@ -825,6 +825,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = } } + const docNumFormat = resolver.pageNumberFormat(); const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx, hf.headerBandCm) : null; const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx, hf.footerBandCm, true) : null; // The presence of a first-page element is the flag, even when it's empty (an empty @@ -852,7 +853,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = foldMarks: ctx.foldMarks, hyphenate: resolver.documentHyphenation(), recordChanges: odfRecordChanges(body), - pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) }, + pageNumbering: { format: docNumFormat, start: odfPageNumberStart(resolver, body) }, tabIntervalCm: resolver.defaultTabInterval(), spacingModel: odfSpacingModel(files), header, @@ -865,7 +866,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = differentOddEven, hfSections: [ { header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven }, - ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry), pageNumberStart: ctx.masterPageStarts[i] ?? null })), + ...ctx.masterPages.map((name, i) => ({ ...hfSetOfMasterPage(name, ctx, geometry, docNumFormat), pageNumberStart: ctx.masterPageStarts[i] ?? null })), ], headerDistanceCm: hasHeader ? edge?.top ?? null : null, footerDistanceCm: hasFooter ? edge?.bottom ?? null : null, @@ -882,6 +883,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = function hfSetOfMasterPage( name: string, ctx: Ctx, doc: { orientation: Orientation; format: PageFormat } | null, + docNumFormat: NoteNumFormat, ): HfSet { const hf = ctx.resolver.masterPageHF(name); const zone = (el: Element | null, footer = false) => @@ -897,9 +899,13 @@ function hfSetOfMasterPage( // The paper of the layout governing the section's body. Only a section disagreeing // with the document carries it — matching is inheritance, as it is for the margins. const paper = restGeo ?? geo; + // The page-number format rides the layout governing the body, like the margins: a + // roman front matter is one master before the decimal body's, not a document setting. + const numFormat = ctx.resolver.pageNumberFormat(hf.restPage ?? name); return { margins: rest, marginsFirst: hf.restPage ? own : null, + pageNumberFormat: numFormat !== docNumFormat ? numFormat : null, format: paper && doc && paper.format !== doc.format ? paper.format : null, orientation: paper && doc && paper.orientation !== doc.orientation ? paper.orientation : null, header: zone(hf.header), diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 3102646..47346fa 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -378,8 +378,8 @@ export class StyleResolver { // 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; + pageNumberFormat(pageName: string | null = null): NoteNumFormat { + const props = this.pageLayoutEl(pageName)?.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'; } diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index 275e3ae..21af68b 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -5,6 +5,7 @@ import type { PageMargins } from './pageMargins'; import type { PageFormat } from './pageFormat'; import type { Orientation } from './pageOrientation'; +import type { NoteNumFormat } from './noteSettings'; export type HfZone = 'header' | 'footer'; export type HfVariant = 'default' | 'first' | 'even'; @@ -34,13 +35,17 @@ export type HfSet = { // 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; + // How its page-number field counts, where the section disagrees with the document + // (roman front matter before decimal body). null = the document's own format. + pageNumberFormat?: NoteNumFormat | 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, pageNumberStart: null, + margins: null, marginsFirst: null, format: null, orientation: null, + pageNumberStart: null, pageNumberFormat: null, }; // The six zone docs of a set, in the order their measured heights travel from diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 686cdf4..cfabe61 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1853,6 +1853,26 @@ describe('Leg 12: per-section page margins (ODT + DOCX)', () => { const back = res.hfSections?.[1]?.margins ?? null; check('section margins round-trip', JSON.stringify(back) === JSON.stringify(wide), back); }); + + // A roman front matter before a decimal body: the format is the section's, not the + // document's, in both formats (ODF style:num-format, Word w:pgNumType w:fmt). + const romanSections = [{ ...EMPTY_HF_SET }, { ...EMPTY_HF_SET, pageNumberFormat: 'i' as const }]; + + it('ODT: the section layout carries style:num-format', async () => { + const bytes = await buildOdt(secDoc, margins, 'portrait', { sections: romanSections, pageCount: 2 }); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + check('the minted layout is roman', /style:name="[^"]*Sec2"[^>]*>\s*]*style:num-format="i"/.test(styles.replace(/\n/g, '')), styles.slice(0, 300)); + const res = importOdt(bytes); + check('document format unchanged', res.pageNumbering?.format === '1', res.pageNumbering); + check('section format round-trips', res.hfSections?.[1]?.pageNumberFormat === 'i', res.hfSections?.[1]); + }); + + it('DOCX: the later sectPr carries w:pgNumType w:fmt', async () => { + const bytes = await buildDocx(secDoc, margins, 'portrait', { sections: romanSections, pageCount: 2 }); + const res = importDocx(bytes); + check('document format unchanged', res.pageNumbering?.format === '1', res.pageNumbering); + check('section format round-trips', res.hfSections?.[1]?.pageNumberFormat === 'i', res.hfSections?.[1]); + }); }); describe('Leg 13: document properties (meta.xml / docProps/core.xml)', () => {