oconv-ir/v1→.odtbytes, tier 2, composing@awacloud/odf's typed body model and typed named styles.
Module oconvIrToOdt | Source packages/front/office/oconv/src/write/ir-to-odt.js | Deps oconvIr, odt | Worker-safe yes
Resolve
import { ModuleRuntime } from '@awacloud/fw/core/runtime.js';
import { fw_require, modules } from '@awacloud/oconv';
const runtime = new ModuleRuntime();
runtime.registerAll(fw_require);
runtime.registerAll(modules);
const oconvIrToOdt = runtime.resolve('oconvIrToOdt');
Reached in practice through oconv.fromMd({ markdown, target: 'odt' }) or
oconv.convert({ ..., target: 'odt' }) (../oconv.md).
API
| Member | Signature | Returns | Throws |
|---|---|---|---|
irToOdt |
(ir: object, opts?: {assets?: Object<string, Uint8Array>}) => IrToOdtResult |
{bytes: Uint8Array, losses: IrToOdtLoss[]} |
Error oconv: invalid ir (<code> at <path>) when ir fails oconvIr.validate |
opts.assets is the same manifest oconvIrToDocx/oconvIrToPdf take
({ <markdown image destination>: Uint8Array }, keyed verbatim). An image
whose bytes are reachable is placed as a Pictures/ package part at a
default box and records image/size-defaulted; an image with no reachable
bytes is dropped and records image/dropped (see Notes).
Examples
Write a heading + paragraph to .odt
// Build the IR with the oconvIr helpers: a bare-literal node tree does not
// pass `oconvIr.validate`.
const { node, doc } = runtime.resolve('oconvIr');
const ir = doc([
node('heading', { level: 1 }, [node('run', { text: 'Title' })]),
node('paragraph', {}, [node('run', { text: 'Hello world' })])
]);
const { bytes, losses } = oconvIrToOdt.irToOdt(ir);
// bytes — a non-empty .odt (package/ZIP) Uint8Array; losses === []
const odt = runtime.resolve('odt');
odt.read(bytes).body[0];
// { type: 'heading', outlineLevel: 1, styleName: 'Heading_20_1', runs: [...] }
Executed against the live package (2026-10-03):
bytes.length > 0, losses is [], styles.xml defines the nine named
styles Standard, Text_20_body, Heading, Heading_20_1..Heading_20_6, and
odt.read(bytes).body[0] reads back styleName: 'Heading_20_1',
outlineLevel: 1.
Place an image from opts.assets
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature
const withImage = doc([
node('paragraph', {}, [
node('run', { text: 'Logo: ' }),
node('image', { name: 'logo', alt: 'Logo' })
])
]);
const placed = oconvIrToOdt.irToOdt(withImage, { assets: { logo: png } });
placed.losses;
// [{ code: 'image/size-defaulted', detail: 'logo' }]
const zip = runtime.resolve('zip');
Object.keys(zip.unzipSync(placed.bytes)).filter((p) => p.startsWith('Pictures/'));
// ['Pictures/image1.png']
oconvIrToOdt.irToOdt(withImage).losses; // no assets, no carried bytes
// [{ code: 'image/dropped', detail: 'logo' }]
Executed against the live package (2026-10-06): the placed write's
losses is [{ code: 'image/size-defaulted', detail: 'logo' }], its only
Pictures/ entry is Pictures/image1.png holding the 8 input bytes, its
content.xml carries one <draw:frame text:anchor-type="as-char" svg:width="5.08cm" svg:height="3.81cm"> whose draw:image has
xlink:href="Pictures/image1.png", and the write without assets records
[{ code: 'image/dropped', detail: 'logo' }].
Notes
- Styles part — fixed, caller-invisible: every write emits
styles.xmlthroughodt.write(doc, { styles }), headings or not, with exactly nine typed named paragraph styles, in this order:Standard;Text_20_body(Text body, based onStandard, 0 / 0.247 cm margins);Heading(based onStandard, nextText_20_body, keep-with-next, 0.423 / 0.141 cm margins);Heading_20_1..Heading_20_6(Heading 1..Heading 6, based onHeading, nextText_20_body,style:default-outline-levelN, bold, 16/14/13/12/11/11 pt — the same sizes as the docx writer's 32/28/26/24/22/22 half-points). Everytext:hnamesHeading_20_<level>(_20_is LibreOffice's encoding of a space in the programmatic style name), so every referenced style is defined. The name is presentation only: the read leg recovers the level fromoutlineLevel, and a read heading simply carries the extrastyleName. There is no option to supply or alter styles — tier 3 (caller styling) is still not promised; heading sizes are fixed points, not LibreOffice's percentages. - Bordered tables: every table is written with
grid: true.@awacloud/odfthen synthesises the bordered cell styleawa-c-b(fo:bordersingle line) and the margins-aligned table styleawa-tb-m(table:align="margins") as automatic styles; this module still builds neither. On read,odt.readconsumes and drops those styles: the table reads backgrid: true, its cells carry nostyleName, andautoStylesstays undefined. List levels carry the label-alignmentstyle:list-level-propertiesgeometry (also odf's) and the bullet no font. - The odf typed write model expresses run emphasis, hyperlinks, ordered
vs. bullet lists and text-body tables WITHOUT loss —
odt.write(body plus the fixed{ styles }above) synthesizes the automatic styles (awa-t-*/awa-l-*,awa-c-b/awa-tb-mforgridtables) and font-face declarations itself; this module never buildsautoStyles/fontFacesexplicitly. What remains genuinely inexpressible through the typed model is degraded with an exact, non-silent loss ledger entry per occurrence — never a raw-XML passthrough node (compose-never-reimplement). - No empty paragraphs are emitted. A code block writes one paragraph
per source line and drops exactly ONE trailing newline first (the text
of a CommonMark code block ends with one), so there is no trailing empty
paragraph; an interior empty line is kept (
'a\n\nb\n'writesa, an empty paragraph,b). A paragraph whose images are ALL dropped and that has no text run emits no odt node at all — the image loss alone is recorded (image/dropped). An empty odt paragraph would collapse on the next markdown parse, so this is what keeps md → odt → md stable on a second pass. A placed image is a run, so an image-only paragraph whose image is placed does produce a paragraph. codemaps tomonospaceon aspanrun (mapRunFlags); a run with no truthy flag is a plaintextrun, never aspan.- Ordered lists carry
numFormat: '1'(md ordered lists are decimal); nesting is preserved structurally throughitems[].children. - Images are placed when their bytes are reachable. The bytes are
looked up as in the docx writer:
opts.assets[name]first, then the bytes adocx → IRread carried inescapes.docx.bytes. With bytes, the image becomes an odtimagerun of@awacloud/odf's typed write model —{ type: 'image', href, width: '5.08cm', height: '3.81cm' }, rendered as<draw:frame text:anchor-type="as-char"><draw:image/></draw:frame>— inline in place within its paragraph, or as its own paragraph at block position (also inside a table cell or a list item), and the bytes travel inodt.write'sdoc.picturesmap as a package part. Without bytes, the image is dropped (image/dropped).- Default box: every frame is 2 in × 1.5 in (
5.08cm×3.81cm), the docx writer's default 2 in × 4:3 box. The image's intrinsic size is not applied, henceimage/size-defaultedon every placed image — a degrade, never a drop. The IR'salttext is not written: the odf image run has no alternative-text field. - Part naming:
Pictures/image<k>.<ext>.kis the 1-based index of each distinct IRnamein order of its first placed occurrence; the samenametwice gives one part, written once (the first occurrence's bytes win), and two frames pointing at it.<ext>is sniffed from the leading bytes — PNG signature →png,FF D8 FF→jpg,GIF8→gif, anything else →bin. The path is never derived from thename, which may carry spaces, slashes and no extension (a docx image is typically namedGrafik 1).@awacloud/odfdeclares each part in the manifest with the media type it sniffs from the same bytes. - When no image is placed,
odt.writereceives nopictureskey. - Reader limitation: reading the written
.odtback throughoconvOdtToIrdoes not recover the image.@awacloud/odf's reader keeps the frame untyped in the paragraph's_extras.children, so it surfaces asimage/unresolved(detaildraw:frame) and md output carries no image reference for it.
- Default box: every frame is 2 in × 1.5 in (
- No byte-reproducibility claim: the fw zip writer's
_dosDateTime(mtime ?? Date.now())reaches the ODFpkg.writepath, so two calls at different instants may not be byte-identical even thoughodt.read()MODELS are always deep-equal (this module is pure — no clock, no I/O, no randomness of its own). - Determinism of the parts:
content.xmlandstyles.xmlare byte-identical across two writes of the same IR (the styles are built from constants, no input reaches them); only the container bytes may vary. - Capture-free (
fw/no-factory-capture), pure object mapping +odt.write(pure JS), hence worker-safe by construction. - See the loss matrix for the published Preserved/Degraded/Dropped classification of every code below.
Loss codes emitted by this module
| Code | Detail | Meaning |
|---|---|---|
block/degraded |
'codeBlock' |
code block written as one plain paragraph per source line — block-level monospace styling is out of this mandate's scope; inline code IS written as a monospace span |
block/degraded |
'blockquote' |
quotation's child blocks emitted in place |
block/dropped |
'hr' |
thematic break dropped |
image/size-defaulted |
the image name |
bytes were reachable and the image is placed at the default 2 in × 1.5 in box — its intrinsic size is not applied |
image/dropped |
the image name |
no bytes were reachable for the image — neither opts.assets[name] nor the docx reader's escapes.docx.bytes |
See also
ir-to-docx— the equivalent.docxwriter.ir-to-md/ir-to-pdf— the other two IR writers.- Loss matrix — published fidelity classification.