feat: import odt
This commit is contained in:
1 parent
69407726a9
commit
3088da0ac9
9 files changed
+1004
-9
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] }),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user