• Joined on 2026-01-29

@mai/meditor-word (0.1.3)

Published 2026-09-30 13:57:08 +00:00 by mAi

Installation

@mai:registry=https://mgit.flexsiebels.de/api/packages/mAi/npm/
npm install @mai/meditor-word@0.1.3
"@mai/meditor-word": "0.1.3"

About this package

Word mode for @mai/meditor: a .docx in, the document's own paragraph styles on a picker, the edited .docx out. AGPL because SuperDoc is.

@mai/meditor-word

Word mode for @mai/meditor: a .docx in, the document's own paragraphs on screen with the Word style each one carries, a picker over the styles the document defines, and the edited .docx back out.

The engine is SuperDoc. This package is AGPL-3.0 because SuperDoc is, and @mai/meditor stays MIT: a consumer that never imports this package takes neither the licence nor the megabytes.

Published to the same registry as the rest of the kit, AGPL and all (d-f640161e): bun 1.4.2 cannot take a git dependency on a subdirectory, and vendoring would copy AGPL source into the consumer. Point the @mai scope at the registry in an .npmrc — no token, the packages are readable without one:

@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/

@mai/meditor is a peer at ^0.9.1, the version that takes a mode prop, so install the two together and a consumer keeps one copy of the editor:

bun add @mai/meditor @mai/meditor-word

SuperDoc comes with the install, its three non-optional peers (yjs, y-prosemirror, @hocuspocus/provider) with it. Its stylesheet does not — the consumer loads that itself, once:

@import '@harbour-enterprises/superdoc/style.css';

paliad's drafting page is the first consumer.

<script>
  import { Editor } from '@mai/meditor';
  import { wordMode } from '@mai/meditor-word';

  let doc;
</script>

<Editor
  preview={false}
  mode={wordMode({
    docx,
    stylePickerLabel: 'Absatzformat',
    styleFilter: (s) => OFFERED.has(s.id),
    onReady: (d) => (doc = d)
  })}
/>

onReady hands back a WordDocument: styles(), paragraphs(), currentStyle(), setStyle(id), insertText(text), insertParagraphs(paragraphs), insertMarkedParagraphs(tag, paragraphs, alias?), markedRanges(), replaceMarkedRange(id, paragraphs, tag), exportDocx(). The style vocabulary is the document's own styles.xml — nothing here spells a style name, and a firm that renames a style in its master renames it in the picker.

insertText and insertParagraphs write at the caret, which is how a host puts a building block or a <<key>> reference token into the draft:

doc.insertText('<<firm.name>>');
doc.insertParagraphs([
  { styleId: 'HLCpat-Heading-H1', runs: [{ text: 'Zur Verletzung' }] },
  { styleId: 'HLCpat-Body-B0', runs: [{ text: 'Die ' }, { text: 'angegriffene', bold: true }, { text: ' Ausführungsform.' }] }
]);

Text lands in the run the caret is in and keeps that run's character format, a selection is replaced, and the text is literal: <<firm.name>> reaches the exported .docx as those characters and never as a Word field. A styleId the document does not define is left off rather than written as a reference nothing in styles.xml resolves. The paragraph the caret sits in is split at the caret, and an empty one is replaced. Both methods report like a keystroke — one onInput, then one onChange — so the host's autosave fires once per call.

Marked ranges

A host that wants to update an inserted building block later has to find the same paragraphs again after a colleague has edited around them. insertMarkedParagraphs wraps the insert in an OOXML content control — w:sdt with the host's own w:tag, a w:id no other control in the document carries, and an optional w:alias Word shows as the control's title:

doc.insertMarkedParagraphs('block.verletzung', paragraphs, 'Verletzung');

doc.markedRanges();
// [{ id: '1583920114', tag: 'block.verletzung', text: 'Zur Verletzung\nDie Ausführungsform.' }]

doc.replaceMarkedRange('1583920114', newerParagraphs, 'block.verletzung@2');

markedRanges() reads the document, not a list this package keeps, so it finds the controls a .docx arrived with as well as the ones it inserted, and it finds them again after the bytes have been filed and reopened. text is one line per paragraph. replaceMarkedRange keeps the range's id and alias, writes the tag it is handed over the old one, and returns false for an id the document does not carry — and then changes nothing. Both write one transaction, so both report once.

