feat: a section numbers its pages in its own format
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.
This commit is contained in:
1 parent
ad12231bf0
commit
862ba22df2
8 files changed
+66
-14
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<style:page-layout-properties ', `<style:page-layout-properties style:num-format="${numFormat}" `);
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
return name;
|
||||
|
||||
+10
-3
@@ -247,8 +247,12 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
// The paper is the *first* section's, as the margins are: a document whose last
|
||||
// section is one landscape page is not a landscape document.
|
||||
const docPaper = sectPaper(groups[0]?.sectPr ?? finalSectPr);
|
||||
// The *first* section's, as the paper and the margins are: a document whose front
|
||||
// matter is roman is not a roman document, and vice versa.
|
||||
const docNumbering = docxPageNumbering(groups[0]?.sectPr ?? finalSectPr);
|
||||
const hfSections = sectionHfSets(
|
||||
groups.filter((_, gi) => !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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:page-layout-properties[^>]*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)', () => {
|
||||
|
||||
Reference in new issue
Block a user