Files
edentext/src/lib/import/styleResolver.ts
T
Steffen Becker d6bd53d971 fix: a header or footer the zone schema can't hold renders anyway
A hyperlink in a fixture's copyright footer threw generateHTML and
blanked the whole zone. Drop what the one-paragraph schema lacks, keep
its text. A master handing over to a successor is also the different-first-
page flag, so a title master with no zones leaves page one blank.
2026-08-10 08:54:53 +00:00

645 lines
29 KiB
TypeScript

import type { PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
// Word) spread formatting across named/automatic styles and parent-style-name chains.
// This flattens those into per-style property maps so import/odt.ts reads effective values.
export const NS = {
office: 'urn:oasis:names:tc:opendocument:xmlns:office:1.0',
style: 'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
text: 'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
table: 'urn:oasis:names:tc:opendocument:xmlns:table:1.0',
fo: 'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
svg: 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
draw: 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
xlink: 'http://www.w3.org/1999/xlink',
number: 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0',
loext: 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0',
math: 'http://www.w3.org/1998/Math/MathML',
} as const;
// One embedded-font binary referenced from a <style:font-face>: the CSS family runs use,
// the package href of the font file, and the face's weight/style.
export interface EmbeddedFontSource {
family: string;
href: string;
weight: 'normal' | 'bold';
style: 'normal' | 'italic';
}
// Property keys are stored as "alias:localName" for the namespaces we care
// about; attributes from other namespaces are dropped.
const ALIAS_BY_URI = new Map<string, string>(Object.entries(NS).map(([alias, uri]) => [uri, alias]));
export type PropMap = Record<string, string>;
type StyleEntry = {
parent: string | null;
text: PropMap; // style:text-properties
para: PropMap; // style:paragraph-properties
misc: PropMap; // table-column / table-row / table-cell properties
};
// ---- length units -----------------------------------------------------------
const PT_PER_UNIT: Record<string, number> = {
pt: 1,
cm: 72 / 2.54,
mm: 72 / 25.4,
in: 72,
px: 72 / 96,
pc: 12,
};
export function lengthToPt(s: string | null | undefined): number | null {
if (!s) return null;
const m = /^(-?[\d.]+)\s*(pt|cm|mm|in|px|pc)?$/.exec(s.trim());
if (!m) return null;
const v = parseFloat(m[1]);
if (!Number.isFinite(v)) return null;
return v * PT_PER_UNIT[m[2] ?? 'pt'];
}
export function lengthToCm(s: string | null | undefined): number | null {
const pt = lengthToPt(s);
return pt == null ? null : (pt / 72) * 2.54;
}
// Layer text props, treating the two font attrs (fo:font-family /
// style:font-name) as one logical property: whichever form the nearer style
// uses must shadow both inherited forms.
export function layerTextProps(base: PropMap, over: PropMap): PropMap {
const out = { ...base };
if ('fo:font-family' in over || 'style:font-name' in over) {
delete out['fo:font-family'];
delete out['style:font-name'];
}
return Object.assign(out, over);
}
// LibreOffice holds a paragraph's two vertical margins in one item, so a style that
// declares just one of them does not inherit the other — the missing one falls back to
// the default paragraph style's value (probed: heading margins, and the space after a
// paragraph that only sets fo:margin-top).
const VERTICAL_MARGINS = ['fo:margin-top', 'fo:margin-bottom'] as const;
export function layerParaProps(base: PropMap, over: PropMap, poolDefaults: PropMap): PropMap {
const out = Object.assign({}, base, over);
if (VERTICAL_MARGINS.some((k) => k in over)) {
for (const k of VERTICAL_MARGINS) if (!(k in over)) out[k] = poolDefaults[k] ?? '0cm';
}
return out;
}
// The size a percentage font-size is relative to when nothing is inherited yet
// (ODF's implied default, matching the editor's body size).
const BASE_FONT_SIZE_PT = 12;
// LibreOffice defines its heading styles relatively (`fo:font-size="130%"`), so a
// percentage must be resolved against the inherited size while walking the parent
// chain — a raw "130%" would be dropped and the file's size lost.
function resolvePercentSize(text: PropMap, inherited: string | undefined): void {
const size = text['fo:font-size'];
if (!size || !size.trim().endsWith('%')) return;
const pct = parseFloat(size);
if (!Number.isFinite(pct)) return;
const base = lengthToPt(inherited) ?? BASE_FONT_SIZE_PT;
text['fo:font-size'] = `${Math.round(base * pct) / 100}pt`;
}
// ---- resolver ----------------------------------------------------------------
function collectProps(el: Element, propsLocalName: string, into: PropMap): void {
for (const child of Array.from(el.children)) {
if (child.namespaceURI !== NS.style || child.localName !== propsLocalName) continue;
for (const attr of Array.from(child.attributes)) {
const alias = attr.namespaceURI ? ALIAS_BY_URI.get(attr.namespaceURI) : undefined;
if (alias) into[`${alias}:${attr.localName}`] = attr.value;
}
}
}
function entryFromStyleElement(el: Element): StyleEntry {
const entry: StyleEntry = {
parent: el.getAttributeNS(NS.style, 'parent-style-name'),
text: {},
para: {},
misc: {},
};
collectProps(el, 'text-properties', entry.text);
collectProps(el, 'paragraph-properties', entry.para);
collectProps(el, 'table-properties', entry.misc);
collectProps(el, 'table-column-properties', entry.misc);
collectProps(el, 'table-row-properties', entry.misc);
collectProps(el, 'table-cell-properties', entry.misc);
collectProps(el, 'graphic-properties', entry.misc);
return entry;
}
// style:leader-style, for a file that names the line kind but no leader text.
const ODF_LEADER: Record<string, string> = { dotted: '.', dash: '-', solid: '_' };
export class StyleResolver {
// (family + '\0' + name) → entry; later registrations win (content.xml
// automatic styles override styles.xml ones on name collision).
private styles = new Map<string, StyleEntry>();
private defaults = new Map<string, StyleEntry>(); // family → style:default-style
private fontFaces = new Map<string, string>(); // style:name → first font-family
private fontSources: EmbeddedFontSource[] = []; // embedded font binaries
private listStyles = new Map<string, Element>(); // style:name → text:list-style
private numberStyles = new Map<string, Element>(); // style:name → number:date/time-style
// Section styles kept as raw elements: <style:columns> is a child element of
// <style:section-properties>, which the attribute-only StyleEntry drops.
private sectionStyleEls = new Map<string, Element>();
// Paragraph styles likewise, for the child element <style:tab-stops>.
private paraStyleEls = new Map<string, Element>();
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
private defaultMaster: string | null = null;
private namedParagraphNames = new Set<string>();
private namedTextNames = new Set<string>();
private namedTableNames = new Set<string>();
private displayNames = new Map<string, string>();
constructor(contentDoc: Document, stylesDoc: Document | null) {
this.stylesDoc = stylesDoc;
// Scan order (later wins): styles.xml named → styles.xml automatic →
// content.xml named → content.xml automatic.
const containers: Element[] = [];
for (const doc of [stylesDoc, contentDoc]) {
if (!doc) continue;
for (const local of ['styles', 'automatic-styles']) {
const el = doc.getElementsByTagNameNS(NS.office, local)[0];
if (el) containers.push(el);
}
const fontDecls = doc.getElementsByTagNameNS(NS.office, 'font-face-decls')[0];
if (fontDecls) this.scanFontFaces(fontDecls);
}
for (const c of containers) this.scanContainer(c, c.localName === 'styles');
}
// Paragraph styles from <office:styles> — the file's named styles (as opposed to the
// automatic styles, which are direct formatting). Own props only; the chain stays a chain.
namedParagraphStyles(): Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }> {
const out = new Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }>();
for (const name of this.namedParagraphNames) {
const entry = this.styles.get(`paragraph\0${name}`);
// display: only the file's own style:display-name — the caller decodes _20_ itself.
if (entry) out.set(name, { parent: entry.parent, display: this.displayNames.get(name), text: entry.text, para: entry.para });
}
return out;
}
// The nearest named style in a style's parent chain (an automatic style is direct
// formatting layered on top of it); null when the chain reaches none.
namedAncestor(styleName: string | null | undefined, family: 'paragraph' | 'text' | 'table' = 'paragraph'): string | null {
const named = family === 'text' ? this.namedTextNames : family === 'table' ? this.namedTableNames : this.namedParagraphNames;
let cur = styleName ?? null;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
seen.add(cur);
if (named.has(cur)) return cur;
cur = this.styles.get(`${family}\0${cur}`)?.parent ?? null;
}
return null;
}
// Named text (character) styles from <office:styles>, own props only.
namedTextStyles(): Map<string, { display?: string; text: PropMap }> {
const out = new Map<string, { display?: string; text: PropMap }>();
for (const name of this.namedTextNames) {
const entry = this.styles.get(`text\0${name}`);
if (entry) out.set(name, { display: this.displayNames.get(name), text: entry.text });
}
return out;
}
private scanFontFaces(container: Element): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI !== NS.style || el.localName !== 'font-face') continue;
const name = el.getAttributeNS(NS.style, 'name');
if (!name) continue;
// svg:font-family may be a quoted list: 'Liberation Serif', 'Times New Roman'
const family = el.getAttributeNS(NS.svg, 'font-family');
const first = family ? family.split(',')[0].trim().replace(/^['"]|['"]$/g, '') : '';
if (first) this.fontFaces.set(name, first);
const cssFamily = first || name;
for (const uri of Array.from(el.getElementsByTagNameNS(NS.svg, 'font-face-uri'))) {
const href = uri.getAttributeNS(NS.xlink, 'href');
if (!href) continue;
const weight = uri.getAttributeNS(NS.loext, 'font-weight') === 'bold' ? 'bold' : 'normal';
const style = uri.getAttributeNS(NS.loext, 'font-style') === 'italic' ? 'italic' : 'normal';
this.fontSources.push({ family: cssFamily, href, weight, style });
}
}
}
// Embedded font binaries (one per <svg:font-face-uri>); importOdt maps href → bytes.
embeddedFontSources(): readonly EmbeddedFontSource[] {
return this.fontSources;
}
private scanContainer(container: Element, named: boolean): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI === NS.style && el.localName === 'style') {
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.style, 'family');
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
if (name && named && (family === 'paragraph' || family === 'text' || family === 'table')) {
if (family === 'paragraph') this.namedParagraphNames.add(name);
else if (family === 'table') this.namedTableNames.add(name);
else this.namedTextNames.add(name);
const display = el.getAttributeNS(NS.style, 'display-name');
if (display) this.displayNames.set(name, display);
}
if (name && family === 'section') this.sectionStyleEls.set(name, el);
if (name && family === 'paragraph') this.paraStyleEls.set(name, el);
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
const family = el.getAttributeNS(NS.style, 'family');
if (family) this.defaults.set(family, entryFromStyleElement(el));
} else if (el.namespaceURI === NS.text && el.localName === 'list-style') {
const name = el.getAttributeNS(NS.style, 'name');
if (name) this.listStyles.set(name, el);
} else if (el.namespaceURI === NS.number && (el.localName === 'date-style' || el.localName === 'time-style')) {
const name = el.getAttributeNS(NS.style, 'name');
if (name) this.numberStyles.set(name, el);
}
}
}
// Flattened props along the parent chain (nearest wins), rooted in the
// family's default style.
private merged(family: string, name: string): { text: PropMap; para: PropMap; misc: PropMap } {
const cacheKey = `${family}\0${name}`;
const hit = this.mergedCache.get(cacheKey);
if (hit) return hit;
const chain: StyleEntry[] = [];
const seen = new Set<string>();
let cur: string | null = name;
while (cur && !seen.has(cur)) {
seen.add(cur);
const entry = this.styles.get(`${family}\0${cur}`);
if (!entry) break;
chain.push(entry);
cur = entry.parent;
}
const def = this.defaults.get(family);
if (def) chain.push(def);
const result = { text: {} as PropMap, para: {} as PropMap, misc: {} as PropMap };
const poolDefaults = def?.para ?? {};
// Apply root-first so nearer definitions overwrite.
for (const entry of chain.reverse()) {
const inherited = result.text['fo:font-size'];
result.text = layerTextProps(result.text, entry.text);
resolvePercentSize(result.text, inherited);
result.para = entry === def ? { ...entry.para } : layerParaProps(result.para, entry.para, poolDefaults);
Object.assign(result.misc, entry.misc);
}
this.mergedCache.set(cacheKey, result);
return result;
}
// Effective text props of a paragraph/heading style (runs without character
// styles inherit these).
paraTextProps(styleName: string | null): PropMap {
return styleName ? this.merged('paragraph', styleName).text : (this.defaults.get('paragraph')?.text ?? {});
}
// Effective paragraph-layout props (alignment, margins, line-height).
paraProps(styleName: string | null): PropMap {
return styleName ? this.merged('paragraph', styleName).para : (this.defaults.get('paragraph')?.para ?? {});
}
// Explicitly-set text props of a character (text:span) style chain; layered
// over the paragraph's by the importer.
spanTextProps(styleName: string | null): PropMap {
return styleName ? this.merged('text', styleName).text : {};
}
// Graphic-properties of a draw:frame's style (style:wrap, positioning, …).
graphicProps(styleName: string | null): PropMap {
return styleName ? this.merged('graphic', styleName).misc : {};
}
// The document's default spell-check language, read from the base Standard
// paragraph style (falls back to the paragraph default-style). null when unset.
documentLanguage(): { language: string; country: string } | null {
const props = this.merged('paragraph', 'Standard').text;
const language = props['fo:language'];
if (!language || language === 'none') return null;
return { language, country: props['fo:country'] ?? '' };
}
// The grid every tab past the last custom stop falls on, from the paragraph
// default-style (Standard may override it). A file that declares none gets ODF's own
// fallback, which is what LibreOffice renders it at.
defaultTabInterval(): number {
return lengthToCm(this.merged('paragraph', 'Standard').para['style:tab-stop-distance']) ?? ODF_IMPLIED_TAB_CM;
}
// Resolve a text-props map's font: fo:font-family wins, else style:font-name
// through the font-face declarations.
fontFamilyOf(props: PropMap): string | null {
const fam = props['fo:font-family'];
if (fam) return fam.split(',')[0].trim().replace(/^['"]|['"]$/g, '') || null;
const name = props['style:font-name'];
if (name) return this.fontFaces.get(name) ?? name;
return null;
}
// A table style's raw <style:table-properties> (style:width, fo:margin-*, table:align).
tableProps(styleName: string | null): PropMap {
return styleName ? this.merged('table', styleName).misc : {};
}
columnWidthCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-column', styleName).misc['style:column-width']);
}
// style:rel-column-width="21846*" — LibreOffice's proportional column form.
columnRelWidth(styleName: string | null): number | null {
if (!styleName) return null;
const raw = this.merged('table-column', styleName).misc['style:rel-column-width'];
if (!raw) return null;
const v = parseFloat(raw);
return Number.isFinite(v) && v > 0 ? v : null;
}
rowMinHeightCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-row', styleName).misc['style:min-row-height']);
}
// A cell style's fo:background-color (cell shading). null when none/transparent.
cellBackgroundColor(styleName: string | null): string | null {
if (!styleName) return null;
const c = this.merged('table-cell', styleName).misc['fo:background-color'];
return c && c !== 'transparent' && c !== 'none' ? c : null;
}
// A cell style's raw fo:border-* values (per-side overriding the fo:border
// shorthand). Sides the style doesn't declare stay undefined (= no border in ODF).
cellBorders(styleName: string | null): Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> {
if (!styleName) return {};
const misc = this.merged('table-cell', styleName).misc;
const all = misc['fo:border'];
const pick = (side: string) => misc[`fo:border-${side}`] ?? all;
const out: Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> = {};
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
const v = pick(side);
if (v != null) out[side] = v;
}
return out;
}
// A cell style's padding (the cell margin) as [top, right, bottom, left] cm, per-side
// overriding the fo:padding shorthand. A side nobody declares comes back null.
cellPadding(styleName: string | null): (number | null)[] {
if (!styleName) return [null, null, null, null];
const misc = this.merged('table-cell', styleName).misc;
const all = misc['fo:padding'];
return (['top', 'right', 'bottom', 'left'] as const)
.map((side) => lengthToCm(misc[`fo:padding-${side}`] ?? all));
}
listStyle(name: string | null): Element | null {
return name ? this.listStyles.get(name) ?? null : null;
}
// The <number:date-style>/<number:time-style> element a date/time field references.
numberStyle(name: string | null): Element | null {
return name ? this.numberStyles.get(name) ?? null : null;
}
// Tab stops of a paragraph style, in cm from the left text margin (the origin Word
// and ODF share). ODF replaces the list rather than merging it, so the nearest
// declaration in the parent chain wins outright.
tabStops(styleName: string | null): TabStop[] {
const seen = new Set<string>();
let cur = styleName;
while (cur && !seen.has(cur)) {
seen.add(cur);
const el = this.paraStyleEls.get(cur);
const list = el?.getElementsByTagNameNS(NS.style, 'tab-stops')[0];
if (list) {
const out: TabStop[] = [];
for (const stop of Array.from(list.getElementsByTagNameNS(NS.style, 'tab-stop'))) {
const pos = lengthToCm(stop.getAttributeNS(NS.style, 'position'));
if (pos == null) continue;
const type = stop.getAttributeNS(NS.style, 'type');
const align: TabAlign = type === 'char' ? 'decimal'
: type === 'center' || type === 'right' ? type : 'left';
// The fill character is the leader text; style:leader-style stands in where
// the file names only the line kind (LibreOffice writes both).
const leader = stop.getAttributeNS(NS.style, 'leader-text')
?? ODF_LEADER[stop.getAttributeNS(NS.style, 'leader-style') ?? ''] ?? null;
out.push({ pos, align, leader: normalizeLeader(leader) });
}
return out;
}
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
}
return [];
}
// A section style's column layout: { count, gapCm } when it declares more than one
// column, else null.
sectionColumns(name: string | null): { count: number; gapCm: number } | null {
const el = name ? this.sectionStyleEls.get(name) : null;
const props = el?.getElementsByTagNameNS(NS.style, 'section-properties')[0];
return this.columnsFrom(props ?? null);
}
// Column layout of the master page's page layout — Word-produced files put
// whole-document columns here instead of in a text:section.
pageColumns(): { count: number; gapCm: number } | null {
return this.columnsFrom(this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null);
}
// <style:columns> under a properties element: { count, gapCm } when it declares more
// than one column, else null. The gap falls back to LibreOffice's per-column indent
// form (first column's fo:end-indent + second's fo:start-indent).
private columnsFrom(props: Element | null): { count: number; gapCm: number } | null {
const cols = props?.getElementsByTagNameNS(NS.style, 'columns')[0];
if (!cols) return null;
const count = parseInt(cols.getAttributeNS(NS.fo, 'column-count') ?? '', 10);
if (!Number.isFinite(count) || count <= 1) return null;
let gap = lengthToCm(cols.getAttributeNS(NS.fo, 'column-gap'));
if (gap == null) {
const colEls = Array.from(cols.getElementsByTagNameNS(NS.style, 'column'));
if (colEls.length >= 2) {
const end = lengthToCm(colEls[0].getAttributeNS(NS.fo, 'end-indent')) ?? 0;
const start = lengthToCm(colEls[1].getAttributeNS(NS.fo, 'start-indent')) ?? 0;
if (end + start > 0) gap = end + start;
}
}
const gapCm = Math.min(5, Math.max(0, gap ?? 0.5));
return { count, gapCm: Math.round(gapCm * 100) / 100 };
}
// The master page governing the document (prefer "Standard", else the first), or a
// named one — a section past the first points at its own (style:master-page-name).
private masterPageEl(name: string | null = null): Element | null {
const doc = this.stylesDoc;
if (!doc) return null;
const pages = Array.from(doc.getElementsByTagNameNS(NS.style, 'master-page'));
const want = name ?? this.defaultMaster;
if (want) {
const found = pages.find(p => p.getAttributeNS(NS.style, 'name') === want);
if (found || name) return found ?? null;
}
return pages.find(p => p.getAttributeNS(NS.style, 'name') === 'Standard') ?? pages[0] ?? null;
}
// The master page the body actually uses. Page geometry is document-wide, so it has
// to come from that one: a file whose "Standard" master no paragraph references would
// otherwise take a geometry — and a header/footer band — no page of it has.
setDefaultMaster(name: string | null): void {
this.defaultMaster = name;
}
// The master page a paragraph style switches to, walking style:parent-style-name.
masterPageOf(styleName: string | null): string | null {
const seen = new Set<string>();
let cur = styleName;
while (cur && !seen.has(cur)) {
seen.add(cur);
const el = this.paraStyleEls.get(cur);
const name = el?.getAttributeNS(NS.style, 'master-page-name');
if (name) return name;
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
}
return null;
}
private pageLayoutEl(): Element | null {
const doc = this.stylesDoc;
const layoutName = this.masterPageEl()?.getAttributeNS(NS.style, 'page-layout-name');
if (!doc || !layoutName) return null;
for (const layout of Array.from(doc.getElementsByTagNameNS(NS.style, 'page-layout'))) {
if (layout.getAttributeNS(NS.style, 'name') === layoutName) return layout;
}
return null;
}
// Header/footer of the master page: the content elements, the vertical space the
// zone occupies below/above the page margin (height + body-side spacing, for the
// body-margin reconstruction), and whether per-page variants exist.
masterPageHF(pageName: string | null = null): {
header: Element | null;
footer: Element | null;
headerFirst: Element | null;
footerFirst: Element | null;
headerLeft: Element | null;
footerLeft: Element | null;
headerExtraCm: number;
footerExtraCm: number;
firstPageOnly: boolean;
} {
const mp = this.masterPageEl(pageName);
const layout = this.pageLayoutEl();
// Match by local name in either namespace: header-first/footer-first are ODF 1.3
// style: or older LibreOffice loext:, so both producers round-trip.
const zoneIn = (owner: Element | null, local: string): Element | null => {
if (!owner) return null;
for (const child of Array.from(owner.children)) {
if (child.localName === local && (child.namespaceURI === NS.style || child.namespaceURI === NS.loext)) return child;
}
return null;
};
// A master page naming a different style:next-style-name governs one page and then
// hands over — the "different first page" idiom. Its own zones become the first-page
// variants, the successor's the ones every later page in the section uses.
const nextName = mp?.getAttributeNS(NS.style, 'next-style-name') ?? null;
const successor = nextName && nextName !== mp?.getAttributeNS(NS.style, 'name')
? this.masterPageEl(nextName)
: null;
const rest = successor ?? mp;
const zone = (local: string) => zoneIn(rest, local);
const firstZone = (local: string): Element | null =>
zoneIn(mp, `${local}-first`) ?? (successor ? zoneIn(mp, local) : null);
const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
if (!layout) return 0;
let props: Element | null = null;
for (const child of Array.from(layout.children)) {
if (child.namespaceURI === NS.style && child.localName === local) {
props = child.getElementsByTagNameNS(NS.style, 'header-footer-properties')[0] ?? null;
break;
}
}
if (!props) return 0;
const height = lengthToCm(props.getAttributeNS(NS.svg, 'height'))
?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height'))
?? 0;
// style:dynamic-spacing="true" makes the header↔body gap collapsible (the header
// grows into it), so the body isn't pushed down by it. Only fixed (non-dynamic)
// spacing is added to the body margin.
const dynamic = props.getAttributeNS(NS.style, 'dynamic-spacing') === 'true';
const spacing = dynamic ? 0 : (lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0);
return height + spacing;
};
return {
header: zone('header'),
footer: zone('footer'),
headerFirst: firstZone('header'),
footerFirst: firstZone('footer'),
// Even-page variant (Word odd/even). ODF header-left = the left (even) page.
headerLeft: zoneIn(rest, 'header-left'),
footerLeft: zoneIn(rest, 'footer-left'),
headerExtraCm: extraCm('header-style', 'margin-bottom'),
footerExtraCm: extraCm('footer-style', 'margin-top'),
// Handing over to a successor is itself the "different first page" flag: a title
// master with no zones of its own leaves page one deliberately blank.
firstPageOnly: !!successor,
};
}
// Page margins + orientation + format from the master page's layout. With a
// header/footer the body margin is page margin + zone height + spacing (inverse of
// the export mapping). Format is matched from fo:page-width/height (fallback A4).
pageGeometry(): { margins: PageMargins; orientation: Orientation; format: PageFormat } | null {
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
const hf = this.masterPageHF();
const cm = (attr: string, fallback: number, extra = 0) => {
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
if (v == null) return fallback;
return Math.min(10, Math.max(0, Math.round((v + extra) * 100) / 100));
};
const margins: PageMargins = {
top: cm('margin-top', 2.54, hf.header ? hf.headerExtraCm : 0),
bottom: cm('margin-bottom', 2.54, hf.footer ? hf.footerExtraCm : 0),
left: cm('margin-left', 2.12),
right: cm('margin-right', 2.12),
};
const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width'));
const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height'));
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
const format: PageFormat = w != null && h != null ? (formatFromCm(w, h) ?? 'A4') : 'A4';
return { margins, orientation, format };
}
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
// the top and footer distance from the bottom when a header/footer is present.
edgeDistancesCm(): { top: number; bottom: number } | null {
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
const cm = (attr: string) => {
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
return v == null ? null : Math.min(10, Math.max(0, Math.round(v * 100) / 100));
};
return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 };
}
}