The id is a non-negative decimal integer because OOXML types w:id as ST_DecimalNumber, and it is drawn again when the document already carries it: SuperDoc's own by-id commands act on every node carrying an id, so a repeat would make two ranges one.

viewLayout is 'web' by default and reflows the document to the container; 'print' draws the page the document declares. § The page canvas below is the measurement.

The surface

The ground under the document is --mw-surface, a custom property the host sets anywhere above the editor. It defaults to #fff, which is what SuperDoc paints, so a host that sets nothing sees no change:

.editor {
  background: var(--mk-surface);
  --mw-surface: transparent;
}

transparent is what a themed host wants: the host box's own background then shows through, and the document's text takes the color it inherits — SuperDoc's style pass writes no color.

The !important this needs belongs to the package, and a host writes ordinary CSS. SuperDoc writes background-color: #fff inline on the host element and on the .ProseMirror root inside it (#applyBaseStyles), and it writes both again on every style pass — at mount, on replaceContent, on updatePageStyle and when the file is replaced. An inline declaration loses only to an !important author rule, and the package cannot answer inline either: style.backgroundColor = x is setProperty(name, value, ''), which drops the priority. suppressDefaultDocxStyles skips the whole pass and with it the document's own typeface and size, the ProseMirror line height and the role/aria-* attributes, so it is no way out. mount therefore marks the host it was given data-meditor-word, and the package injects one rule, once per document:

