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 }; + } +}