Universal OfficeBeta

Universal Write

A text editor compatible with .docx, delivered as the <universal-write> web component. Canvas rendering with layout faithful to the reference application, full editing, and lossless format round-trip.

This documentation is about the <universal-write> web component — for embedding the editor in your own web app. If you just want the ready-to-run desktop app, download Universal Office (Beta) instead — Universal Write, Sheet and Slides in one install, no code required (more about the app), or try the editor live in your browser:

Overview

<universal-write> is a Custom Element that renders and edits Word documents directly in the browser. Unlike editors based on contenteditable, it paints the document to a canvas using a custom layout engine (line breaking, justification, pagination, and tables using real font metrics) — the result is the same pagination and the same line breaks as the reference application.

All the code ships in a single package, @universal-office/write (its only dependency is @universal-office/font-files, the open fonts), which internally is organized into modules:

ModuleRole
Document coreDocument model + OOXML parse/serialize. Lossless round-trip: parts not yet modeled are preserved byte for byte.
Layout engineAbsolute-position tree (pages → lines → glyph runs).
Editor (<universal-write>)The web component: its own caret, selection, IME, and clipboard.
ExportersHTML, Markdown, PDF, XPS, and ODT.
The editor: typing, formatting, toolbar and status bar

Two related components ship as their own packages: <universal-document-viewer> (read-only viewer) and <universal-write-diff> (document comparison). The viewer is also inside this package, as a second entry that does not load the editor: import '@universal-office/write/viewer'.

Install

<universal-write> is open source under the MIT license and is published on npm:

npm install @universal-office/write

Serve the package's chunks/ folder (parts loaded on demand) next to index.js, and the folder of the @universal-office/font-files package (open fonts, including the required Liberation Sans), installed with it. Then import the bundle so the custom element registers itself, and place the tag on the page:

import '@universal-office/write';
<universal-write id="editor" fonts="/node_modules/@universal-office/font-files" src="/document.docx" topbar bottombar></universal-write>

Spell checking is optional: the Hunspell engine and the dictionaries are separate packages (@universal-office/spellcheck, @universal-office/dictionary-pt-br, @universal-office/dictionary-en-us, @universal-office/dictionary-es), downloaded only when used. With a bundler, register them with registerSpellcheck(() => import('@universal-office/spellcheck')) and registerDictionary('pt-BR', () => import('@universal-office/dictionary-pt-br')); when the packages are served as static folders (<base>/spellcheck/, <base>/dictionary-pt-br/…), set spellcheck-url="<base>". Without them the editor does not check spelling. Text whose language the document doesn't say is checked in spellcheck-default-lang (pt-BR by default).

Observed attributes:

AttributeDescription
srcURL of a .docx to load automatically when connected to the DOM.
fontsFolder with the font files — the @universal-office/font-files package. Liberation Sans (liberationsans-regular.ttf, -bold, -italic, -bolditalic) is required; the other open fonts are metric-compatible substitutes for Calibri, Cambria, Arial, Times New Roman and Courier New. Aptos and Calibri files are used if you add them.
topbarBuilt-in top bar: the document title above the menus and one toolbar row (toolbar="ribbon" for Word-style tabs).
bottombarBuilt-in status bar: page and word count, view buttons, zoom.
spellcheck-urlFolder with the spell checking packages, one subfolder each: spellcheck/index.js and dictionary-<language>/index.js. spellcheck-languages lists the dictionaries present (default: all published — pt-BR, en-US, es).
spellcheck-default-langLanguage of text whose language the document doesn't say (no w:lang on the run, its styles or the document defaults), e.g. the host app's UI language. A new document (newDocument()) is also created in it. Default: pt-BR. Also the spellcheckDefaultLang property.
langUI language of the bars and popups: en, pt or es (any region: es-MX, es-419… → Spanish). Without it: the first of Spanish/Portuguese/English the browser prefers, otherwise English.
layoutlegacy uses the previous canvas layout instead of the Word-fidelity engine (the editor also falls back to it on its own if the engine fails on a document).
manual-initIf present, the component does not initialize on its own — it waits for a call to initialize(). Use this to run fully offline, with no backend.

