diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83630b6..690fbe4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,10 +36,10 @@
- page orientation
- tables
- undo history
+- ODT import / open existing file
- ...
### Not yet implemented
-- ODT import / open existing file
- Images
- Custom right-click context menu (formatting, cut/copy)
- Multi-document management
diff --git a/package-lock.json b/package-lock.json
index f9e5acb..c0c9cb1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@tiptap/extension-bullet-list": "^3.23.6",
"@tiptap/extension-document": "^3.23.6",
"@tiptap/extension-font-family": "^3.23.6",
+ "@tiptap/extension-hard-break": "^3.23.6",
"@tiptap/extension-heading": "^3.23.6",
"@tiptap/extension-highlight": "3.23.6",
"@tiptap/extension-history": "^3.23.6",
@@ -513,6 +514,19 @@
"@tiptap/extension-text-style": "3.23.6"
}
},
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.23.6",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.6.tgz",
+ "integrity": "sha512-KeUm+tkUfIVSX9QM9XOIhaay0Fn36sLKUo5NVYjN3uJaxFvaZXZmTlxdO85OTdgF2P5sqh9LomrIgliaFRGk4w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.23.6"
+ }
+ },
"node_modules/@tiptap/extension-heading": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.6.tgz",
diff --git a/package.json b/package.json
index 36062dc..9c809b6 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"@tiptap/extension-bullet-list": "^3.23.6",
"@tiptap/extension-document": "^3.23.6",
"@tiptap/extension-font-family": "^3.23.6",
+ "@tiptap/extension-hard-break": "^3.23.6",
"@tiptap/extension-heading": "^3.23.6",
"@tiptap/extension-highlight": "3.23.6",
"@tiptap/extension-history": "^3.23.6",
diff --git a/src/App.svelte b/src/App.svelte
index cac89a9..ea60d92 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -4,6 +4,7 @@
import Toolbar from './lib/editor/Toolbar.svelte';
import ToolbarExpanded from './lib/editor/ToolbarExpanded.svelte';
import { exportToOdt } from './lib/export/odt';
+ import { importOdt } from './lib/import/odt';
import { getPageBreakDebug } from './lib/editor/pageBreaks';
import { getColorDebug } from './lib/editor/colorDebug';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
@@ -64,6 +65,38 @@
if (editor) await exportToOdt(editor, pageMargins, pageOrientation);
}
+ let fileInput: HTMLInputElement | null = $state(null);
+
+ async function handleImportFile(e: Event) {
+ const input = e.target as HTMLInputElement;
+ const file = input.files?.[0];
+ input.value = ''; // allow re-selecting the same file
+ if (!file || !editor) return;
+
+ try {
+ const result = importOdt(new Uint8Array(await file.arrayBuffer()));
+
+ const hasContent = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
+ if (hasContent && !confirm('Opening this file will replace the current document. Continue?')) {
+ return;
+ }
+
+ editor.commands.setContent(result.content); // onUpdate fires → autosave
+ // Adopt the document's page geometry; the $effects persist it and
+ // Editor.svelte re-paginates.
+ if (result.margins) pageMargins = result.margins;
+ if (result.orientation) pageOrientation = result.orientation;
+
+ if (result.warnings.length) {
+ console.warn('[import] Unsupported content in opened file:', result.warnings);
+ alert(`Opened with limitations:\n• ${result.warnings.join('\n• ')}`);
+ }
+ } catch (err) {
+ console.error('[import] Failed to open file:', err);
+ alert(err instanceof Error ? err.message : 'Could not open this file.');
+ }
+ }
+
function handleDebugDump() {
if (!editor) return;
const snapshot = getPageBreakDebug(editor.view);
@@ -167,6 +200,16 @@
Debug
{/if}
+
+
@@ -310,6 +353,21 @@
font-weight: 400;
}
+ .file-input {
+ display: none;
+ }
+
+ /* Compound selector: must outweigh the later .export-btn rules. */
+ .export-btn.open-btn {
+ background: transparent;
+ color: var(--color-text);
+ border: 1px solid var(--color-border);
+ }
+
+ .export-btn.open-btn:hover:not(:disabled) {
+ background: var(--color-btn-hover);
+ }
+
.export-btn {
margin-left: 0;
margin-right: 0;
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index 2d73d7d..6dae3c7 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -11,6 +11,7 @@ import Highlight from '@tiptap/extension-highlight';
import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
import { FontWeight } from './fontWeight';
import { FontColor } from './fontColor';
+import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import BulletList from '@tiptap/extension-bullet-list';
import { OrderedList } from './orderedList';
@@ -48,6 +49,9 @@ export const extensions = [
// odf-kit exports that natively to fo:background-color. See export/odt.ts
// applyRuns for the custom-attr-paragraph export path.
Highlight.configure({ multicolor: true }),
+ // Shift+Enter line breaks; round-trips to ODF (export/odt.ts
+ // replaceHardBreaks, import/odt.ts).
+ HardBreak,
LineHeight,
ParagraphSpacing,
Heading.configure({ levels: [1, 2, 3] }),
diff --git a/src/lib/editor/orderedListTypes.ts b/src/lib/editor/orderedListTypes.ts
index 495f059..b4d2e34 100644
--- a/src/lib/editor/orderedListTypes.ts
+++ b/src/lib/editor/orderedListTypes.ts
@@ -39,3 +39,10 @@ const BY_KEY = new Map(ORDERED_LIST_TYPES.map(t => [t.ke
export function orderedTypeDef(key: string | null | undefined): OrderedTypeDef {
return BY_KEY.get(key ?? DEFAULT_ORDERED_TYPE) ?? BY_KEY.get(DEFAULT_ORDERED_TYPE)!;
}
+
+// Reverse lookup for the ODT importer: ODF numbering attrs → listStyleType key.
+// Unknown formats (e.g. "1.1.", figure numbering) fall back to decimal.
+export function orderedTypeFromFormat(numFormat: string | null, numSuffix: string | null): OrderedListType {
+ const match = ORDERED_LIST_TYPES.find(t => t.numFormat === numFormat && t.numSuffix === (numSuffix ?? '.'));
+ return match?.key ?? DEFAULT_ORDERED_TYPE;
+}
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 131c4ec..4e271fb 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -37,6 +37,11 @@ const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in
// char: never in user text, and passes odf-kit's XML escaper (only & < >) untouched.
const SEG = '';
+// Sentinel for hardBreak nodes: replaceHardBreaks turns them into text so every
+// odf-kit path (plain paragraphs, list items, cells) carries them; applyLineBreaks
+// swaps the char for in content.xml. U+E001 (SEG is U+E000).
+const LBR = '';
+
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
@@ -46,7 +51,8 @@ const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
// Heading sizes/margins shown in the editor (editor.css); odf-kit's Heading_20_N
// defaults are larger, so we rewrite them on export. Margins are the editor's em
// values (top 1.5em, bottom 0.5em) resolved against each heading's font size, in cm.
-const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
+// Exported: import/odt.ts uses these as the defaults to suppress on re-import.
+export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
{ name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' },
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.741cm', marginBottom: '0.247cm' },
@@ -61,6 +67,14 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
}
+// odf-kit's native run handling ignores the fontWeight textStyle attr (used to
+// un-bold heading text), so such blocks must go through applyRuns via CUST_P/_H.
+function hasFontWeightRun(content: TiptapNode['content']): boolean {
+ return !!content?.some(c =>
+ c.type === 'text' && c.marks?.some(m => m.type === 'textStyle' && m.attrs?.fontWeight),
+ );
+}
+
function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
// Rename tables so odf-kit routes them to our unknownNodeHandler (see
// CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT
@@ -75,7 +89,7 @@ function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
// Don't rename paragraphs inside list items or table cells — tiptapToOdt's list
// builder (and our table handler) walk them by type === "paragraph"; renaming
// breaks that.
- if (!inContainer && hasCustomAttrs(node.attrs)) {
+ if (!inContainer && (hasCustomAttrs(node.attrs) || hasFontWeightRun(node.content))) {
if (node.type === 'paragraph') return { ...node, type: CUST_P };
if (node.type === 'heading') return { ...node, type: CUST_H };
}
@@ -89,6 +103,17 @@ function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
return node;
}
+// Turn hardBreak nodes into LBR-sentinel text nodes so they ride through every
+// odf-kit serialization path as plain run text; applyLineBreaks rewrites the char
+// to in content.xml afterwards.
+function replaceHardBreaks(node: TiptapNode): TiptapNode {
+ if (!node.content?.length) return node;
+ return {
+ ...node,
+ content: node.content.map(c => c.type === 'hardBreak' ? { type: 'text', text: LBR } : replaceHardBreaks(c)),
+ };
+}
+
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
function normalizeLineHeight(lh: number | string): string {
@@ -325,6 +350,77 @@ function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | nu
return rezipOdt(files);
}
+// odf-kit emits nested lists as bare sharing the top-level L# style,
+// so a nested list of a *different* kind/format (e.g. ordered inside bullets)
+// loses its type. Give those nested lists their own minted 6-level list style —
+// the same pattern applyCellBlocks uses for in-cell lists.
+type ListDef = { ordered: boolean; numFormat: string; numSuffix: string };
+
+function listDefOf(node: TiptapNode): ListDef {
+ const ordered = node.type === 'orderedList';
+ const def = ordered ? orderedTypeDef(node.attrs?.listStyleType as string | undefined) : null;
+ return { ordered, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.' };
+}
+
+// One entry per nested in DFS order (null = inherits its governing
+// style correctly). Only walks top-level lists — cell lists never emit bare tags.
+function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): void {
+ const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean) => {
+ let gov = governing;
+ if (!isTop) {
+ const def = listDefOf(list);
+ const differs = def.ordered !== governing.ordered
+ || (def.ordered && (def.numFormat !== governing.numFormat || def.numSuffix !== governing.numSuffix));
+ result.push(differs ? def : null);
+ if (differs) gov = def; // restyled list governs its own descendants
+ }
+ for (const item of list.content ?? []) {
+ if (item.type !== 'listItem') continue;
+ for (const child of item.content ?? []) {
+ if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false);
+ }
+ }
+ };
+ for (const child of doc.content ?? []) {
+ if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, listDefOf(child), true);
+ }
+}
+
+function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]): Uint8Array {
+ if (fixes.every(f => f === null)) return odtBytes;
+
+ const files = unzipSync(odtBytes);
+ const contentBytes = files['content.xml'];
+ if (!contentBytes) return odtBytes;
+
+ let content = strFromU8(contentBytes);
+ const minted: string[] = [];
+ const nameByKey = new Map();
+ const styleFor = (fix: ListDef): string => {
+ const key = `${fix.ordered}|${fix.numFormat}|${fix.numSuffix}`;
+ let name = nameByKey.get(key);
+ if (!name) {
+ name = `NL${nameByKey.size + 1}`;
+ nameByKey.set(key, name);
+ minted.push(buildCellListStyle(name, fix.ordered, fix.numFormat, fix.numSuffix));
+ }
+ return name;
+ };
+
+ // Bare tags are exactly odf-kit's nested lists, in DFS order.
+ let idx = 0;
+ content = content.replace(//g, (match) => {
+ const fix = fixes[idx++];
+ return fix ? `` : match;
+ });
+
+ if (minted.length) {
+ content = content.replace('', `${minted.join('\n')}\n`);
+ }
+ files['content.xml'] = strToU8(content);
+ return rezipOdt(files);
+}
+
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
// in-cell lists reference. Six nesting levels with identical bullet chars /
// indents / numbering, so cell lists match the editor's top-level lists.
@@ -539,8 +635,8 @@ function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
// ODF requires fo:color as #RRGGBB. TipTap may store hex (color picker) or rgb(r,g,b)
// after an HTML round-trip. Anything not valid hex is silently dropped by
-// Word/LibreOffice → text renders black, so coerce it here.
-function normalizeColor(input: string): string | undefined {
+// Word/LibreOffice → text renders black, so coerce it here. Also used on import.
+export function normalizeColor(input: string): string | undefined {
const s = input.trim();
if (!s) return undefined;
@@ -578,6 +674,11 @@ function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []
const tsm = marks.find(m => m.type === 'textStyle');
const fmt: TextFormatting = {};
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
+ // Explicit weight (e.g. un-bolding heading text) overrides the bold shortcut.
+ if (tsm?.attrs?.fontWeight) {
+ const fw = String(tsm.attrs.fontWeight);
+ fmt.fontWeight = (/^\d+$/.test(fw) ? Number(fw) : fw) as TextFormatting['fontWeight'];
+ }
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
if (marks.some(m => m.type === 'strike')) fmt.strikethrough = true;
@@ -730,8 +831,23 @@ function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
-export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise {
- const raw = editor.getJSON() as TiptapNode;
+// Rewrite the LBR sentinels replaceHardBreaks planted into real
+// elements (valid both as bare paragraph text and inside ).
+function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
+ const files = unzipSync(odtBytes);
+ const contentBytes = files['content.xml'];
+ if (!contentBytes) return odtBytes;
+
+ const content = strFromU8(contentBytes);
+ if (!content.includes(LBR)) return odtBytes;
+
+ files['content.xml'] = strToU8(content.split(LBR).join(''));
+ return rezipOdt(files);
+}
+
+// The full document → .odt pipeline, DOM-free (exportToOdt adds the download).
+export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise {
+ const raw = replaceHardBreaks(docJson);
const json = injectCustomTypes(raw);
// Text width = A4 page width (portrait 21cm / landscape 29.7cm) minus the L/R
@@ -797,7 +913,11 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
// styles, not the definitions.
const olFormats: (OrderedFmt | null)[] = [];
collectOrderedListFormats(raw, olFormats);
- const numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
+ let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
+
+ const nestedFixes: (ListDef | null)[] = [];
+ collectNestedListFixes(raw, nestedFixes);
+ numberedOdt = applyNestedListTypes(numberedOdt, nestedFixes);
const listStyles: ParaStyle[] = [];
collectListItemStyles(raw, listStyles);
@@ -812,7 +932,12 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
const styledRows = applyTableRowHeights(styledCells, rowHeights);
const cleaned = collapseRunWhitespace(styledRows);
- const finalBytes = rewriteStylesXml(cleaned);
+ const withBreaks = applyLineBreaks(cleaned);
+ return rewriteStylesXml(withBreaks);
+}
+
+export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise {
+ const finalBytes = await buildOdt(editor.getJSON() as TiptapNode, margins, orientation);
const blob = new Blob([finalBytes as Uint8Array], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
new file mode 100644
index 0000000..ad32a26
--- /dev/null
+++ b/src/lib/import/odt.ts
@@ -0,0 +1,520 @@
+import { unzipSync, strFromU8 } from 'fflate';
+import { StyleResolver, NS, lengthToPt, layerTextProps, type PropMap } from './styleResolver';
+import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
+import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../editor/orderedListTypes';
+import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
+import type { Orientation } from '../storage/pageOrientation';
+
+// .odt → TipTap JSON, inverting export/odt.ts. Anything the editor can express
+// becomes its native node/mark/attr; values matching the editor's defaults are
+// suppressed so a round trip (editor → LibreOffice → editor) doesn't accrete
+// explicit attrs. Unsupported content degrades gracefully and is reported.
+
+type Mark = { type: string; attrs?: Record };
+type Node = {
+ type: string;
+ attrs?: Record;
+ content?: Node[];
+ marks?: Mark[];
+ text?: string;
+};
+
+export interface OdtImportResult {
+ content: Node; // { type: 'doc', … }
+ margins: PageMargins | null;
+ orientation: Orientation | null;
+ warnings: string[];
+}
+
+// 'list' zeroes the default bottom margin (export rewrites List_20_* to 0cm to
+// match the editor); body/cell paragraphs default to Standard's 0.212cm.
+type BlockKind = 'body' | 'list' | 'cell';
+
+type Ctx = { resolver: StyleResolver; warnings: Set };
+
+// ---- editor defaults to suppress on import -----------------------------------
+
+const BODY_FONT_SIZE_PT = 12;
+// Standard paragraph style's fo:margin-bottom as odf-kit emits it.
+const STD_MARGIN_BOTTOM_PT = lengthToPt('0.212cm')!;
+// Match LO/Word producer rounding noise (cm: ≤0.014pt, twips: ≤0.025pt), not
+// genuine user values.
+const EPS_PT = 0.15;
+
+const HEADING_DEFAULTS = HEADING_STYLE_OVERRIDES.map(h => ({
+ fontSizePt: lengthToPt(h.fontSize)!,
+ marginTopPt: lengthToPt(h.marginTop)!,
+ marginBottomPt: lengthToPt(h.marginBottom)!,
+}));
+
+// The editor's on-screen default (Liberation Serif) and what the export declares
+// in its place (Times New Roman) — both mean "default font", so no mark.
+const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
+
+// ODF line spacing multiplies the font's natural line height; see lineHeight.ts.
+const LINE_HEIGHT_RATIO = 1.15;
+
+// ---- entry --------------------------------------------------------------------
+
+export function importOdt(bytes: Uint8Array): OdtImportResult {
+ let files: Record;
+ try {
+ files = unzipSync(bytes);
+ } catch {
+ throw new Error('Not a valid .odt file (could not read the archive).');
+ }
+ const contentBytes = files['content.xml'];
+ if (!contentBytes) throw new Error('Not a valid .odt file (content.xml is missing).');
+
+ const contentDoc = parseXml(strFromU8(contentBytes));
+ const stylesDoc = files['styles.xml'] ? parseXml(strFromU8(files['styles.xml'])) : null;
+ const resolver = new StyleResolver(contentDoc, stylesDoc);
+ const warnings = new Set();
+
+ const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
+ if (!body) throw new Error('Not a text document (no office:text body).');
+
+ const ctx: Ctx = { resolver, warnings };
+ const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
+ if (blocks.length === 0) blocks.push({ type: 'paragraph' });
+
+ const geometry = resolver.pageGeometry();
+ return {
+ content: { type: 'doc', content: blocks },
+ margins: geometry?.margins ?? null,
+ orientation: geometry?.orientation ?? null,
+ warnings: [...warnings],
+ };
+}
+
+function parseXml(xml: string): Document {
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
+ if (doc.getElementsByTagName('parsererror').length) {
+ throw new Error('Not a valid .odt file (malformed XML).');
+ }
+ return doc;
+}
+
+// ---- block conversion -----------------------------------------------------------
+
+function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind): Node[] {
+ const out: Node[] = [];
+ for (const el of elements) {
+ if (el.namespaceURI === NS.text) {
+ if (el.localName === 'p' || el.localName === 'h') {
+ out.push(convertParaLike(el, ctx, kind));
+ } else if (el.localName === 'list') {
+ const list = convertList(el, ctx, null, 1);
+ if (list) out.push(list);
+ } else if (el.localName === 'section') {
+ out.push(...convertBlocks(Array.from(el.children), ctx, kind));
+ } else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
+ // Generated indexes (ToC, …): keep the rendered text from index-body.
+ const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
+ if (indexBody) out.push(...convertBlocks(Array.from(indexBody.children), ctx, kind));
+ }
+ // tracked-changes registry, decls, soft-page-break, … → no visual content
+ } else if (el.namespaceURI === NS.table && el.localName === 'table') {
+ if (kind === 'body') {
+ const table = convertTable(el, ctx);
+ if (table) out.push(table);
+ } else {
+ // The editor (and its export) can't nest tables in cells/list items.
+ ctx.warnings.add('Nested tables were flattened to paragraphs');
+ out.push(...flattenTable(el, ctx));
+ }
+ } else if (el.namespaceURI === NS.draw) {
+ ctx.warnings.add('Images and drawings were removed');
+ }
+ }
+ return out;
+}
+
+function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind): Node {
+ const { resolver } = ctx;
+ const styleName = el.getAttributeNS(NS.text, 'style-name');
+ const paraProps = resolver.paraProps(styleName);
+ const baseTextProps = resolver.paraTextProps(styleName);
+
+ const isHeading = el.localName === 'h' && kind !== 'list';
+ let level = 1;
+ if (isHeading) {
+ const raw = parseInt(el.getAttributeNS(NS.text, 'outline-level') ?? '1', 10);
+ level = Math.min(3, Math.max(1, Number.isFinite(raw) ? raw : 1));
+ }
+
+ const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
+ const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null);
+
+ const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
+ if (isHeading) attrs.level = level;
+ if (Object.keys(attrs).length) node.attrs = attrs;
+ if (content.length) node.content = content;
+ return node;
+}
+
+// textAlign / lineHeight / spaceBefore / spaceAfter from resolved paragraph
+// props, suppressing values that match the editor's defaults for this context.
+function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number | null, kind: BlockKind): Record {
+ const attrs: Record = {};
+ const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
+
+ const ta = paraProps['fo:text-align'];
+ if (ta === 'center' || ta === 'justify') attrs.textAlign = ta;
+ else if (ta === 'right' || ta === 'end') attrs.textAlign = 'right';
+
+ const lh = paraProps['fo:line-height'];
+ if (lh && lh !== 'normal') {
+ let mult: number | null = null;
+ if (lh.endsWith('%')) {
+ const p = parseFloat(lh);
+ if (Number.isFinite(p)) mult = p / 100;
+ } else {
+ // Fixed line height: best-effort multiplier against the resolved font size.
+ const pt = lengthToPt(lh);
+ const fontPt = lengthToPt(textProps['fo:font-size']) ?? hdef?.fontSizePt ?? BODY_FONT_SIZE_PT;
+ if (pt != null && fontPt > 0) mult = pt / (fontPt * LINE_HEIGHT_RATIO);
+ }
+ if (mult != null) {
+ mult = Math.round(mult * 100) / 100;
+ if (Math.abs(mult - 1) > 0.01) attrs.lineHeight = String(mult);
+ }
+ }
+
+ const defTop = hdef ? hdef.marginTopPt : 0;
+ const defBottom = hdef ? hdef.marginBottomPt : kind === 'list' ? 0 : STD_MARGIN_BOTTOM_PT;
+ const mt = lengthToPt(paraProps['fo:margin-top']);
+ const mb = lengthToPt(paraProps['fo:margin-bottom']);
+ if (mt != null && Math.abs(mt - defTop) > EPS_PT) attrs.spaceBefore = snapPt(mt);
+ if (mb != null && Math.abs(mb - defBottom) > EPS_PT) attrs.spaceAfter = snapPt(mb);
+
+ return attrs;
+}
+
+// Round to 2 decimals; snap to the integer when within producer rounding noise
+// (6pt → LO's 0.212cm → 6.0094pt → 6).
+function snapPt(v: number): number {
+ const r = Math.round(v * 100) / 100;
+ const i = Math.round(r);
+ return Math.abs(r - i) <= 0.03 ? i : r;
+}
+
+// ---- inline conversion --------------------------------------------------------
+
+function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null): Node[] {
+ const out: Node[] = [];
+
+ const pushText = (text: string, props: PropMap) => {
+ // Strip our export sentinels (SEG/LBR) defensively — never legitimate text.
+ const clean = text.replace(/[]/g, '');
+ if (!clean) return;
+ const marks = marksFor(props, ctx.resolver, headingLevel);
+ const node: Node = { type: 'text', text: clean };
+ if (marks.length) node.marks = marks;
+ out.push(node);
+ };
+
+ const walk = (el: Element, props: PropMap) => {
+ for (const child of Array.from(el.childNodes)) {
+ if (child.nodeType === 3 /* text */) {
+ pushText(child.nodeValue ?? '', props);
+ continue;
+ }
+ if (child.nodeType !== 1) continue;
+ const e = child as Element;
+
+ if (e.namespaceURI === NS.text) {
+ switch (e.localName) {
+ case 'span':
+ walk(e, layerTextProps(props, ctx.resolver.spanTextProps(e.getAttributeNS(NS.text, 'style-name'))));
+ continue;
+ case 's': {
+ const c = parseInt(e.getAttributeNS(NS.text, 'c') ?? '1', 10);
+ pushText(' '.repeat(Number.isFinite(c) && c > 0 ? c : 1), props);
+ continue;
+ }
+ case 'tab':
+ pushText('\t', props);
+ continue;
+ case 'line-break':
+ out.push({ type: 'hardBreak' });
+ continue;
+ case 'a':
+ ctx.warnings.add('Hyperlinks were converted to plain text');
+ walk(e, props);
+ continue;
+ case 'note':
+ ctx.warnings.add('Footnotes and endnotes were removed');
+ continue;
+ case 'bookmark':
+ case 'bookmark-start':
+ case 'bookmark-end':
+ case 'soft-page-break':
+ case 'change':
+ case 'change-start':
+ case 'change-end':
+ continue;
+ default:
+ // Text fields (date, page number, title, …) store their evaluated
+ // value as element text — keep what the source document showed.
+ if (e.textContent) pushText(e.textContent, props);
+ continue;
+ }
+ }
+ if (e.namespaceURI === NS.draw) {
+ ctx.warnings.add('Images and drawings were removed');
+ continue;
+ }
+ if (e.namespaceURI === NS.office && e.localName === 'annotation') {
+ ctx.warnings.add('Comments were removed');
+ continue;
+ }
+ }
+ };
+
+ walk(root, baseProps);
+ return mergeAdjacentText(out);
+}
+
+// text + text:s + text would otherwise emit three identically-marked nodes.
+function mergeAdjacentText(nodes: Node[]): Node[] {
+ const out: Node[] = [];
+ for (const n of nodes) {
+ const prev = out[out.length - 1];
+ if (
+ prev && n.type === 'text' && prev.type === 'text' &&
+ JSON.stringify(prev.marks ?? []) === JSON.stringify(n.marks ?? [])
+ ) {
+ prev.text! += n.text!;
+ } else {
+ out.push(n);
+ }
+ }
+ return out;
+}
+
+function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null): Mark[] {
+ const marks: Mark[] = [];
+ const textStyle: Record = {};
+
+ const weight = props['fo:font-weight'];
+ const bold = weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : null;
+ if (headingLevel != null) {
+ // Headings render bold by default; only a *non*-bold run needs a mark.
+ if (bold === false) textStyle.fontWeight = 'normal';
+ } else if (bold === true) {
+ marks.push({ type: 'bold' });
+ }
+
+ const fs = props['fo:font-style'];
+ if (fs === 'italic' || fs === 'oblique') marks.push({ type: 'italic' });
+ const ul = props['style:text-underline-style'];
+ if (ul && ul !== 'none') marks.push({ type: 'underline' });
+ const lt = props['style:text-line-through-style'];
+ if (lt && lt !== 'none') marks.push({ type: 'strike' });
+
+ // "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered).
+ const pos = props['style:text-position'];
+ if (pos) {
+ if (pos.startsWith('super')) marks.push({ type: 'superscript' });
+ else if (pos.startsWith('sub')) marks.push({ type: 'subscript' });
+ else {
+ const p = parseFloat(pos);
+ if (p > 0) marks.push({ type: 'superscript' });
+ else if (p < 0) marks.push({ type: 'subscript' });
+ }
+ }
+
+ const bg = props['fo:background-color'];
+ if (bg && bg !== 'transparent') {
+ const c = normalizeColor(bg);
+ if (c) marks.push({ type: 'highlight', attrs: { color: c } });
+ }
+
+ const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
+ // Pure black is the theme default; an explicit mark would fight dark/allBlack.
+ if (color && color !== '#000000') textStyle.color = color;
+
+ const sizePt = lengthToPt(props['fo:font-size']);
+ const defSize = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1].fontSizePt : BODY_FONT_SIZE_PT;
+ if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = formatPt(sizePt);
+
+ const family = resolver.fontFamilyOf(props);
+ if (family && !DEFAULT_FONTS.has(family.toLowerCase())) textStyle.fontFamily = family;
+
+ if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
+ return marks;
+}
+
+function formatPt(v: number): string {
+ const r = Math.round(v * 10) / 10;
+ return `${Number.isInteger(r) ? r : r}pt`;
+}
+
+// ---- lists -----------------------------------------------------------------------
+
+// `inheritedStyleName`: nested text:list elements usually carry no style-name of
+// their own — the outermost list's style governs, with one level def per depth.
+function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number): Node | null {
+ const styleName = el.getAttributeNS(NS.text, 'style-name') ?? inheritedStyleName;
+ const levelDef = listLevelDef(ctx.resolver.listStyle(styleName), depth);
+ const ordered = levelDef?.localName === 'list-level-style-number';
+
+ const items: Node[] = [];
+ let start: number | null = null;
+ for (const item of Array.from(el.children)) {
+ if (item.namespaceURI !== NS.text || (item.localName !== 'list-item' && item.localName !== 'list-header')) continue;
+
+ if (ordered && items.length === 0) {
+ const sv = parseInt(item.getAttributeNS(NS.text, 'start-value') ?? '', 10);
+ if (Number.isFinite(sv) && sv > 1) start = sv;
+ }
+
+ const blocks: Node[] = [];
+ for (const child of Array.from(item.children)) {
+ if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) {
+ blocks.push(convertParaLike(child, ctx, 'list'));
+ } else if (child.namespaceURI === NS.text && child.localName === 'list') {
+ const nested = convertList(child, ctx, styleName, depth + 1);
+ if (nested) blocks.push(nested);
+ } else if (child.namespaceURI === NS.table && child.localName === 'table') {
+ ctx.warnings.add('Nested tables were flattened to paragraphs');
+ blocks.push(...flattenTable(child, ctx));
+ }
+ }
+ // listItem requires a leading paragraph (e.g. an item holding only a sub-list).
+ if (blocks[0]?.type !== 'paragraph') blocks.unshift({ type: 'paragraph' });
+ items.push({ type: 'listItem', content: blocks });
+ }
+ if (items.length === 0) return null;
+
+ if (!ordered) return { type: 'bulletList', content: items };
+
+ const listStyleType = orderedTypeFromFormat(
+ levelDef!.getAttributeNS(NS.style, 'num-format'),
+ levelDef!.getAttributeNS(NS.style, 'num-suffix'),
+ );
+ const attrs: Record = {};
+ if (start != null) attrs.start = start;
+ if (listStyleType !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = listStyleType;
+ const node: Node = { type: 'orderedList', content: items };
+ if (Object.keys(attrs).length) node.attrs = attrs;
+ return node;
+}
+
+function listLevelDef(listStyle: Element | null, depth: number): Element | null {
+ if (!listStyle) return null;
+ for (const child of Array.from(listStyle.children)) {
+ if (child.namespaceURI !== NS.text) continue;
+ if (!child.localName.startsWith('list-level-style-')) continue;
+ if (child.getAttributeNS(NS.text, 'level') === String(depth)) return child;
+ }
+ return null;
+}
+
+// ---- tables ------------------------------------------------------------------------
+
+function convertTable(el: Element, ctx: Ctx): Node | null {
+ const weights = columnWeights(el, ctx.resolver);
+
+ const rows: Node[] = [];
+ const addRow = (rowEl: Element, header: boolean) => {
+ const cells: Node[] = [];
+ let colIndex = 0;
+ for (const cellEl of Array.from(rowEl.children)) {
+ if (cellEl.namespaceURI !== NS.table) continue;
+ const repeated = Math.min(256, parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-repeated') ?? '1', 10) || 1);
+ if (cellEl.localName === 'covered-table-cell') {
+ colIndex += repeated; // grid slots owned by a span elsewhere
+ continue;
+ }
+ if (cellEl.localName !== 'table-cell') continue;
+
+ const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1;
+ const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
+ const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell');
+ for (let r = 0; r < repeated; r++) {
+ const attrs: Record = { colspan, rowspan };
+ if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan);
+ cells.push({
+ type: header ? 'tableHeader' : 'tableCell',
+ attrs,
+ content: blocks.length ? structuredClone(blocks) : [{ type: 'paragraph' }],
+ });
+ colIndex += colspan;
+ }
+ }
+ if (cells.length === 0) return;
+
+ const row: Node = { type: 'tableRow', content: cells };
+ const heightCm = ctx.resolver.rowMinHeightCm(rowEl.getAttributeNS(NS.table, 'style-name'));
+ if (heightCm != null && heightCm > 0) row.attrs = { rowHeight: Math.round(heightCm * PX_PER_CM) };
+ rows.push(row);
+ };
+
+ for (const child of Array.from(el.children)) {
+ if (child.namespaceURI !== NS.table) continue;
+ if (child.localName === 'table-row') addRow(child, false);
+ else if (child.localName === 'table-header-rows' || child.localName === 'table-rows') {
+ const header = child.localName === 'table-header-rows';
+ for (const rowEl of Array.from(child.children)) {
+ if (rowEl.namespaceURI === NS.table && rowEl.localName === 'table-row') addRow(rowEl, header);
+ }
+ }
+ }
+
+ return rows.length ? { type: 'table', content: rows } : null;
+}
+
+// Per-column proportional weights for the colwidth cell attr (tableView.ts uses
+// ratios only). Prefers LibreOffice's relative widths (rel-column-width) and
+// falls back to absolute cm. null when the document declares no usable widths.
+function columnWeights(tableEl: Element, resolver: StyleResolver): number[] | null {
+ const rel: (number | null)[] = [];
+ const cm: (number | null)[] = [];
+ const scan = (parent: Element) => {
+ for (const child of Array.from(parent.children)) {
+ if (child.namespaceURI !== NS.table) continue;
+ if (child.localName === 'table-column') {
+ const repeated = Math.min(256, parseInt(child.getAttributeNS(NS.table, 'number-columns-repeated') ?? '1', 10) || 1);
+ const styleName = child.getAttributeNS(NS.table, 'style-name');
+ for (let i = 0; i < repeated; i++) {
+ rel.push(resolver.columnRelWidth(styleName));
+ cm.push(resolver.columnWidthCm(styleName));
+ }
+ } else if (child.localName === 'table-columns' || child.localName === 'table-header-columns') {
+ scan(child);
+ }
+ }
+ };
+ scan(tableEl);
+
+ // cm values get ×100 so both forms land in comparable integer territory.
+ const widths = rel.some(w => w != null) ? rel : cm.map(w => (w != null ? w * 100 : null));
+ if (widths.length === 0 || widths.every(w => w == null)) return null;
+ const present = widths.filter((w): w is number => w != null);
+ const avg = present.reduce((a, b) => a + b, 0) / present.length;
+ return widths.map(w => Math.max(1, Math.round(w ?? avg)));
+}
+
+// Salvage a nested table's text: its cells' blocks, in reading order. Walks
+// direct rows only — deeper tables recurse through convertBlocks again.
+function flattenTable(el: Element, ctx: Ctx): Node[] {
+ const out: Node[] = [];
+ const walkRows = (parent: Element) => {
+ for (const child of Array.from(parent.children)) {
+ if (child.namespaceURI !== NS.table) continue;
+ if (child.localName === 'table-row') {
+ for (const cellEl of Array.from(child.children)) {
+ if (cellEl.namespaceURI === NS.table && cellEl.localName === 'table-cell') {
+ out.push(...convertBlocks(Array.from(cellEl.children), ctx, 'cell'));
+ }
+ }
+ } else if (child.localName === 'table-header-rows' || child.localName === 'table-rows') {
+ walkRows(child);
+ }
+ }
+ };
+ walkRows(el);
+ return out;
+}
diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts
new file mode 100644
index 0000000..be084cc
--- /dev/null
+++ b/src/lib/import/styleResolver.ts
@@ -0,0 +1,266 @@
+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(Object.entries(NS).map(([alias, uri]) => [uri, alias]));
+
+export type PropMap = Record;
+
+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 = {
+ 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();
+ private defaults = new Map(); // family → style:default-style
+ private fontFaces = new Map(); // style:name → first font-family
+ private listStyles = new Map(); // style:name → text:list-style
+ private mergedCache = new Map();
+ 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();
+ 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;
+ }
+
+ // Page margins + orientation from the first master page's page layout.
+ // Page *size* is intentionally ignored — the editor is A4-only.
+ pageGeometry(): { margins: PageMargins; orientation: Orientation } | null {
+ const doc = this.stylesDoc;
+ if (!doc) return null;
+ const masterPage = doc.getElementsByTagNameNS(NS.style, 'master-page')[0];
+ const layoutName = masterPage?.getAttributeNS(NS.style, 'page-layout-name');
+ if (!layoutName) return null;
+ let props: Element | null = null;
+ for (const layout of Array.from(doc.getElementsByTagNameNS(NS.style, 'page-layout'))) {
+ if (layout.getAttributeNS(NS.style, 'name') === layoutName) {
+ props = layout.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
+ break;
+ }
+ }
+ if (!props) return null;
+
+ const cm = (attr: string, fallback: number) => {
+ const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
+ if (v == null) return fallback;
+ return Math.min(10, Math.max(0, Math.round(v * 100) / 100));
+ };
+ const margins: PageMargins = {
+ top: cm('margin-top', 2.54),
+ bottom: cm('margin-bottom', 2.54),
+ 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 };
+ }
+}