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)', () => {