The element sizes itself to its container: give it a size via CSS (for example universal-write { flex: 1; } in a column layout).

Load documents

Besides the src attribute, load(bytes, format?, opts?) loads bytes directly. format defaults to 'auto', which detects .docx vs .odt from the bytes:

const editor = document.getElementById('editor');

// format 'auto' (default) — detects .docx or .odt
const resp = await fetch('/contract.docx');
editor.load(new Uint8Array(await resp.arrayBuffer()), 'auto', { protected: false });

// or force a format
editor.load(odtBytes, 'odt');

// Empty, editable document
editor.newDocument();

Protected View: documents opened with protected: true are read-only until enableEditing() is called. The uw-protected event fires when the mode changes — use it to show the "Enable Editing" bar in your app.

Save and export

A single save(format?, opts?) covers every output. format defaults to 'auto' — the format the document was loaded as. It always returns a Promise; binary formats resolve to a Uint8Array, html/markdown to a string.

// 'auto' (default) → the loaded format (.docx or .odt)
const bytes = await editor.save();

// explicit formats
const docx = await editor.save('docx');
const odt  = await editor.save('odt');
const pdf  = await editor.save('pdf');
const xps  = await editor.save('xps');
const html = await editor.save('html', { title: 'My document' });
const md   = await editor.save('markdown', { images: false }); // omit images

// Native printing (browser dialog with pages in Print mode)
editor.print();

To download the bytes in the browser:

function download(data, filename, type) {
  const url = URL.createObjectURL(new Blob([data], { type }));
  const a = Object.assign(document.createElement('a'), { href: url, download: filename });
  a.click();
  URL.revokeObjectURL(url);
}

download(await editor.save('docx'), 'document.docx',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document');

Manual initialization (offline, no backend)

With manual-init, you provide the fonts and the document as bytes — the component makes no network request:

<universal-write id="editor" manual-init></universal-write>
await editor.initialize({
  fonts: [
    // Liberation Sans is required (the default font and last-resort substitute)
    { family: 'Liberation Sans', variant: 'regular',    data: regular },
    { family: 'Liberation Sans', variant: 'bold',       data: bold },
    { family: 'Liberation Sans', variant: 'italic',     data: italic },
    { family: 'Liberation Sans', variant: 'boldItalic', data: boldItalic },
  ],
  document: { bytes: docxBytes, format: 'docx', protected: false },
});

Accepted types:

interface UniversalWriteInitializeOptions {
  fonts?: readonly UniversalWriteFontAsset[];
  document?: UniversalWriteDocumentSource;
}

interface UniversalWriteFontAsset {
  family: string;
  variant: 'regular' | 'bold' | 'italic' | 'boldItalic';
  data: Uint8Array | ArrayBuffer;
  advances?: Record<string, Record<string, number>>;
}

interface UniversalWriteDocumentSource {
  bytes: Uint8Array;
  format?: 'docx' | 'odt';
  protected?: boolean;
}

Full example

<body style="margin:0; height:100vh; display:flex; flex-direction:column">
  <header>
    <button id="undo">Undo</button>
    <button id="redo">Redo</button>
  </header>
  <universal-write id="editor" fonts="/fonts" style="flex:1; min-width:0"></universal-write>
  <footer id="status"></footer>
</body>
import '@universal-office/write';

const editor = document.getElementById('editor');

editor.addEventListener('uw-ready', async () => {
  try {
    const resp = await fetch('/samples/catalog.docx');
    if (resp.ok) editor.load(new Uint8Array(await resp.arrayBuffer()), 'auto');
    else editor.newDocument();
  } catch {
    editor.newDocument();
  }
  editor.focusEditor();
});

editor.addEventListener('uw-change', () => {
  const st = editor.stats();
  document.getElementById('status').textContent =
    `Page ${st.page} of ${st.pages} · ${st.words} words`;
});

document.getElementById('undo').onclick = () => editor.undo();
document.getElementById('redo').onclick = () => editor.redo();