fix: the page geometry is the body's master before the body is read

The dominant master was elected after convertBlocks, so the text width a
table's margins, an inline image's fit and the tab-stop suppression measure
against was whatever master the file declares first (17.0cm against a real
14.7cm on one file). A pre-pass over the body's paragraphs elects it up
front, and the post-conversion election is gone so the two cannot disagree.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:05 +02:00
1 parent a7cf09a6be
commit 0cf43de1b1
1 file changed
+42 -21
+42 -21
View File
@@ -149,9 +149,9 @@ type Ctx = {
// 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>;
// Body blocks walked so far: only the document's first has nothing above it to break
// from when a style names a master page.
bodyBlocks: number;
// Bookmark ranges open at this point of the walk, outermost first; a range may end in
// a later paragraph than it started in, so the set outlives one convertInline call.
openBookmarks: Set<string>;
@@ -770,13 +770,15 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display));
const charStyleNames = new Map<string, string>();
for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display));
// Text width of the file's own page setup — the reference a table's margins are
// measured against (falls back to A4 with the ODF default margins).
// Page geometry is document-wide, so it comes from the master governing most of the
// body — elected before the walk, because the text width it yields is what a table's
// margins, an inline image's fit and the tab-stop suppression measure against.
resolver.setDefaultMaster(dominantMasterPage(Array.from(body.children), resolver));
const geo = resolver.pageGeometry();
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(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], bodyBlocks: 0, openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -800,18 +802,8 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
})) });
}
// Page geometry — margins, format, and the band a header/footer reserves — is
// document-wide, so it comes from the master governing most of the body rather than
// from one the file merely declares (`geo` above only sized the tables).
let dominant = '';
let governed = 0;
for (const [name, count] of ctx.masterBlocks) {
if (count > governed) { dominant = name; governed = count; }
}
resolver.setDefaultMaster(dominant || null);
const hf = resolver.masterPageHF();
const geometry = resolver.pageGeometry() ?? geo;
const geometry = geo;
const edge = resolver.edgeDistancesCm();
const fonts: EmbeddedFont[] = [];
@@ -1630,7 +1622,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// Naming a master *is* a page break, even where the page already uses that one
// (probed) — which is how a book starts every chapter on a fresh page. Only the
// document's first block has nothing above it to break from.
if (ctx.masterBlocks.size) attrs.breakBefore = 'page';
if (ctx.bodyBlocks) 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.
@@ -1639,9 +1631,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
attrs.sectionBreak = true;
}
}
// '' = the file's own default master, which the blocks before the first switch use.
const governing = kind === 'body' ? ctx.masterPages[ctx.masterPages.length - 1] ?? '' : null;
if (governing != null) ctx.masterBlocks.set(governing, (ctx.masterBlocks.get(governing) ?? 0) + 1);
if (kind === 'body') ctx.bodyBlocks++;
// Tab stops live in a child element of the paragraph properties, so they come from
// the resolver's own walk rather than the flattened paraProps.
const stops = formatTabStops(resolver.tabStops(styleName));
@@ -2475,6 +2465,37 @@ function formatPt(v: number): string {
// The heading elements of a <text:list> whose leaves are all headings (each list-item
// holds only a text:h and/or nested such lists) — ODF outline/chapter numbering, not a
// real list, so they import as plain headings. null when it's a genuine list.
// The master page governing the most body blocks. Mirrors convertBlocks' body-level
// dispatch: only a paragraph or heading can name one, and '' is the file's own default.
function dominantMasterPage(elements: Element[], resolver: StyleResolver): string | null {
const counts = new Map<string, number>();
let current = '';
const walk = (els: Element[]): void => {
for (const el of els) {
if (el.namespaceURI !== NS.text) continue;
if (el.localName === 'p' || el.localName === 'h') {
current = resolver.masterPageOf(el.getAttributeNS(NS.text, 'style-name')) ?? current;
counts.set(current, (counts.get(current) ?? 0) + 1);
} else if (el.localName === 'list') {
const headings = outlineHeadingEls(el);
if (headings) walk(headings);
} else if (el.localName === 'section') {
walk(Array.from(el.children));
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
if (indexBody) walk(Array.from(indexBody.children));
}
}
};
walk(elements);
let dominant = '';
let governed = 0;
for (const [name, count] of counts) {
if (count > governed) { dominant = name; governed = count; }
}
return dominant || null;
}
function outlineHeadingEls(listEl: Element): Element[] | null {
const out: Element[] = [];
for (const item of Array.from(listEl.children)) {