Guide · odf-kit/lexical

Lexical JSON to ODT in JavaScript

Convert a Lexical SerializedEditorState to a valid .odt OpenDocument file in Node.js and browsers — pure ESM, no LibreOffice required. Includes Proton Docs integration example.

Install

lexicalToOdt() is available via the odf-kit/lexical sub-export — no separate package needed:

$ npm install odf-kit

Basic usage

Call editor.getEditorState().toJSON() to get the SerializedEditorState, then pass it to lexicalToOdt(). The function returns a Uint8Array containing a complete, valid .odt file.

import { lexicalToOdt } from "odf-kit/lexical";
import { writeFileSync } from "fs";

// Get serialized state from your Lexical editor
const editorState = editor.getEditorState().toJSON();

const bytes = await lexicalToOdt(editorState, {
  pageFormat: "A4",
});

writeFileSync("document.odt", bytes);
// → Valid .odt file, opens in LibreOffice, Google Docs, Word

Browser usage

odf-kit/lexical is pure ESM — it works in any modern browser. Call lexicalToOdt() and trigger a download with the resulting bytes.

import { lexicalToOdt } from "odf-kit/lexical";

const editorState = editor.getEditorState().toJSON();
const bytes = await lexicalToOdt(editorState, { pageFormat: "A4" });

const blob = new Blob([bytes], {
  type: "application/vnd.oasis.opendocument.text"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.odt";
a.click();
URL.revokeObjectURL(url);

Images

Base64 data URL images are embedded automatically. For remote image URLs (e.g. images stored on a CDN or IPFS), provide a fetchImage callback that resolves the URL to raw bytes:

const bytes = await lexicalToOdt(editorState, {
  pageFormat: "A4",
  fetchImage: async (src) => {
    const response = await fetch(src);
    return new Uint8Array(await response.arrayBuffer());
  },
});

Page format options

Pass a LexicalToOdtOptions object as the second argument:

const bytes = await lexicalToOdt(editorState, {
  pageFormat: "letter",   // "A4" | "letter" | "legal" | "A3" | "A5"
  marginTop:  "2.5cm",
  marginBottom: "2.5cm",
  marginLeft: "2.5cm",
  marginRight: "2.5cm",
});

Supported Lexical node types

All standard Lexical node types are supported:

Node type ODT output
paragraph text:p — with alignment support
heading (h1–h6) text:h with Heading 1–6 styles
quote Indented paragraph (1cm left margin)
code Monospace paragraph (Courier New)
list (bullet) Unordered text:list
list (number) Ordered text:list with numFormat
custom-list Ordered with lower-alpha / upper-alpha / upper-roman
table table:table with colSpan / rowSpan
image (decorator) draw:frame with embedded image
horizontalrule Paragraph with bottom border
text text:span with bold, italic, underline, strikethrough, code, subscript, superscript, color
link / autolink text:a hyperlink
linebreak text:line-break
code-highlight Monospace text run
hashtag Plain text run

Proton Docs integration

Proton Docs uses Lexical as its editor engine. Adding ODT export requires changes to three files in applications/docs-editor/src/app/Conversion/Exporter/:

Step 1 — Create EditorOdtExporter.ts:

import { lexicalToOdt } from 'odf-kit/lexical'
import { EditorExporter } from './EditorExporter'

export class EditorOdtExporter extends EditorExporter {
  async export(): Promise<Uint8Array<ArrayBuffer>> {
    return lexicalToOdt(this.editor.getEditorState().toJSON(), {
      pageFormat: 'A4',
      fetchImage: async (src) => {
        const b64 = await this.callbacks.fetchExternalImageAsBase64(src)
        if (!b64) return undefined
        const binary = atob(b64.split(',')[1] ?? b64)
        return Uint8Array.from(binary, c => c.charCodeAt(0))
      },
    })
  }
}

Step 2 — Add one case to ExportDataFromEditorState.ts:

case 'odt':
  return new EditorOdtExporter(editorState, callbacks).export()

Step 3 — Add 'odt' to the export type union in DataTypesThatDocumentCanBeExportedAs in @proton/docs-shared.

That's the complete integration — three files, one new case statement.

TypeScript types

The odf-kit/lexical sub-export includes full TypeScript types. The input type is loosely typed to accept any valid Lexical SerializedEditorState without requiring a Lexical dependency in your project:

import { lexicalToOdt } from "odf-kit/lexical";
import type {
  LexicalToOdtOptions,
  LexicalSerializedEditorState,
} from "odf-kit/lexical";

Try it in your browser

Paste a Lexical editor state and download an ODT file instantly — no install required.