Files
edentext/src/lib/import/styleResolver.ts
T
Steffen Becker 0c1eb2626c fix: a section's zones sit at the distance its own page setup gives them
The edge→zone distance was document-wide, so an index whose header starts 4cm
down had its body open 14.5mm too high and the page under it followed. HfSet
carries the section's own pair (and its first page's, where the page style hands
over): ODF's page-layout margin is that distance, Word's is w:pgMar
w:header/w:footer, and both exports write it back.
2026-09-04 19:30:08 +02:00

913 lines
45 KiB
TypeScript

import { fitMargins, 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';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { DEFAULT_WATERMARK, normalizePageDecor, type PageDecor, type Watermark } from '../storage/pageDecor';
import { DEFAULT_LINE_NUMBERING, normalizeLineNumbering, type LineNumbering } from '../storage/lineNumbering';
// LibreOffice's (and Word's) name for the watermark shape — what tells it apart from an
// ordinary drawing in the header.
export const WATERMARK_NAME = 'PowerPlusWaterMarkObject';
// 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',
dc: 'http://purl.org/dc/elements/1.1/',
meta: 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
} 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 declaring
// just one does not inherit the other: the missing one falls back to the default
// paragraph style's value (probed on heading margins).
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);
// Not a property element but an attribute of the style itself, and it inherits along
// the parent chain like one — a cell's number format is a reference to a data style.
const dataStyle = el.getAttributeNS(NS.style, 'data-style-name');
if (dataStyle) entry.misc['style:data-style-name'] = dataStyle;
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 namedListNames = 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;
}
// A style's own style:parent-style-name — the parent the registry gives it in place of
// the built-in's (collectStyleSheet). Null where the file declares neither.
styleParent(styleName: string | null, family: 'paragraph' | 'text' = 'paragraph'): string | null {
return (styleName && this.styles.get(`${family}\0${styleName}`)?.parent) ?? null;
}
// 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);
// A list style in <office:styles> is a named one (an automatic style is the
// direct formatting of one list); its display name decodes like the others'.
if (name && named) {
this.namedListNames.add(name);
const display = el.getAttributeNS(NS.style, 'display-name');
if (display) this.displayNames.set(name, display);
}
} else if (el.namespaceURI === NS.number
&& ['date-style', 'time-style', 'number-style', 'percentage-style', 'currency-style'].includes(el.localName)) {
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 : {};
}
// A frame style's own paragraph properties, which is where its writing mode lives
// (probed: in the graphic properties LibreOffice drops it).
graphicParaProps(styleName: string | null): PropMap {
return styleName ? this.merged('graphic', styleName).para : {};
}
// 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'] ?? '' };
}
// Automatic hyphenation, from the same style — ODF counts it a text property, and
// LibreOffice keeps it there rather than document-wide, so that style's value is
// what "the document hyphenates" means.
documentHyphenation(): boolean {
return this.merged('paragraph', 'Standard').text['fo:hyphenate'] === 'true';
}
// The page-number format, from the page layout of the governing master page.
// ODF's own default is decimal, which is `1` here.
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';
}
// 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;
}
// The data style a cell style points at (style:data-style-name), which is where a
// cell's number format lives — the cell element itself carries none.
cellDataStyle(styleName: string | null): string | null {
if (!styleName) return null;
return this.merged('table-cell', styleName).misc['style:data-style-name'] ?? null;
}
// A cell style's style:vertical-align, where it is not the ODF default (top).
cellVerticalAlign(styleName: string | null): 'middle' | 'bottom' | null {
if (!styleName) return null;
const v = this.merged('table-cell', styleName).misc['style:vertical-align'];
return v === 'middle' ? 'middle' : v === 'bottom' ? 'bottom' : 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;
}
// Whether a list style came from <office:styles> — a named Listenformatvorlage
// rather than one list's direct formatting.
isNamedListStyle(name: string | null): boolean {
return !!name && this.namedListNames.has(name);
}
// Its display name ("List_20_Number" → the file's own "List Number"), decoded like
// the paragraph families' — the caller decodes _20_ itself when the file names none.
listStyleDisplayName(name: string): string | undefined {
return this.displayNames.get(name);
}
// 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 };
}
// <text:notes-configuration> per class, plus <style:footnote-sep> off the page
// layout. Everything the file leaves out keeps the editor's (= LibreOffice's) default.
noteSettings(): NoteSettings {
const doc = this.stylesDoc;
if (!doc) return DEFAULT_NOTE_SETTINGS;
const out: NoteSettings = {
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote },
endnote: { ...DEFAULT_NOTE_SETTINGS.endnote },
separator: { ...DEFAULT_NOTE_SETTINGS.separator },
};
for (const el of Array.from(doc.getElementsByTagNameNS(NS.text, 'notes-configuration'))) {
const kind: NoteKind = el.getAttributeNS(NS.text, 'note-class') === 'endnote' ? 'endnote' : 'footnote';
const cls = out[kind];
const fmt = el.getAttributeNS(NS.style, 'num-format');
if (fmt === '1' || fmt === 'a' || fmt === 'A' || fmt === 'i' || fmt === 'I') cls.numFormat = fmt;
// ODF counts from 0, the dialog from the number the first note shows.
const start = Number(el.getAttributeNS(NS.text, 'start-value'));
if (Number.isFinite(start)) cls.startAt = Math.max(1, start + 1);
const restart = el.getAttributeNS(NS.text, 'start-numbering-at');
if (restart === 'page' || restart === 'chapter' || restart === 'document') cls.restart = restart;
if (el.getAttributeNS(NS.text, 'footnotes-position') === 'document') cls.position = 'document';
cls.prefix = el.getAttributeNS(NS.style, 'num-prefix') ?? '';
cls.suffix = el.getAttributeNS(NS.style, 'num-suffix') ?? '';
const body = el.getAttributeNS(NS.text, 'default-style-name');
if (body) cls.bodyStyle = this.displayNames.get(body) ?? body.replace(/_20_/g, ' ');
const cite = el.getAttributeNS(NS.text, 'citation-style-name');
if (cite) cls.citationStyle = this.displayNames.get(cite) ?? cite.replace(/_20_/g, ' ');
}
const sep = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'footnote-sep')[0];
if (sep) {
const num = (v: string | null) => (v == null ? null : lengthToCm(v));
const width = num(sep.getAttributeNS(NS.style, 'width'));
if (width != null) out.separator.weightPt = Math.round((width / 2.54) * 72 * 100) / 100;
const above = num(sep.getAttributeNS(NS.style, 'distance-before-sep'));
if (above != null) out.separator.spaceAboveCm = Math.round(above * 1000) / 1000;
const below = num(sep.getAttributeNS(NS.style, 'distance-after-sep'));
if (below != null) out.separator.spaceBelowCm = Math.round(below * 1000) / 1000;
const rel = parseFloat(sep.getAttributeNS(NS.style, 'rel-width') ?? '');
if (Number.isFinite(rel)) out.separator.relWidthPercent = rel;
const align = sep.getAttributeNS(NS.style, 'adjustment');
if (align === 'left' || align === 'center' || align === 'right') out.separator.align = align;
const color = sep.getAttributeNS(NS.style, 'color');
if (color && /^#[0-9a-fA-F]{6}$/.test(color)) out.separator.color = color;
}
return out;
}
// 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);
// Present but empty is LibreOffice's explicit "no page style change", so it ends
// the walk instead of letting an ancestor's break through to every child style.
const name = el?.getAttributeNS(NS.style, 'master-page-name');
if (name != null) return name || null;
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
}
return null;
}
private pageLayoutEl(pageName: string | null = null): Element | null {
const doc = this.stylesDoc;
const layoutName = this.masterPageEl(pageName)?.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;
headerBandCm: number;
footerBandCm: number;
firstPageOnly: boolean;
restPage: string | null;
mirrorPair: boolean;
} {
const mp = this.masterPageEl(pageName);
const layout = this.pageLayoutEl(pageName);
// 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;
let rest = successor ?? mp;
// Two masters naming each other are the left/right pair of a mirrored book, not a
// hand-over: LibreOffice's own Left Page / Right Page. The right one governs the odd
// pages, the left one becomes the even variant, and neither ends the section.
const twin = this.mirrorTwin(rest);
// The master named here is itself one half of the pair — as opposed to a title page
// handing over *to* the pair, which really does govern one page only.
const pair = !!twin && (rest === mp || twin === mp);
const leftPage = twin
? (this.pageUsage(rest) === 'left' ? rest : this.pageUsage(twin) === 'left' ? twin : null)
: null;
if (leftPage === rest) rest = twin;
const handover = pair ? null : successor;
const zone = (local: string) => zoneIn(rest, local);
const firstZone = (local: string): Element | null =>
zoneIn(mp, `${local}-first`) ?? (handover ? zoneIn(mp, local) : null);
const bandProps = (local: 'header-style' | 'footer-style'): Element | null => {
if (!layout) return null;
for (const child of Array.from(layout.children)) {
if (child.namespaceURI === NS.style && child.localName === local) {
return child.getElementsByTagNameNS(NS.style, 'header-footer-properties')[0] ?? null;
}
}
return null;
};
// The band LibreOffice lays out is max(declared height, zone content + its gap), and
// the zone's own fo:padding counts on both sides of that content — but a *dynamic*
// gap is absorbed instead of added, so there the band is max(height, content).
// Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: false → 11.37 / 21.11 / 35.71
// for 1 / 3 / 6 lines, true → 7.51 / 14.60 / 29.21.
// `extraCm` is the part known without laying the zone out; `bandCm` what its content
// grows by.
const gapCm = (props: Element, spacingAttr: 'margin-bottom' | 'margin-top'): number =>
(props.getAttributeNS(NS.style, 'dynamic-spacing') === 'true'
? 0 : lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0);
const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
const props = bandProps(local);
if (!props) return 0;
const height = lengthToCm(props.getAttributeNS(NS.svg, 'height'))
?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height'))
?? 0;
return Math.max(height, gapCm(props, spacingAttr));
};
const bandCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
const props = bandProps(local);
if (!props) return 0;
const pad = lengthToCm(props.getAttributeNS(NS.fo, 'padding'));
const padTop = lengthToCm(props.getAttributeNS(NS.fo, 'padding-top')) ?? pad ?? 0;
const padBottom = lengthToCm(props.getAttributeNS(NS.fo, 'padding-bottom')) ?? pad ?? 0;
return gapCm(props, spacingAttr) + padTop + padBottom;
};
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; the
// left master of a mirrored pair is that variant in full.
headerLeft: zoneIn(rest, 'header-left') ?? zoneIn(leftPage, 'header'),
footerLeft: zoneIn(rest, 'footer-left') ?? zoneIn(leftPage, 'footer'),
headerExtraCm: extraCm('header-style', 'margin-bottom'),
footerExtraCm: extraCm('footer-style', 'margin-top'),
headerBandCm: bandCm('header-style', 'margin-bottom'),
footerBandCm: bandCm('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: !!handover,
// The master every page after the first uses — also where their geometry comes from.
restPage: rest !== mp ? rest?.getAttributeNS(NS.style, 'name') ?? null : null,
mirrorPair: pair,
};
}
private pageUsage(mp: Element | null): string {
const name = mp?.getAttributeNS(NS.style, 'name') ?? null;
return name ? this.pageLayoutEl(name)?.getAttributeNS(NS.style, 'page-usage') ?? '' : '';
}
// The master that names this one back through style:next-style-name — the other half
// of a left/right pair. A one-way chain is the title-page hand-over instead.
private mirrorTwin(mp: Element | null): Element | null {
const name = mp?.getAttributeNS(NS.style, 'name');
const next = mp?.getAttributeNS(NS.style, 'next-style-name');
if (!name || !next || next === name) return null;
const twin = this.masterPageEl(next);
return twin?.getAttributeNS(NS.style, 'next-style-name') === name ? twin : null;
}
// 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(pageName: string | null = null): { margins: PageMargins; orientation: Orientation; format: PageFormat; rtl: boolean } | null {
const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
const hf = this.masterPageHF(pageName);
// A page border + its fo:padding sit inside the margin and push the text in; the
// editor keeps text at the margin (like w:pgBorders offsetFrom="text"), so the
// shrunken ODF margins are read back grown by that inset (inverse of the export).
const inset = this.decorInsetCm(props);
const cm = (attr: string, fallback: number, extra = 0) => {
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
if (v == null) return fallback;
return Math.max(0, Math.round((v + extra + inset) * 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),
// Mirrored: fo:margin-left is the *inner* margin, so an even (left-hand) page
// swaps the pair. The declared values stay the odd page's, as the file writes them.
left: cm('margin-left', 2.12),
right: cm('margin-right', 2.12),
};
// A page style that is one side of a book mirrors as surely as one marked
// "mirrored" — but only where the two masters really alternate: style:page-usage
// ="right" on a single layout is a page that starts on the right, nothing more,
// and swapping its pair would draw it at the wrong margin (probed).
const usage = this.pageLayoutEl(pageName)?.getAttributeNS(NS.style, 'page-usage');
if (usage === 'mirrored' || ((usage === 'left' || usage === 'right') && hf.mirrorPair)) {
margins.mirrored = true;
if (usage === 'left') [margins.left, margins.right] = [margins.right, margins.left];
}
const w = lengthToCm(props.getAttributeNS(NS.fo, 'page-width'));
const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height'));
const fitted = fitMargins(margins, w ?? 21, h ?? 29.7);
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
const format: PageFormat = w != null && h != null ? (formatFromCm(w, h) ?? 'A4') : 'A4';
// style:writing-mode: rl-tb is a right-to-left page — the columns fill from the
// right and the text's base direction is RTL. The vertical modes are not read.
const rtl = (props.getAttributeNS(NS.style, 'writing-mode') ?? '').startsWith('rl');
return { margins: fitted, orientation, format, rtl };
}
// The document's chapter numbering — one <text:outline-style> in styles.xml.
outlineStyle(): Element | null {
return this.stylesDoc?.getElementsByTagNameNS(NS.text, 'outline-style')[0] ?? null;
}
// The page's own decoration (storage/pageDecor.ts): background and border ride the
// page layout, the watermark is a fontwork shape in the master page's header —
// exactly where LibreOffice keeps all three (probed).
pageDecor(pageName: string | null = null): PageDecor {
const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
const bg = props?.getAttributeNS(NS.fo, 'background-color') ?? null;
const border = props?.getAttributeNS(NS.fo, 'border')
?? props?.getAttributeNS(NS.fo, 'border-top') ?? null;
// '<width> <style> <color>' — the same shorthand a paragraph border uses.
const parts = border && border !== 'none' ? border.trim().split(/\s+/) : null;
const widthPt = parts ? lengthToPt(parts[0]) : null;
return normalizePageDecor({
background: bg && bg !== 'transparent' ? bg : null,
border: parts && widthPt
? { widthPt: Math.round(widthPt * 100) / 100, color: parts[2] ?? '#000000',
paddingCm: lengthToCm(props?.getAttributeNS(NS.fo, 'padding')) ?? 0 }
: null,
watermark: this.watermark(pageName),
});
}
private watermark(pageName: string | null): Watermark | null {
const master = this.masterPageEl(pageName);
const shapes = master?.getElementsByTagNameNS(NS.draw, 'custom-shape') ?? [];
const shape = Array.from(shapes).find((s) => s.getAttributeNS(NS.draw, 'name')?.startsWith(WATERMARK_NAME));
if (!shape) return null;
const text = (shape.textContent ?? '').trim();
if (!text) return null;
const gfx = this.graphicProps(shape.getAttributeNS(NS.draw, 'style-name'));
const opacity = parseFloat(String(gfx['draw:opacity'] ?? '100'));
// ODF stores the rotation in radians, counter-clockwise.
const rot = /rotate\s*\(\s*(-?[\d.eE+-]+)\s*\)/.exec(shape.getAttributeNS(NS.draw, 'transform') ?? '');
const font = this.merged('paragraph', shape.getAttributeNS(NS.draw, 'text-style-name') ?? '').text['fo:font-family'];
return {
text,
font: typeof font === 'string' ? font.replace(/^['"]|['"]$/g, '') : DEFAULT_WATERMARK.font,
color: String(gfx['draw:fill-color'] ?? DEFAULT_WATERMARK.color),
angle: rot ? Math.round((parseFloat(rot[1]) * 180) / Math.PI) : 0,
transparency: Number.isFinite(opacity) ? Math.round(100 - opacity) : 0,
};
}
// <text:linenumbering-configuration> in office:styles: one document-wide element, and
// its absence is what "not numbered" means. LibreOffice omits every attribute at its
// ODF default, so each one falls back to that (probed).
lineNumbering(): LineNumbering {
const cfg = this.stylesDoc?.getElementsByTagNameNS(NS.text, 'linenumbering-configuration')[0] ?? null;
if (!cfg || cfg.getAttributeNS(NS.text, 'number-lines') === 'false') return DEFAULT_LINE_NUMBERING;
return normalizeLineNumbering({
on: true,
interval: Number(cfg.getAttributeNS(NS.text, 'increment')) || DEFAULT_LINE_NUMBERING.interval,
distanceCm: lengthToCm(cfg.getAttributeNS(NS.text, 'offset')) ?? DEFAULT_LINE_NUMBERING.distanceCm,
restart: cfg.getAttributeNS(NS.text, 'restart-on-page') === 'true' ? 'page' : 'continuous',
countEmpty: cfg.getAttributeNS(NS.text, 'count-empty-lines') !== 'false',
});
}
// 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(pageName: string | null = null): { top: number; bottom: number } | null {
const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
const inset = this.decorInsetCm(props);
const room = (lengthToCm(props.getAttributeNS(NS.fo, 'page-height')) ?? 29.7) - 1;
const cm = (attr: string) => {
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
return v == null ? null : Math.min(room, Math.max(0, Math.round((v + inset) * 100) / 100));
};
return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 };
}
// Border width + fo:padding of the page's own border, 0 without one — the amount ODF
// pushed the text in past the declared margin (pageGeometry adds it back).
private decorInsetCm(props: Element): number {
const border = props.getAttributeNS(NS.fo, 'border') ?? props.getAttributeNS(NS.fo, 'border-top');
if (!border || border === 'none') return 0;
const widthPt = lengthToPt(border.trim().split(/\s+/)[0]) ?? 0;
return (lengthToCm(props.getAttributeNS(NS.fo, 'padding')) ?? 0) + (widthPt * 2.54) / 72;
}
}