Files
edentext/src/lib/import/styleResolver.ts
T
2026-06-13 15:20:24 +02:00

323 lines
13 KiB
TypeScript

import type { PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
// Resolves ODF style indirection for the importer: every producer (our export,
// LibreOffice re-saves, Word) spreads formatting across named styles, automatic
// styles, and parent-style-name chains. This module flattens those chains into
// per-style property maps so import/odt.ts can read effective values directly.
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',
} as const;
// 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);
}
// ---- 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-column-properties', entry.misc);
collectProps(el, 'table-row-properties', entry.misc);
collectProps(el, 'table-cell-properties', entry.misc);
return entry;
}
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 listStyles = new Map<string, Element>(); // style:name → text:list-style
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
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);
}
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');
const family = el.getAttributeNS(NS.svg, 'font-family');
if (name && family) {
// svg:font-family may be a quoted list: 'Liberation Serif', 'Times New Roman'
const first = family.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
if (first) this.fontFaces.set(name, first);
}
}
}
private scanContainer(container: Element): 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));
} 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);
}
}
}
// 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 };
// Apply root-first so nearer definitions overwrite.
for (const entry of chain.reverse()) {
result.text = layerTextProps(result.text, entry.text);
Object.assign(result.para, entry.para);
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 : {};
}
// 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;
}
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']);
}
listStyle(name: string | null): Element | null {
return name ? this.listStyles.get(name) ?? null : null;
}
// The master page governing the document (prefer "Standard", else the first).
private masterPageEl(): Element | null {
const doc = this.stylesDoc;
if (!doc) return null;
const pages = Array.from(doc.getElementsByTagNameNS(NS.style, 'master-page'));
return pages.find(p => p.getAttributeNS(NS.style, 'name') === 'Standard') ?? pages[0] ?? 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
// Word-style body-margin reconstruction), and whether per-page variants exist.
masterPageHF(): {
header: Element | null;
footer: Element | null;
headerExtraCm: number;
footerExtraCm: number;
hasVariants: boolean;
} {
const mp = this.masterPageEl();
const layout = this.pageLayoutEl();
const zone = (local: string): Element | null => {
if (!mp) return null;
for (const child of Array.from(mp.children)) {
if (child.namespaceURI === NS.style && child.localName === local) return child;
}
return 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;
const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0;
return height + spacing;
};
return {
header: zone('header'),
footer: zone('footer'),
headerExtraCm: extraCm('header-style', 'margin-bottom'),
footerExtraCm: extraCm('footer-style', 'margin-top'),
hasVariants: ['header-first', 'footer-first', 'header-left', 'footer-left'].some(l => zone(l) != null),
};
}
// Page margins + orientation from the master page's layout. With a header/footer,
// ODF's vertical margin ends at the zone — the editor's body margin is margin +
// zone height + spacing (inverse of the export's Word-style mapping). Page *size*
// is intentionally ignored — the editor is A4-only.
pageGeometry(): { margins: PageMargins; orientation: Orientation } | 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';
return { margins, orientation };
}
}