feat: import odt

This commit is contained in:
Steffen Becker committed 2026-06-12 21:17:29 +02:00
1 parent 69407726a9
commit 3088da0ac9
9 files changed
+1004 -9

No files matched your search

+1 -1
View File
@@ -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
+14
View File
@@ -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",
+1
View File
@@ -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",
+58
View File
@@ -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
</button>
{/if}
<input
bind:this={fileInput}
type="file"
accept=".odt,application/vnd.oasis.opendocument.text"
class="file-input"
onchange={handleImportFile}
/>
<button class="export-btn open-btn" onclick={() => fileInput?.click()} disabled={!editor}>
Open .odt
</button>
<button class="export-btn" onclick={handleExport} disabled={!editor}>
Download .odt
</button>
@@ -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;
+4
View File
@@ -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 <text:line-break/> (export/odt.ts
// replaceHardBreaks, import/odt.ts).
HardBreak,
LineHeight,
ParagraphSpacing,
Heading.configure({ levels: [1, 2, 3] }),
+7
View File
@@ -39,3 +39,10 @@ const BY_KEY = new Map<string, OrderedTypeDef>(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;
}
+133 -8
View File
@@ -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 <text:line-break/> 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 <text:line-break/> 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 <text:list> 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 <text:list> 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<string, string>();
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 <text:list> tags are exactly odf-kit's nested lists, in DFS order.
let idx = 0;
content = content.replace(/<text:list>/g, (match) => {
const fix = fixes[idx++];
return fix ? `<text:list text:style-name="${styleFor(fix)}">` : match;
});
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('\n')}\n</office:automatic-styles>`);
}
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<void> {
const raw = editor.getJSON() as TiptapNode;
// Rewrite the LBR sentinels replaceHardBreaks planted into real <text:line-break/>
// elements (valid both as bare paragraph text and inside <text:span>).
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('<text:line-break/>'));
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<Uint8Array> {
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
// <text:p> styles, not the <text:list-style> 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<void> {
const finalBytes = await buildOdt(editor.getJSON() as TiptapNode, margins, orientation);
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
+520
View File
@@ -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<string, unknown> };
type Node = {
type: string;
attrs?: Record<string, unknown>;
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<string> };
// ---- 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<string, Uint8Array>;
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<string>();
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<string, unknown> {
const attrs: Record<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = { 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;
}
+266
View File
@@ -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<string, string>(Object.entries(NS).map(([alias, uri]) => [uri, alias]));
export type PropMap = Record<string, string>;
type StyleEntry = {
parent: string | null;
text: PropMap; // style:text-properties
para: PropMap; // style:paragraph-properties
misc: PropMap; // table-column / table-row / table-cell properties
};
// ---- length units -----------------------------------------------------------
const PT_PER_UNIT: Record<string, number> = {
pt: 1,
cm: 72 / 2.54,
mm: 72 / 25.4,
in: 72,
px: 72 / 96,
pc: 12,
};
export function lengthToPt(s: string | null | undefined): number | null {
if (!s) return null;
const m = /^(-?[\d.]+)\s*(pt|cm|mm|in|px|pc)?$/.exec(s.trim());
if (!m) return null;
const v = parseFloat(m[1]);
if (!Number.isFinite(v)) return null;
return v * PT_PER_UNIT[m[2] ?? 'pt'];
}
export function lengthToCm(s: string | null | undefined): number | null {
const pt = lengthToPt(s);
return pt == null ? null : (pt / 72) * 2.54;
}
// Layer text props, treating the two font attrs (fo:font-family /
// style:font-name) as one logical property: whichever form the nearer style
// uses must shadow both inherited forms.
export function layerTextProps(base: PropMap, over: PropMap): PropMap {
const out = { ...base };
if ('fo:font-family' in over || 'style:font-name' in over) {
delete out['fo:font-family'];
delete out['style:font-name'];
}
return Object.assign(out, over);
}
// ---- resolver ----------------------------------------------------------------
function collectProps(el: Element, propsLocalName: string, into: PropMap): void {
for (const child of Array.from(el.children)) {
if (child.namespaceURI !== NS.style || child.localName !== propsLocalName) continue;
for (const attr of Array.from(child.attributes)) {
const alias = attr.namespaceURI ? ALIAS_BY_URI.get(attr.namespaceURI) : undefined;
if (alias) into[`${alias}:${attr.localName}`] = attr.value;
}
}
}
function entryFromStyleElement(el: Element): StyleEntry {
const entry: StyleEntry = {
parent: el.getAttributeNS(NS.style, 'parent-style-name'),
text: {},
para: {},
misc: {},
};
collectProps(el, 'text-properties', entry.text);
collectProps(el, 'paragraph-properties', entry.para);
collectProps(el, 'table-column-properties', entry.misc);
collectProps(el, 'table-row-properties', entry.misc);
collectProps(el, 'table-cell-properties', entry.misc);
return entry;
}
export class StyleResolver {
// (family + '\0' + name) → entry; later registrations win (content.xml
// automatic styles override styles.xml ones on name collision).
private styles = new Map<string, StyleEntry>();
private defaults = new Map<string, StyleEntry>(); // family → style:default-style
private fontFaces = new Map<string, string>(); // style:name → first font-family
private listStyles = new Map<string, Element>(); // style:name → text:list-style
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
constructor(contentDoc: Document, stylesDoc: Document | null) {
this.stylesDoc = stylesDoc;
// Scan order (later wins): styles.xml named → styles.xml automatic →
// content.xml named → content.xml automatic.
const containers: Element[] = [];
for (const doc of [stylesDoc, contentDoc]) {
if (!doc) continue;
for (const local of ['styles', 'automatic-styles']) {
const el = doc.getElementsByTagNameNS(NS.office, local)[0];
if (el) containers.push(el);
}
const fontDecls = doc.getElementsByTagNameNS(NS.office, 'font-face-decls')[0];
if (fontDecls) this.scanFontFaces(fontDecls);
}
for (const c of containers) this.scanContainer(c);
}
private scanFontFaces(container: Element): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI !== NS.style || el.localName !== 'font-face') continue;
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.svg, 'font-family');
if (name && family) {
// svg:font-family may be a quoted list: 'Liberation Serif', 'Times New Roman'
const first = family.split(',')[0].trim().replace(/^['"]|['"]$/g, '');
if (first) this.fontFaces.set(name, first);
}
}
}
private scanContainer(container: Element): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI === NS.style && el.localName === 'style') {
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.style, 'family');
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
const family = el.getAttributeNS(NS.style, 'family');
if (family) this.defaults.set(family, entryFromStyleElement(el));
} else if (el.namespaceURI === NS.text && el.localName === 'list-style') {
const name = el.getAttributeNS(NS.style, 'name');
if (name) this.listStyles.set(name, el);
}
}
}
// Flattened props along the parent chain (nearest wins), rooted in the
// family's default style.
private merged(family: string, name: string): { text: PropMap; para: PropMap; misc: PropMap } {
const cacheKey = `${family}\0${name}`;
const hit = this.mergedCache.get(cacheKey);
if (hit) return hit;
const chain: StyleEntry[] = [];
const seen = new Set<string>();
let cur: string | null = name;
while (cur && !seen.has(cur)) {
seen.add(cur);
const entry = this.styles.get(`${family}\0${cur}`);
if (!entry) break;
chain.push(entry);
cur = entry.parent;
}
const def = this.defaults.get(family);
if (def) chain.push(def);
const result = { text: {} as PropMap, para: {} as PropMap, misc: {} as PropMap };
// Apply root-first so nearer definitions overwrite.
for (const entry of chain.reverse()) {
result.text = layerTextProps(result.text, entry.text);
Object.assign(result.para, entry.para);
Object.assign(result.misc, entry.misc);
}
this.mergedCache.set(cacheKey, result);
return result;
}
// Effective text props of a paragraph/heading style (runs without character
// styles inherit these).
paraTextProps(styleName: string | null): PropMap {
return styleName ? this.merged('paragraph', styleName).text : (this.defaults.get('paragraph')?.text ?? {});
}
// Effective paragraph-layout props (alignment, margins, line-height).
paraProps(styleName: string | null): PropMap {
return styleName ? this.merged('paragraph', styleName).para : (this.defaults.get('paragraph')?.para ?? {});
}
// Explicitly-set text props of a character (text:span) style chain; layered
// over the paragraph's by the importer.
spanTextProps(styleName: string | null): PropMap {
return styleName ? this.merged('text', styleName).text : {};
}
// Resolve a text-props map's font: fo:font-family wins, else style:font-name
// through the font-face declarations.
fontFamilyOf(props: PropMap): string | null {
const fam = props['fo:font-family'];
if (fam) return fam.split(',')[0].trim().replace(/^['"]|['"]$/g, '') || null;
const name = props['style:font-name'];
if (name) return this.fontFaces.get(name) ?? name;
return null;
}
columnWidthCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-column', styleName).misc['style:column-width']);
}
// style:rel-column-width="21846*" — LibreOffice's proportional column form.
columnRelWidth(styleName: string | null): number | null {
if (!styleName) return null;
const raw = this.merged('table-column', styleName).misc['style:rel-column-width'];
if (!raw) return null;
const v = parseFloat(raw);
return Number.isFinite(v) && v > 0 ? v : null;
}
rowMinHeightCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-row', styleName).misc['style:min-row-height']);
}
listStyle(name: string | null): Element | null {
return name ? this.listStyles.get(name) ?? null : null;
}
// 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 };
}
}