[data-meditor-word],
[data-meditor-word] .ProseMirror {
  background-color: var(--mw-surface, #fff) !important;
}

A rule and not a write, because a rule needs no hook into SuperDoc's passes: it already holds when the next pass ends, and a .ProseMirror a later pass creates is matched by the same descendant selector. A custom property and not a wordMode({ surface }) option, because a theme toggle then costs no re-mount, and a re-mount would take the caret back to the start of the document. A host still shipping its own !important on .sd-editor-scoped keeps winning on specificity until it deletes it, so the two can be swapped in either order.

bun run demo serves demo/ on port 5199: a one-part .docx built from a real HLCpat styles.xml extract, the picker over ten body-prose styles, a web/print radio pair, a light/dark Grund pair over --mw-surface, and an export button.

What was measured, on SuperDoc 1.46.3

bun run bench:bundle re-measures the first three lines; bun test test the rest. Numbers here are 28.09.2026 and are the reason paliad's design reads the way it does.

Size

raw gzip
@harbour-enterprises/superdoc/super-editor — what word mode imports 7 677 kB 1 839 kB
@harbour-enterprises/superdoc — the application shell 8 642 kB 2 092 kB
@harbour-enterprises/superdoc/style.css 110 kB 19 kB

For scale, paliad's whole app today is 1 159 kB raw / 344 kB gzip. SuperDoc must be dynamically imported, which is why mount is async and why the import in word-mode.ts is a dynamic one.

SuperDoc's prebuilt chunks do not tree-shake: the super-editor entry carries the Vue 3 runtime even though nothing here mounts a Vue component. yjs, y-prosemirror and @hocuspocus/provider are non-optional peer dependencies and have to be installed even with collaboration off, each at the range SuperDoc declares: @hocuspocus/provider is ^2.13.6 there, and a 4.x pin resolves a second copy beside the 2.x one SuperDoc itself gets.

The round trip

A style set through setStyleById comes out as w:pStyle on that paragraph and on no other. Text, Umlaute and en dashes are unchanged. The export writes no <w:rPr>: SuperDoc resolves a style's character formatting into marks on the text to draw it, and does not write those marks back as direct formatting. A run insertParagraphs was told is bold or italic is the one exception — that is direct formatting the host asked for, and it comes back as <w:b/> or <w:i/>.

SuperDoc adds Title, Subtitle, Heading1, Heading2, Heading3 and Hyperlink to styles.xml when the document does not already define them. suppressDefaultDocxStyles does not stop it. It rewrites no definition the document brought, so a part carrying a firm's whole styles.xml gets nothing added.

A whole document, not a part

test/carrier-round-trip.test.ts runs against a real firm carrier when PALIAD_CARRIER_DOCX names one. On paliad's HLC Patents Style.docx: no part lost, no part invented, the logo byte-identical, word/glossary/document.xml (63 block-library docParts) byte-identical, every content-control tag intact, the body text unchanged, no style definition rewritten.

Two losses, both pinned by a test:

  • <w:bookmarkEnd> is dropped, leaving the bookmark open. This is the one that can make Word offer to repair a filed document.
  • The SharePoint customXml stores are rewritten, and item4.xml's <FormTemplates> is replaced by an empty element.

mediaFiles is the option name for the third value Editor.loadXmlData returns. Pass it under any other name and the image part is dropped silently while the <w:drawing> that references it stays — a red cross on a filed submission.

The page canvas

Measured in the demo at 1280 px and at 700 px, on the six-paragraph fixture part, with Chromium.

viewLayout: 'web' viewLayout: 'print'
host box at a 958 px container 958 × 279 px 958 × 1123 px
host box at a 634 px container 634 × 321 px 794 × 1123 px — the page overflows the container by 159 px
min-width / min-height SuperDoc sets none 793.73 px (8.27 in) / 1122.53 px (11.69 in)
page margin as host padding none 94.47 px (0.98 in) each side
paragraph indents from styles.xml 0 / 0 / 0 / 76 / 38 / 0 px identical
the picker follows the caret into HLCpat-Level-L2 yes yes
a style applied lands on that paragraph and no other yes yes
export 6 348 bytes 6 348 bytes
console errors none none

'web' is the default because a part is not a page. Five paragraphs under a printed page leave 844 px of whitespace, and in a container narrower than the page the page itself overflows sideways. Nothing about the document's appearance is given up: a paragraph's indent, size and style come from styles.xml either way, and web layout drops only the page's own width, minimum height and margins.

A short document shrink-wraps rather than filling the host, because @mai/meditor's host div is a flex container and the engine's root is a flex item. A paragraph wider than the host reflows to the host, so nothing overflows; only a document with no line long enough to wrap sits narrower than the box it is in.

The layout is the host's option and nothing else. SuperDoc parses the document's own w:view (OOXML ST_View) into converter.viewSetting and writes it back verbatim, and never reads it to decide how to draw — a part saved from Word's own Web Layout still opens in whatever the host asked for, and keeps its w:view through the round trip.

Telemetry

SuperDoc posts the page URL, the hostname, the user agent, the screen size and a document hash to https://ingest.superdoc.dev/v1/collect on every document open. It is on by default. This package sets telemetry: { enabled: false } unless a host passes telemetry: true.

Two API traps

  • The legacy constructor (new Editor({ element, content })) runs SuperDoc's own setDocumentMode inside its init, before the ProseMirror view exists, and throws there in a browser. Use the lifecycle API: construct, then open(blob). The constructor opens a blank document of its own first, so close() comes before open() — open refuses any state but initialized or closed.
  • The caret's paragraph is not selection.$from.parent: text sits inside a run node, so the immediate parent is the run and carries no style. Walk up to the nearest paragraph.

Dependencies

Dependencies

ID Version
@harbour-enterprises/superdoc ^1.46.3
@hocuspocus/provider ^2.13.6
y-prosemirror ^1.3.7
yjs ^13.6.19

Development Dependencies

ID Version
@mai/meditor ^0.9.4
@sveltejs/vite-plugin-svelte ^7.3.1
@types/bun ^1.3.9
@types/jsdom ^30.0.0
jsdom ^30.0.1
jszip ^3.10.2
svelte ^5.57.1
typescript ^5.0.0
vite ^6.0.0

Peer Dependencies

ID Version
@mai/meditor ^0.9.1
Details
npm
2026-09-30 13:57:08 +00:00
0
AGPL-3.0
latest
13 KiB
Assets (1)
Versions (4) View all
0.1.3 2026-09-30
0.1.2 2026-09-28
0.1.1 2026-09-28
0.1.0 2026-09-28