Files
edentext/src/lib/import/odt.ts
T
Steffen Becker 78e35cef2c feat: a reference finds the text it points at, and follows it
Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.

ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
2026-08-09 13:56:22 +00:00

1933 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { fitInlineImage } from '../editor/extensions/image';
import { odfChartDataUrl } from './chart';
import { formatTabStops } from '../editor/extensions/tabStops';
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '../styles/tableStyles';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
import { astToLatex } from '../math/latex';
import { parseMathml } from '../math/mathml';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc, HfSet } from '../storage/headerFooter';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
// .odt → TipTap JSON, inverting export/odt.ts. Editor-expressible content becomes its
// native node/mark/attr; values matching the editor's defaults are suppressed so round
// trips don't accrete explicit attrs. Unsupported content degrades gracefully (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;
format: PageFormat | null;
// The grid every tab past the last custom stop falls on; the format's own fallback
// when the file declares none.
tabIntervalCm: number;
// Single-paragraph docs in the hfExtensions schema; null = no zone.
header: HfDoc;
footer: HfDoc;
// First-page variants (Word "Different First Page" / ODF header-first). null when
// the file has no first-page override; only used when differentFirstPage is set.
headerFirst: HfDoc;
footerFirst: HfDoc;
differentFirstPage: boolean;
// Even-page variants (Word "Different Odd & Even Pages" / ODF header-left). null when
// the file has no even-page override; only used when differentOddEven is set.
headerEven: HfDoc;
footerEven: HfDoc;
differentOddEven: boolean;
// One set per section, in body order — [0] repeats the fields above (the app's own
// editable zones); the rest belong to blocks after a `sectionBreak`.
hfSections: HfSet[];
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
headerDistanceCm: number | null;
footerDistanceCm: number | null;
// Document spell-check language; NO_LANGUAGE when the file's language has no
// bundled dictionary; null when the file declares none.
language: DocumentLanguage | null;
// Fonts embedded in the package, to register via FontFace so they render.
fonts: EmbeddedFont[];
// The document's named paragraph styles: the file's own (only those it uses, plus
// their parent chains) merged over the editor's built-ins.
styles: StyleSheet;
warnings: string[];
}
// Where a block sits: only 'body' takes page breaks/columns/TOCs, and a 'list'
// paragraph's indent lives in the list style rather than its own margin.
type BlockKind = 'body' | 'list' | 'cell';
// `files` is the full unzipped archive so image converters can read Pictures/ binaries;
// imageCache dedupes repeated hrefs into one data-URI. pendingBlocks is the side channel
// for text boxes found in inline content — convertBlocks flushes them after the anchor.
type Ctx = {
resolver: StyleResolver;
// ODF style name → display name, and the names blocks actually reference: the
// document's style registry is built from these (only used styles are kept).
styleNames: Map<string, string>;
usedStyles: Set<string>;
charStyleNames: Map<string, string>;
usedCharStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
imageCache: Map<string, string>;
convertedImages: ConvertedImages;
pendingBlocks: Node[];
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
// Master pages the body switches to, in order — one section each past the first.
masterPages: string[];
// Bookmark ranges open at this point of the walk, outermost first; a range may end in
// a later paragraph than it started in, so the set outlives one convertInline call.
openBookmarks: Set<string>;
};
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
// the browser can't display (the caller distinguishes the two for its warning).
function loadImageDataUrl(href: string, ctx: Ctx): string | null {
const cached = ctx.imageCache.get(href);
if (cached) return cached;
// A format the browser can't render may have been pre-decoded to PNG by the async pass.
const converted = ctx.convertedImages.get(href);
if (converted) { ctx.imageCache.set(href, converted); return converted; }
const bytes = ctx.files[href];
if (!bytes) return null;
const url = imageDataUrl(bytes, href);
if (!url) return null;
ctx.imageCache.set(href, url);
return url;
}
// ODF style:wrap names the side TEXT flows on (inverse of the image side); pick a
// side from horizontal-pos when the value doesn't name one (parallel/dynamic/…).
function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined): 'left' | 'right' | 'topBottom' {
if (wrapVal === 'none') return 'topBottom';
if (wrapVal === 'left') return 'right'; // text on left ⇒ image on the right
if (wrapVal === 'right') return 'left'; // text on right ⇒ image on the left
return hpos && /right/.test(hpos) ? 'right' : 'left';
}
// draw:transform rotate() is CCW radians; the editor stores CW degrees.
function frameRotationDeg(el: Element): number {
const transform = el.getAttributeNS(NS.draw, 'transform');
const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform);
if (!rot) return 0;
return ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
}
// Rotation + wrap attrs, shared by images, text boxes and shapes. A non-as-char anchor or
// an explicit style:wrap floats the element (free x/y collapse to the nearest side). An
// explicit as-char anchor stays inline: LibreOffice's Graphics style carries a style:wrap.
function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>, gp: PropMap): void {
const deg = frameRotationDeg(el);
if (deg) attrs.rotation = deg;
const anchor = el.getAttributeNS(NS.text, 'anchor-type');
if (anchor === 'as-char') return;
const wrapVal = gp['style:wrap'];
if (anchor || wrapVal) {
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
}
// A frame placed by coordinate (style:horizontal-pos="from-left") keeps its x in the
// column; the wrap mode alone would snap it to a side.
const hpos = gp['style:horizontal-pos'];
const x = hpos === 'from-left' ? lengthToCm(el.getAttributeNS(NS.svg, 'x')) : null;
if (x != null && attrs.wrap) attrs.wrapOffset = Math.round(x * 100) / 100;
// Set against one end of its band rather than filling it (Word's positionH align).
if (attrs.wrap === 'topBottom' && (hpos === 'left' || hpos === 'right')) attrs.wrapAlign = hpos;
// Likewise down the page, but only against the anchor paragraph — a page-relative
// frame is placed absolutely, which one in the text flow cannot be.
const rel = gp['style:vertical-rel'];
const y = gp['style:vertical-pos'] === 'from-top' && (!rel || rel.startsWith('paragraph') || rel === 'line')
? lengthToCm(el.getAttributeNS(NS.svg, 'y')) : null;
if (y != null && y > 0 && attrs.wrap) attrs.wrapOffsetY = Math.round(y * 100) / 100;
}
// A paragraph mark that declares nothing keeps the style's font, but the runs may all
// agree on another one — then that is the paragraph's font, strut included. Without it
// the taller style strut governs every line and the block renders too high.
export function applyUniformRunFont(attrs: Record<string, unknown>, content: { type: string; marks?: { type: string; attrs?: Record<string, unknown> }[] }[]): void {
let family: string | null | undefined;
let size: string | null | undefined;
let runs = 0;
for (const n of content) {
if (n.type !== 'text') continue;
const ts = (n.marks ?? []).find((m) => m.type === 'textStyle')?.attrs ?? {};
const f = (ts.fontFamily as string) ?? null;
const s = (ts.fontSize as string) ?? null;
if (runs && f !== family) family = undefined;
if (runs && s !== size) size = undefined;
if (!runs) { family = f; size = s; }
runs++;
}
if (!runs) return;
if (attrs.fontFamily == null && family) attrs.fontFamily = family;
if (attrs.fontSize == null && size) attrs.fontSize = size;
}
// A top-and-bottom frame set below its paragraph's top sinks behind the paragraph's
// text: a full-width float pushes every following line under itself, so where there is
// text the offset can only be drawn as the lines standing above the frame.
export function sinkOffsetFrames(content: { type: string; text?: string; attrs?: Record<string, unknown> }[]): void {
const sinks = (n: { type: string; attrs?: Record<string, unknown> }) =>
n.type === 'image' && n.attrs?.wrap === 'topBottom' && (n.attrs.wrapOffsetY as number) > 0;
if (!content.some(sinks) || !content.some(n => n.type === 'text' && n.text?.trim())) return;
const frames = content.filter(sinks);
for (const f of frames) content.splice(content.indexOf(f), 1);
content.push(...frames);
}
// Two top-and-bottom frames set against opposite ends of nearby paragraphs share one
// band and sit side by side, as they do in LibreOffice and Word. Only such a pair keeps
// its wrapAlign: a lone frame reserves the whole band, which is what the wrap means.
export function pairAlignedFrames(blocks: { content?: { type: string; attrs?: Record<string, unknown> }[] }[], columnPx: number): void {
const found: { attrs: Record<string, unknown>; at: number }[] = [];
blocks.forEach((b, i) => {
for (const node of b.content ?? [])
if (node.type === 'image' && node.attrs?.wrap === 'topBottom' && node.attrs.wrapAlign) found.push({ attrs: node.attrs, at: i });
});
const paired = new Set<Record<string, unknown>>();
for (let i = 0; i + 1 < found.length; i++) {
const [a, b] = [found[i], found[i + 1]];
if (paired.has(a.attrs) || b.at - a.at > 3 || a.attrs.wrapAlign === b.attrs.wrapAlign) continue;
const total = ((a.attrs.width as number) ?? 0) + ((b.attrs.width as number) ?? 0);
if (!total || total > columnPx * 1.15) continue;
// Word lets the two overlap in the middle; two floats cannot, so a pair a little
// wider than the column is scaled down to it instead of breaking apart.
if (total > columnPx) for (const f of [a, b]) scaleFrame(f.attrs, columnPx / total);
paired.add(a.attrs).add(b.attrs);
}
for (const f of found) if (!paired.has(f.attrs)) f.attrs.wrapAlign = null;
}
function scaleFrame(attrs: Record<string, unknown>, factor: number): void {
for (const k of ['width', 'height'])
if (typeof attrs[k] === 'number') attrs[k] = Math.max(1, Math.round(attrs[k] * factor));
}
// A <draw:frame><draw:image> → an image node. Size comes from the frame's svg
// geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames
// with a wrap become floating (wrap mode + svg:x/svg:y position).
function convertFrame(frame: Element, ctx: Ctx): Node | null {
// A frame may list several <draw:image> alternatives (e.g. a metafile + a bitmap
// fallback) — prefer the first the browser can display.
const hrefs = Array.from(frame.getElementsByTagNameNS(NS.draw, 'image'))
.map(im => (im.getAttributeNS(NS.xlink, 'href') ?? '').replace(/^\.\//, ''))
.filter(Boolean);
if (!hrefs.length) return null;
const href = hrefs.find(h => loadImageDataUrl(h, ctx)) ?? hrefs[0];
let src = loadImageDataUrl(href, ctx);
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
if (!src) {
// The frame still occupies its box, so an undrawable picture comes in as a
// placeholder of that size rather than collapsing the layout around it.
if (wCm == null || hCm == null) {
ctx.warnings.add('Some images could not be read and were skipped');
return null;
}
ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF, SVM) were replaced by a placeholder');
src = placeholderImage('Image', cmToPx(wCm), cmToPx(hCm));
}
const attrs: Record<string, unknown> = { src };
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
if (title) attrs.alt = title;
applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')));
if (!attrs.wrap || attrs.wrap === 'inline') fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
return { type: 'image', attrs };
}
// A shape's fill color; fo:background-color (LibreOffice's per-shape fill) beats draw:fill.
// A drawn shape's fill defaults to solid when the keyword is absent — LibreOffice only
// writes "none" to turn it off. defaultSolid is false for plain text frames (fill none).
function shapeFill(gp: PropMap, defaultSolid: boolean): string | null {
const bg = gp['fo:background-color'];
if (bg !== undefined) return bg === 'transparent' || bg === 'none' ? null : normalizeColor(bg) ?? null;
const fill = gp['draw:fill'];
const solid = fill === 'solid' || (fill === undefined && defaultSolid);
return solid ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
}
// A shape's stroke as { color, widthPt }. fo:border ("<w> <style> <color>", LibreOffice's
// per-shape border) wins over draw:stroke. Like the fill, a drawn shape's stroke defaults
// to solid when draw:stroke is absent (color from svg:stroke-color); "none" turns it off.
function shapeStroke(gp: PropMap, defaultSolid: boolean): { color: string | null; widthPt: number | null } {
const border = gp['fo:border'];
if (border !== undefined) {
let widthPt: number | null = null, color: string | null = null, styleTok: string | null = null;
for (const part of border.trim().split(/\s+/)) {
if (/^-?[\d.]+\s*(pt|cm|mm|in|px|pc)?$/.test(part)) widthPt = lengthToPt(part);
else if (part.startsWith('#')) color = normalizeColor(part) ?? part;
else styleTok = part;
}
if (styleTok === 'none' || styleTok === 'hidden' || (widthPt != null && widthPt <= 0)) {
return { color: null, widthPt: null };
}
return { color: color ?? '#000000', widthPt };
}
const stroke = gp['draw:stroke'];
const present = stroke === undefined ? defaultSolid && gp['svg:stroke-color'] !== undefined : stroke !== 'none';
const color = present ? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000' : null;
return { color, widthPt: color ? lengthToPt(gp['svg:stroke-width']) : null };
}
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults (white
// fill, 1pt black stroke). Absent/none → explicit null, since omitting the attr would
// re-apply the default. defaultSolid marks drawn shapes, whose fill/stroke default solid.
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, defaultSolid: boolean): void {
const fillColor = shapeFill(gp, defaultSolid);
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
const { color: strokeColor, widthPt } = shapeStroke(gp, defaultSolid);
if (strokeColor !== '#000000') attrs.strokeColor = strokeColor;
if (strokeColor && widthPt != null && Math.abs(widthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
}
}
// The block content of a text box / shape: its text:* children via the cell path
// (which flattens whatever the box schema can't hold), at least one paragraph.
function textBoxContent(children: Element[], ctx: Ctx): Node[] {
const textChildren = children.filter(c => c.namespaceURI === NS.text);
const blocks = convertBlocks(textChildren, ctx, 'cell');
return blocks.length ? blocks : [{ type: 'paragraph' }];
}
// A <draw:frame><draw:text-box> → a textBox node. The height is the text-box's
// fo:min-height when present (our own export; height = minimum, content grows the
// box), else the frame's computed svg:height (LibreOffice re-saves).
function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node {
const attrs: Record<string, unknown> = {};
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
const hCm = lengthToCm(textBoxEl.getAttributeNS(NS.fo, 'min-height'))
?? lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(frame, attrs, gp);
shapeStyleAttrs(gp, attrs, false);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
}
// A <draw:frame> holding a formula object → a formula node. The MathML lives either
// in the referenced sub-document (LibreOffice, and our own export) or inline in the
// <draw:object>; parseMathml prefers our LaTeX annotation when the file carries one.
function convertFormulaFrame(frame: Element, ctx: Ctx): Node | null {
const obj = Array.from(frame.children).find(
c => c.namespaceURI === NS.draw && (c.localName === 'object' || c.localName === 'object-ole'),
);
if (!obj) return null;
const inline = obj.getElementsByTagNameNS(NS.math, 'math')[0];
const mathEl = inline ?? loadFormulaObject(obj.getAttributeNS(NS.xlink, 'href'), ctx);
if (!mathEl) return null;
const got = parseMathml(mathEl);
const latex = got.latex ?? astToLatex(got.ast);
if (!latex.trim()) return null;
// The frame's svg geometry is ignored: a formula is laid out from its own markup,
// so a stored box would only fight the renderer.
return { type: 'formula', attrs: { latex, display: aloneInParagraph(frame) } };
}
// A <draw:frame> holding a chart object → an image node drawing it, at the frame's own
// size. Read-only, like the DOCX leg: the editor has no chart object, so a re-export
// carries the picture (see import/chart.ts).
function convertChartFrame(frame: Element, ctx: Ctx): Node | null {
const obj = Array.from(frame.children).find(
c => c.namespaceURI === NS.draw && (c.localName === 'object' || c.localName === 'object-ole'),
);
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
if (!obj || wCm == null || hCm == null) return null;
const doc = loadObjectDoc(obj.getAttributeNS(NS.xlink, 'href'), ctx);
const src = doc && odfChartDataUrl(doc, cmToPx(wCm), cmToPx(hCm));
if (!src) return null;
const attrs: Record<string, unknown> = { src, width: Math.round(cmToPx(wCm)), height: Math.round(cmToPx(hCm)), alt: 'Chart' };
applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')));
if (!attrs.wrap || attrs.wrap === 'inline') fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
return { type: 'image', attrs };
}
// A display formula is one that owns its line — ODF has no flag for it (LibreOffice
// writes display="block" on every formula object it re-saves), so it is read off the
// paragraph: nothing but this frame in it.
function aloneInParagraph(frame: Element): boolean {
const p = frame.parentElement;
if (!p || p.namespaceURI !== NS.text || (p.localName !== 'p' && p.localName !== 'h')) return false;
if ((p.textContent ?? '').trim()) return false;
return Array.from(p.children).filter(c => c.namespaceURI === NS.draw).length === 1;
}
// The embedded object's content.xml — an ODF formula document's root is the MathML
// itself. A non-formula object (chart, spreadsheet) has no math root and falls through.
function loadFormulaObject(href: string | null, ctx: Ctx): Element | null {
const doc = loadObjectDoc(href, ctx);
const root = doc?.documentElement;
if (!root) return null;
return root.namespaceURI === NS.math && root.localName === 'math'
? root
: doc!.getElementsByTagNameNS(NS.math, 'math')[0] ?? null;
}
// The sub-document a draw:object points at ("./Object 1" → "Object 1/content.xml").
function loadObjectDoc(href: string | null, ctx: Ctx): Document | null {
const dir = (href ?? '').replace(/^\.\//, '').replace(/\/$/, '');
if (!dir) return null;
const bytes = ctx.files[`${dir}/content.xml`] ?? ctx.files[dir];
if (!bytes) return null;
const doc = new DOMParser().parseFromString(strFromU8(bytes), 'text/xml');
return doc.documentElement && !doc.getElementsByTagName('parsererror').length ? doc : null;
}
// draw:rect / draw:ellipse / draw:custom-shape (rect/round-rect/ellipse preset) → a
// textBox with the matching shapeKind; the shape's text is its content. Other
// custom-shape presets (stars, arrows, …) are dropped with a warning.
function convertShape(el: Element, ctx: Ctx): Node | null {
let kind: 'textbox' | 'roundRect' | 'ellipse' | null = null;
if (el.localName === 'rect') kind = 'textbox';
else if (el.localName === 'ellipse') kind = 'ellipse';
else {
const geo = el.getElementsByTagNameNS(NS.draw, 'enhanced-geometry')[0];
const type = geo?.getAttributeNS(NS.draw, 'type') ?? '';
if (type === 'ellipse' || type === 'circle') kind = 'ellipse';
else if (type === 'round-rectangle') kind = 'roundRect';
else if (type === 'rectangle' || !type) kind = 'textbox';
}
if (!kind) {
ctx.warnings.add('Unsupported shapes were removed');
return null;
}
const attrs: Record<string, unknown> = {};
if (kind !== 'textbox') attrs.shapeKind = kind;
const wCm = lengthToCm(el.getAttributeNS(NS.svg, 'width'));
const hCm = lengthToCm(el.getAttributeNS(NS.svg, 'height'));
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
applyFrameRotationAndWrap(el, attrs, gp);
shapeStyleAttrs(gp, attrs, true);
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
}
// Dispatch any draw:* element: an image stays inline; a text box / shape is a block
// node routed through ctx.pendingBlocks; everything else is dropped with a warning.
function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node } | null {
if (e.localName === 'frame') {
const textBoxEl = Array.from(e.children).find(
c => c.namespaceURI === NS.draw && c.localName === 'text-box',
);
if (textBoxEl) return { block: convertTextBoxFrame(e, textBoxEl, ctx) };
const formula = convertFormulaFrame(e, ctx);
if (formula) return { inline: formula };
const chart = convertChartFrame(e, ctx);
if (chart) return { inline: chart };
const hasImage = !!e.getElementsByTagNameNS(NS.draw, 'image')[0];
const img = convertFrame(e, ctx);
if (img) return { inline: img };
// A frame with neither image nor text box (OLE object, …) — report the drop;
// an unreadable image already warned inside convertFrame.
if (!hasImage) ctx.warnings.add('Drawings were removed');
return null;
}
if (e.localName === 'rect' || e.localName === 'ellipse' || e.localName === 'custom-shape') {
const shape = convertShape(e, ctx);
return shape ? { block: shape } : null;
}
if (e.localName === 'a') {
// draw:a wraps a drawing in a hyperlink; the editor has no image link, so unwrap
// to the inner drawing (the link is dropped, like other flattened hyperlinks).
for (const child of Array.from(e.children)) {
if (child.namespaceURI !== NS.draw) continue;
const conv = convertDrawElement(child, ctx);
if (conv) return conv;
}
return null;
}
ctx.warnings.add('Drawings were removed');
return null;
}
// ---- editor defaults to suppress on import -----------------------------------
const BODY_FONT_SIZE_PT = 12;
// 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. Headings
// render sans (HEADING_FONT), so their default pair is the metric-identical one.
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']);
// ODF line spacing multiplies the font's natural line height; see lineHeight.ts.
const LINE_HEIGHT_RATIO = 1.15;
// odf-kit emits each list level at margin-left = level × 1.27cm (label-alignment).
// A top-level list's margin beyond this level-1 base is its whole-list indent.
const LIST_BASE_MARGIN_CM = 1.27;
const LIST_INDENT_EPS_CM = 0.05;
// ---- entry --------------------------------------------------------------------
export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = new Map()): 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 styleNames = new Map<string, string>();
for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display));
const charStyleNames = new Map<string, string>();
for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display));
// Text width of the file's own page setup — the reference a table's margins are
// measured against (falls back to A4 with the ODF default margins).
const geo = resolver.pageGeometry();
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], openBookmarks: new Set() };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
// Whole-document columns declared on the page layout instead of a
// text:section: wrap the body's wrappable runs the way a section would be.
const pageCols = resolver.pageColumns();
if (pageCols) {
const wrapped: Node[] = [];
pushColumnRuns(blocks, pageCols, wrapped, ctx);
blocks = wrapped;
}
const hf = resolver.masterPageHF();
const geometry = geo;
const edge = resolver.edgeDistancesCm();
const fonts: EmbeddedFont[] = [];
for (const s of resolver.embeddedFontSources()) {
const data = files[s.href];
if (data) fonts.push({ family: s.family, weight: s.weight, style: s.style, data });
}
const odfLang = resolver.documentLanguage();
let language: DocumentLanguage | null = null;
if (odfLang) {
const code = languageFromOdf(odfLang.language, odfLang.country || undefined);
if (code) {
language = code;
} else {
language = NO_LANGUAGE;
const tag = odfLang.country ? `${odfLang.language}-${odfLang.country}` : odfLang.language;
warnings.add(`Spell-check language "${tag}" has no bundled dictionary — spell check was turned off`);
}
}
const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx) : null;
const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx) : null;
// The presence of a first-page element is the flag, even when it's empty (an empty
// first-page zone deliberately blanks page 1 while the default fills later pages).
const differentFirstPage = !!(hf.headerFirst || hf.footerFirst);
const headerEven = hf.headerLeft ? convertHfZone(hf.headerLeft, ctx) : null;
const footerEven = hf.footerLeft ? convertHfZone(hf.footerLeft, ctx) : null;
const differentOddEven = !!(hf.headerLeft || hf.footerLeft);
// A first-page/even zone reserves the band even if its default counterpart is empty.
const hasHeader = hf.header || headerFirst || headerEven;
const hasFooter = hf.footer || footerFirst || footerEven;
const header = hf.header ? convertHfZone(hf.header, ctx) : null;
const footer = hf.footer ? convertHfZone(hf.footer, ctx) : null;
return {
content: { type: 'doc', content: blocks },
margins: geometry?.margins ?? null,
orientation: geometry?.orientation ?? null,
format: geometry?.format ?? null,
tabIntervalCm: resolver.defaultTabInterval(),
header,
footer,
headerFirst,
footerFirst,
differentFirstPage,
headerEven,
footerEven,
differentOddEven,
hfSections: [
{ header, footer, headerFirst, footerFirst, differentFirstPage, headerEven, footerEven, differentOddEven },
...ctx.masterPages.map((name) => hfSetOfMasterPage(name, ctx)),
],
headerDistanceCm: hasHeader ? edge?.top ?? null : null,
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
language,
fonts,
styles: collectStyleSheet(resolver, ctx),
warnings: [...warnings],
};
}
// The zones of a named master page — what a section past the first switches to.
function hfSetOfMasterPage(name: string, ctx: Ctx): HfSet {
const hf = ctx.resolver.masterPageHF(name);
const zone = (el: Element | null) => (el ? convertHfZone(el, ctx) : null);
return {
header: zone(hf.header),
footer: zone(hf.footer),
headerFirst: zone(hf.headerFirst),
footerFirst: zone(hf.footerFirst),
differentFirstPage: !!(hf.headerFirst || hf.footerFirst),
headerEven: zone(hf.headerLeft),
footerEven: zone(hf.footerLeft),
differentOddEven: !!(hf.headerLeft || hf.footerLeft),
};
}
// A header/footer zone → one single-paragraph doc (hfExtensions schema). Multiple
// paragraphs collapse to hard line breaks; block structures flatten to their text.
function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
const inline: Node[] = [];
let textAlign: string | null = null;
let stops: string | null = null;
const boxMaps: Record<string, string>[] = [];
const addPara = (p: Element) => {
if (inline.length) inline.push({ type: 'hardBreak' });
const styleName = p.getAttributeNS(NS.text, 'style-name');
// The zone is one paragraph, so the first line's stops are the zone's.
stops ??= formatTabStops(ctx.resolver.tabStops(styleName));
if (textAlign === null) {
const ta = ctx.resolver.paraProps(styleName)['fo:text-align'] ?? '';
textAlign = ta === 'center' || ta === 'justify' ? ta : ta === 'right' || ta === 'end' ? 'right' : null;
if (textAlign === null) textAlign = ''; // only the first paragraph decides
}
boxMaps.push(paraBoxAttrs(ctx.resolver.paraProps(styleName)));
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), blockDefaults(ctx.resolver, null, null, false), true));
};
for (const child of Array.from(zoneEl.children)) {
if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) {
addPara(child);
} else if (child.namespaceURI === NS.text || child.namespaceURI === NS.table) {
// Lists/tables in headers are beyond the one-paragraph model — keep their text.
ctx.warnings.add('Lists/tables in headers or footers were flattened to text');
for (const p of Array.from(child.getElementsByTagNameNS(NS.text, 'p'))) addPara(p);
}
}
// Empty source paragraphs become blank lines (hardBreaks); an all-empty zone has no
// runs and no inter-paragraph break, so inline stays empty. Keep such a zone only when
// it carries a background/rule line (a footer that is just a colored line has no text).
const box = mergeHfBox(boxMaps);
if (inline.length === 0 && Object.keys(box).length === 0) return null;
const para: Node = { type: 'paragraph', content: inline };
const attrs: Record<string, string> = {};
if (textAlign) attrs.textAlign = textAlign;
if (stops) attrs.tabStops = stops;
Object.assign(attrs, box);
if (Object.keys(attrs).length) para.attrs = attrs;
return { type: 'doc', content: [para] };
}
// The zone collapses several source paragraphs into one, so pick the box props that
// reproduce the visible band+rule: background/side rules from the first that declares
// them, but the bottom rule from the LAST paragraph (it sits under the whole band).
function mergeHfBox(maps: Record<string, string>[]): Record<string, string> {
const out: Record<string, string> = {};
const first = (key: string) => maps.find((m) => m[key] !== undefined)?.[key];
const last = (key: string) => [...maps].reverse().find((m) => m[key] !== undefined)?.[key];
for (const [key, val] of [
['backgroundColor', first('backgroundColor')],
['borderTop', first('borderTop')],
['borderLeft', first('borderLeft')],
['borderRight', first('borderRight')],
['borderBottom', last('borderBottom')],
] as const) {
if (val !== undefined) out[key] = val;
}
return out;
}
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 -----------------------------------------------------------
// Block types a columns section (columns.ts) can contain.
const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
// Wrap a multi-column region's converted blocks into columns nodes: maximal runs of
// allowed types become one node each; anything else is emitted between the runs at
// top level — with a warning, except columns nodes (they keep their own layout).
function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void {
let count = cols.count;
if (count > 3) {
ctx.warnings.add('Sections with more than 3 columns were reduced to 3 columns');
count = 3;
}
let run: Node[] = [];
const flush = () => {
if (run.length) out.push({ type: 'columns', attrs: { count, gapCm: cols.gapCm }, content: run });
run = [];
};
for (const block of inner) {
if (COLUMNS_ALLOWED.has(block.type)) {
run.push(block);
} else {
if (block.type !== 'columns') {
ctx.warnings.add('Tables and text boxes inside a multi-column layout were moved out of the columns');
}
flush();
out.push(block);
}
}
flush();
}
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
// Emit an anchor block, then any text boxes found inside it (block nodes riding
// ctx.pendingBlocks). In the body they follow the anchor at top level, and an empty
// anchor (our export's wrapper) is dropped; in cells/boxes they unwrap in place.
const pushWithPending = (anchor: Node | null) => {
const pending = ctx.pendingBlocks.splice(0);
const anchorIsEmpty = anchor?.type === 'paragraph' && !anchor.content?.length;
if (anchor && !(pending.length && anchorIsEmpty)) out.push(anchor);
if (!pending.length) return;
if (kind === 'body') {
out.push(...pending);
} else {
ctx.warnings.add('Text boxes nested in table cells or other text boxes were flattened');
for (const box of pending) out.push(...(box.content ?? [{ type: 'paragraph' }]));
}
};
for (const el of elements) {
if (el.namespaceURI === NS.text) {
if (el.localName === 'p' || el.localName === 'h') {
pushWithPending(convertParaLike(el, ctx, kind, boldByDefault));
} else if (el.localName === 'list') {
// A list wrapping only headings is ODF outline (chapter) numbering, not a real
// list — unwrap it to plain headings instead of empty nested list levels.
const headingEls = kind === 'body' ? outlineHeadingEls(el) : null;
if (headingEls) {
for (const h of headingEls) out.push(convertParaLike(h, ctx, 'body'));
} else {
const list = convertList(el, ctx, null, 1);
if (list) out.push(list);
}
pushWithPending(null);
} else if (el.localName === 'section') {
const inner = convertBlocks(Array.from(el.children), ctx, kind, boldByDefault);
const cols = ctx.resolver.sectionColumns(el.getAttributeNS(NS.text, 'style-name'));
if (kind === 'body' && cols) pushColumnRuns(inner, cols, out, ctx);
else out.push(...inner);
} else if (el.localName === 'table-of-content' && kind === 'body') {
// Generated table of contents → a tableOfContents node (regenerated live).
out.push(convertToc(el));
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
// Other generated indexes (bibliography, …) or a TOC nested in a cell: 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, boldByDefault));
}
// 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) {
const conv = convertDrawElement(el, ctx);
// An image at block level (rare) → wrapped in a paragraph.
if (conv?.inline) out.push({ type: 'paragraph', content: [conv.inline] });
else if (conv?.block) {
ctx.pendingBlocks.push(conv.block);
pushWithPending(null);
}
}
}
return out;
}
// A <text:table-of-content> → a tableOfContents node. Entries (text + level + page) are
// parsed from the cached index-body as a starting cache; the node view recomputes page
// numbers live after mount, so parse fidelity isn't critical.
function convertToc(el: Element): Node {
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
const entries: { text: string; level: number; page: number }[] = [];
if (indexBody) {
for (const p of Array.from(indexBody.children)) {
if (p.namespaceURI !== NS.text || p.localName !== 'p') continue; // skip index-title
const style = p.getAttributeNS(NS.text, 'style-name') ?? '';
const m = /Contents_20_(\d+)/.exec(style);
const level = m ? Math.min(MAX_HEADING_LEVEL, Math.max(1, parseInt(m[1], 10))) : 1;
const { text, page } = tocEntryTextAndPage(p);
if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) });
}
}
// The file's own heading ("Inhalt", "Sommaire", …), so a reopened index keeps its name.
const title = el.getElementsByTagNameNS(NS.text, 'index-title')[0]?.textContent?.trim();
return { type: 'tableOfContents', attrs: { entries, ...(title ? { title } : {}) } };
}
// Split a TOC entry paragraph around its last <text:tab/>: the text before it is the
// entry text, the run after it is the page number. Tabs contribute no textContent, so
// partition the text nodes by their document position relative to the tab element.
function tocEntryTextAndPage(p: Element): { text: string; page: string } {
const FOLLOWING = 0x04; // Node.DOCUMENT_POSITION_FOLLOWING (the DOM Node is shadowed here)
const tabs = p.getElementsByTagNameNS(NS.text, 'tab');
const lastTab = tabs.length ? tabs[tabs.length - 1] : null;
let before = '';
let after = '';
const walker = p.ownerDocument.createTreeWalker(p, NodeFilter.SHOW_TEXT);
let n: ChildNode | null;
while ((n = walker.nextNode() as ChildNode | null)) {
const txt = n.nodeValue ?? '';
if (lastTab && lastTab.compareDocumentPosition(n) & FOLLOWING) after += txt;
else before += txt;
}
return { text: before.trim(), page: after.trim() };
}
// What a block's named style already gives it — the yardstick for "is this direct
// formatting?". Without a style in the file, the editor's own defaults stand in.
type BlockDefaults = {
fontSizePt: number;
marginTopPt: number;
marginBottomPt: number;
indentPt: number;
boldByDefault: boolean;
fonts: Set<string>;
color: string;
// Marks the style already provides. A run that opts *out* of one can't be expressed
// (only bold has fontWeight:'normal'), so it keeps the style's rendering.
// ponytail: per-mark "off" overrides need mark attrs like fontWeight's; add if files need it.
italic: boolean;
underline: boolean;
strike: boolean;
caps: CapsMode | null;
};
// Metric twins: the on-screen font and the name we declare in files mean the same thing.
const FONT_TWINS: Record<string, string[]> = {
'times new roman': ['liberation serif'],
'liberation serif': ['times new roman'],
arial: ['liberation sans'],
'liberation sans': ['arial'],
};
function blockDefaults(resolver: StyleResolver, named: string | null, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
const fallback: BlockDefaults = {
fontSizePt: hdef ? hdef.fontSizePt : BODY_FONT_SIZE_PT,
marginTopPt: hdef ? hdef.marginTopPt : 0,
marginBottomPt: hdef ? hdef.marginBottomPt : 0,
indentPt: 0,
boldByDefault: headingLevel != null || boldByDefault,
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
color: '#000000',
italic: false,
underline: false,
strike: false,
caps: null,
};
if (!named) return fallback;
const para = resolver.paraProps(named);
const text = resolver.paraTextProps(named);
const family = resolver.fontFamilyOf(text)?.toLowerCase();
const fonts = new Set(fallback.fonts);
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
const weight = text['fo:font-weight'];
const fontStyle = text['fo:font-style'];
return {
fontSizePt: lengthToPt(text['fo:font-size']) ?? fallback.fontSizePt,
marginTopPt: lengthToPt(para['fo:margin-top']) ?? 0,
marginBottomPt: lengthToPt(para['fo:margin-bottom']) ?? 0,
indentPt: lengthToPt(para['fo:margin-left']) ?? 0,
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : fallback.boldByDefault,
fonts,
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
italic: fontStyle === 'italic' || fontStyle === 'oblique',
underline: !!text['style:text-underline-style'] && text['style:text-underline-style'] !== 'none',
strike: !!text['style:text-line-through-style'] && text['style:text-line-through-style'] !== 'none',
caps: capsFromOdf(text),
};
}
// The inverse of export/odt.ts twinFontName: a declared metric twin reads back as the
// bundled family the editor renders.
function screenFontName(family: string): string {
if (family === 'Times New Roman') return 'Liberation Serif';
if (family === 'Arial') return 'Liberation Sans';
return family;
}
// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative.
function displayStyleName(odfName: string, display?: string): string {
return display || odfName.replace(/_20_/g, ' ');
}
function paraPropsFromOdf(props: PropMap): ParaProps {
const out: ParaProps = {};
const ta = props['fo:text-align'];
if (ta === 'center' || ta === 'justify') out.textAlign = ta;
else if (ta === 'right' || ta === 'end') out.textAlign = 'right';
else if (ta === 'left' || ta === 'start') out.textAlign = 'left';
const mt = lengthToPt(props['fo:margin-top']);
const mb = lengthToPt(props['fo:margin-bottom']);
const ml = lengthToCm(props['fo:margin-left']);
if (mt != null) out.spaceBefore = snapPt(mt);
if (mb != null) out.spaceAfter = snapPt(mb);
if (ml != null) out.indent = Math.round(ml * 100) / 100;
const lh = props['fo:line-height'];
if (lh && lh.endsWith('%')) {
const mult = parseFloat(lh) / 100 / LINE_HEIGHT_RATIO;
if (Number.isFinite(mult)) out.lineHeight = String(Math.round(mult * 100) / 100);
}
const bg = props['fo:background-color'];
if (bg && bg !== 'transparent') out.backgroundColor = normalizeColor(bg) ?? undefined;
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const v = props[`fo:border-${side}`] ?? props['fo:border'];
if (v && v !== 'none') out[key] = borderAttrFromOdf(v) ?? undefined;
}
return out;
}
function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
const out: TextProps = {};
const family = resolver.fontFamilyOf(props);
// Our own export declares the metric twin; keep the registry on the on-screen name
// so an export→import loop doesn't drift.
if (family) out.fontFamily = screenFontName(family);
const size = lengthToPt(props['fo:font-size']);
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
const spacing = lengthToPt(props['fo:letter-spacing']);
if (spacing) out.letterSpacingPt = Math.round(spacing * 100) / 100;
const weight = props['fo:font-weight'];
if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600;
const style = props['fo:font-style'];
if (style) out.italic = style === 'italic' || style === 'oblique';
const ul = props['style:text-underline-style'];
if (ul) out.underline = ul !== 'none';
const lt = props['style:text-line-through-style'];
if (lt) out.strike = lt !== 'none';
const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
if (color) out.color = color;
const caps = capsFromOdf(props);
if (caps) out.caps = caps;
return out;
}
// What a style declares itself: its resolved props minus the parent's.
function ownProps<T extends object>(resolved: T, parent: T): T {
const out = {} as T;
for (const key of Object.keys(resolved) as (keyof T)[]) {
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
}
return out;
}
// The document's style registry: the editor's built-ins, with the file's own definitions
// merged over them — only the styles blocks actually reference, plus their parent chains.
function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet {
const defs = resolver.namedParagraphStyles();
const sheet = builtinStyleSheet();
const keep = new Set<string>();
for (const name of ctx.usedStyles) {
let cur: string | null = name;
const seen = new Set<string>();
while (cur && defs.has(cur) && !seen.has(cur)) {
seen.add(cur);
keep.add(cur);
cur = defs.get(cur)!.parent;
}
}
for (const odfName of keep) {
const def = defs.get(odfName)!;
const name = displayStyleName(odfName, def.display);
const parent = def.parent ? displayStyleName(def.parent, defs.get(def.parent)?.display) : null;
const builtin = sheet.paragraph[name];
// Own props = resolved minus the parent's resolved values. Going through the
// resolver keeps relative sizes ("130%") and values a file repeats from its parent
// from landing here raw.
const style: Style = {
name,
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
next: builtin?.next ?? null,
builtin: builtin?.builtin,
para: ownProps(paraPropsFromOdf(resolver.paraProps(odfName)), paraPropsFromOdf(def.parent ? resolver.paraProps(def.parent) : {})),
text: ownProps(textPropsFromOdf(resolver.paraTextProps(odfName), resolver), textPropsFromOdf(def.parent ? resolver.paraTextProps(def.parent) : {}, resolver)),
};
const level = /^Heading (\d)$/.exec(name);
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
for (const odfName of ctx.usedCharStyles) {
const name = ctx.charStyleNames.get(odfName) ?? odfName;
const builtin = sheet.character[name];
sheet.character[name] = {
name, parent: null, next: null, builtin: builtin?.builtin,
para: {}, text: textPropsFromOdf(resolver.spanTextProps(odfName), resolver),
};
}
return sheet;
}
// The block's yardstick plus what the run's character style provides.
function charDefaults(ctx: Ctx, base: BlockDefaults, odfName: string): BlockDefaults {
const props = ctx.resolver.spanTextProps(odfName);
const family = ctx.resolver.fontFamilyOf(props)?.toLowerCase();
const fonts = new Set(base.fonts);
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
const weight = props['fo:font-weight'];
const fontStyle = props['fo:font-style'];
return {
...base,
fontSizePt: lengthToPt(props['fo:font-size']) ?? base.fontSizePt,
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : base.boldByDefault,
fonts,
color: (props['fo:color'] && normalizeColor(props['fo:color'])) || base.color,
italic: fontStyle ? fontStyle === 'italic' || fontStyle === 'oblique' : base.italic,
underline: props['style:text-underline-style'] ? props['style:text-underline-style'] !== 'none' : base.underline,
strike: props['style:text-line-through-style'] ? props['style:text-line-through-style'] !== 'none' : base.strike,
caps: capsFromOdf(props) ?? base.caps,
};
}
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): 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(MAX_HEADING_LEVEL, Math.max(1, Number.isFinite(raw) ? raw : 1));
}
// The file's own named style for this block (an automatic style is direct formatting
// layered on top of it); everything it already provides is not direct formatting.
const named = resolver.namedAncestor(styleName);
// Only a body block carries its style's name (below); anywhere else — a cell, a text
// box — the formatting has to become direct, so it is measured against the default
// style instead: the only one re-applied on import.
const defaults = blockDefaults(resolver, kind === 'body' ? named : null, isHeading ? level : null, boldByDefault);
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
// A style:master-page-name switches the page master, which is how ODF gives a section
// its own header/footer; the block that does it opens that section.
const master = kind === 'body' ? resolver.masterPageOf(styleName) : null;
if (master && ctx.masterPages[ctx.masterPages.length - 1] !== master) {
ctx.masterPages.push(master);
attrs.sectionBreak = true;
}
// Tab stops live in a child element of the paragraph properties, so they come from
// the resolver's own walk rather than the flattened paraProps.
const stops = formatTabStops(resolver.tabStops(styleName));
if (stops) attrs.tabStops = stops;
const content = convertInline(el, ctx, baseTextProps, defaults, false);
// The paragraph style's own font size is the block's line-height floor on every
// line, not only on an empty one; carry it as a block attr (mirrors the docx
// paragraph-mark size), or text smaller than the style keeps the taller strut.
const markSize = lengthToPt(baseTextProps['fo:font-size']);
if (markSize != null && Math.abs(markSize - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(markSize);
// Keep with next: a heading does that anyway (pageBreaks.ts), so only a plain
// paragraph carries it — otherwise every heading would accrete the producer's flag.
if (!isHeading && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true;
if (!isHeading && paraProps['fo:keep-together'] === 'always') attrs.keepLines = true;
const markFont = resolver.fontFamilyOf(baseTextProps);
if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont;
applyUniformRunFont(attrs, content);
sinkOffsetFrames(content);
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
if (isHeading) attrs.level = level;
// Only top-level blocks carry a style: list items and cells reference the producer's
// own plumbing styles (List_20_Bullet, Table_20_Contents), which are not user styles.
if (named && kind === 'body') {
const display = ctx.styleNames.get(named) ?? named;
ctx.usedStyles.add(named);
if (display !== (isHeading ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = display;
}
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, defaults: BlockDefaults, kind: BlockKind): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
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']) ?? defaults.fontSizePt;
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 = defaults.marginTopPt;
const defBottom = defaults.marginBottomPt;
// An unspecified fo:margin is ODF's default of 0, which is also the editor's
// paragraph default — so it is suppressed, and a file that does declare spacing
// (e.g. LO's 0.212cm Text Body) keeps it as an explicit value.
const mt = lengthToPt(paraProps['fo:margin-top']) ?? 0;
const mb = lengthToPt(paraProps['fo:margin-bottom']) ?? 0;
if (Math.abs(mt - defTop) > EPS_PT) attrs.spaceBefore = snapPt(mt);
if (Math.abs(mb - defBottom) > EPS_PT) attrs.spaceAfter = snapPt(mb);
// Left indent → fo:margin-left (cm). Skip lists: their indent lives in the
// list-style level properties, not paraProps. What the style already indents is not
// direct formatting.
if (kind !== 'list') {
const ml = lengthToPt(paraProps['fo:margin-left']) ?? 0;
if (Math.abs(ml - defaults.indentPt) > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
// fo:text-indent is the first line only; negative is a hanging indent.
const ti = lengthToCm(paraProps['fo:text-indent']);
if (ti != null && Math.abs(ti) > 0.02) attrs.indentFirst = Math.round(ti * 100) / 100;
const mr = lengthToCm(paraProps['fo:margin-right']);
if (mr != null && mr > 0.02) attrs.indentRight = Math.round(mr * 100) / 100;
}
// Manual page break (fo:break-before). Honored for top-level blocks only
// (pageBreaks.ts forces them to the next page top); the editor has no column
// breaks, so only "page". Cell/list blocks can't carry it.
if (kind === 'body' && paraProps['fo:break-before'] === 'page') attrs.breakBefore = 'page';
// Widow-orphan control: LibreOffice writes 0 for "off", absent means the XSL-FO
// default of 2 (on) — so only an explicit 0 disables it.
if (paraProps['fo:widows'] === '0' || paraProps['fo:orphans'] === '0') attrs.widowControl = false;
// Paragraph background ("colored field") + per-side borders ("rule line").
Object.assign(attrs, paraBoxAttrs(paraProps));
return attrs;
}
// Paragraph background + per-side border attrs (paragraphBox.ts) from resolved paraProps.
// Each key is omitted when absent so a paragraph with no box props stays clean. Borders:
// a per-side fo:border-* overrides the all-sides fo:border; 'none' ⇒ no border (undefined).
export function paraBoxAttrs(paraProps: PropMap): Record<string, string> {
const out: Record<string, string> = {};
const bg = paraProps['fo:background-color'];
if (bg && bg !== 'transparent') {
const c = normalizeColor(bg);
if (c) out.backgroundColor = c;
}
const all = paraProps['fo:border'];
const sides: [string, string][] = [
['borderTop', 'fo:border-top'],
['borderRight', 'fo:border-right'],
['borderBottom', 'fo:border-bottom'],
['borderLeft', 'fo:border-left'],
];
for (const [attr, key] of sides) {
const raw = paraProps[key] ?? all;
if (raw == null) continue;
const v = borderAttrFromOdf(raw, false);
if (v && v !== 'none') out[attr] = v;
}
return out;
}
// 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;
}
// ---- date/time fields ---------------------------------------------------------
// Parse a <number:date-style>/<number:time-style> body into our token model so it can
// be matched to a catalog format. number:style="long" = padded/4-digit, and hours
// become 12-hour when an am-pm token is present.
function parseNumberStyleTokens(styleEl: Element): Token[] {
const toks: Token[] = [];
for (const c of Array.from(styleEl.children)) {
if (c.namespaceURI !== NS.number) continue;
const long = c.getAttributeNS(NS.number, 'style') === 'long';
switch (c.localName) {
case 'text': toks.push({ t: 'lit', s: c.textContent ?? '' }); break;
case 'year': toks.push({ t: 'year', long }); break;
case 'month': {
const textual = c.getAttributeNS(NS.number, 'textual') === 'true';
toks.push({ t: 'month', style: textual ? (long ? 'longText' : 'shortText') : (long ? 'num2' : 'num') });
break;
}
case 'day': toks.push({ t: 'day', pad: long }); break;
case 'day-of-week': toks.push({ t: 'weekday', long }); break;
case 'hours': toks.push({ t: 'hour24', pad: long }); break;
case 'minutes': toks.push({ t: 'minute' }); break;
case 'seconds': toks.push({ t: 'second' }); break;
case 'am-pm': toks.push({ t: 'ampm' }); break;
}
}
if (toks.some(t => t.t === 'ampm')) {
for (let i = 0; i < toks.length; i++) {
if (toks[i].t === 'hour24') toks[i] = { t: 'hour12', pad: (toks[i] as { pad: boolean }).pad };
}
}
return toks;
}
// ISO local datetime for the node's `value`: a date-value is kept; a time-value
// (PThHmMsS) is placed on today's date (only its time part is rendered).
function fieldValue(kind: 'date' | 'time', raw: string | null): string {
if (kind === 'date') {
const d = raw ? new Date(raw) : null;
return d && !isNaN(d.getTime()) ? raw! : '';
}
const m = raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null;
if (!m) return '';
const now = new Date();
now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0);
return toDateValue(now);
}
// A <text:date>/<text:time> whose data style matches a known format → a live field
// node; returns null (keep the shown text) for an unrecognised format.
function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
const styleEl = ctx.resolver.numberStyle(e.getAttributeNS(NS.style, 'data-style-name'));
if (!styleEl) return null;
const tokens = parseNumberStyleTokens(styleEl);
// Kind follows the number style's actual tokens, not the element name: OpenOffice
// can wrap a date field in <text:time> yet reference a date-style, and vice versa.
const kind = numberStyleKind(tokens) ?? (e.localName === 'time' ? 'time' : 'date');
const format = matchFormat(tokens, kind);
if (!format) return null;
const fixed = e.getAttributeNS(NS.text, 'fixed') === 'true';
const raw = e.getAttributeNS(NS.text, 'date-value') ?? e.getAttributeNS(NS.text, 'time-value');
return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
}
// A number style with any calendar token is a date style; hours/minutes → time; else null.
function numberStyleKind(tokens: Token[]): 'date' | 'time' | null {
if (tokens.some(t => t.t === 'year' || t.t === 'month' || t.t === 'day' || t.t === 'weekday')) return 'date';
if (tokens.some(t => t.t === 'hour24' || t.t === 'hour12' || t.t === 'minute' || t.t === 'second' || t.t === 'ampm')) return 'time';
return null;
}
// ---- inline conversion --------------------------------------------------------
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: BlockDefaults, hfFields = false): Node[] {
const out: Node[] = [];
// The named character style of the enclosing span, if any: its formatting belongs to
// the style, so the run only keeps what goes beyond it.
let charStyle: string | null = null;
const pushText = (text: string, props: PropMap, linkHref?: string) => {
// Strip our export sentinels (SEG/LBR) defensively — never legitimate text.
let clean = text.replace(/[-]/g, '');
if (clean.includes('\n')) {
// Newlines in ODF text content are formatting whitespace (real breaks
// are text:line-break): drop whitespace-only nodes, collapse the rest.
if (!clean.trim()) return;
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
}
if (!clean) return;
const marks = marksFor(props, ctx.resolver, charStyle ? charDefaults(ctx, defaults, charStyle) : defaults);
if (charStyle) {
const display = ctx.charStyleNames.get(charStyle) ?? charStyle;
ctx.usedCharStyles.add(charStyle);
marks.push({ type: 'charStyle', attrs: { name: display } });
}
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
// The one-paragraph header/footer schema has no bookmark mark.
const bookmark = hfFields ? undefined : ctx.openBookmarks.values().next().value;
if (bookmark) marks.push({ type: 'bookmark', attrs: { name: bookmark } });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
out.push(node);
};
const walk = (el: Element, props: PropMap, linkHref?: string) => {
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 3 /* text */) {
pushText(child.nodeValue ?? '', props, linkHref);
continue;
}
if (child.nodeType !== 1) continue;
const e = child as Element;
if (e.namespaceURI === NS.text) {
switch (e.localName) {
case 'span': {
const spanStyle = e.getAttributeNS(NS.text, 'style-name');
const outer = charStyle;
charStyle = ctx.resolver.namedAncestor(spanStyle, 'text') ?? outer;
walk(e, layerTextProps(props, ctx.resolver.spanTextProps(spanStyle)), linkHref);
charStyle = outer;
continue;
}
case 's': {
const c = parseInt(e.getAttributeNS(NS.text, 'c') ?? '1', 10);
pushText(' '.repeat(Number.isFinite(c) && c > 0 ? c : 1), props, linkHref);
continue;
}
case 'tab':
pushText('\t', props, linkHref);
continue;
case 'line-break': {
// Carry the run's marks so an empty line between two breaks keeps its font size.
const marks = marksFor(props, ctx.resolver, defaults);
const br: Node = { type: 'hardBreak' };
if (marks.length) br.marks = marks;
out.push(br);
continue;
}
case 'a': {
// ODF hyperlink → link mark on the contained text; an internal #bookmark
// href is kept as-is and resolved against the document's bookmarks.
const href = e.getAttributeNS(NS.xlink, 'href') ?? '';
walk(e, props, href || linkHref);
continue;
}
case 'note':
ctx.warnings.add('Footnotes and endnotes were removed');
continue;
case 'bookmark-start':
case 'bookmark-end': {
// A named range → a bookmark mark on the text it covers. A point bookmark
// (<text:bookmark/>) has no range to mark, so it is dropped.
const name = e.getAttributeNS(NS.text, 'name');
if (name) {
if (e.localName === 'bookmark-start') ctx.openBookmarks.add(name);
else ctx.openBookmarks.delete(name);
}
continue;
}
case 'bookmark-ref': {
const name = e.getAttributeNS(NS.text, 'ref-name');
const fmt = e.getAttributeNS(NS.text, 'reference-format');
// Only the two formats the editor models; any other one (chapter, number,
// …) keeps the value the file cached.
// Same for the crossRef node: in a zone the reference stays its shown text.
if (!hfFields && name && (fmt === 'text' || fmt === 'page')) {
const field: Node = { type: 'crossRef', attrs: { name, format: fmt, text: e.textContent ?? '' } };
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
if (marks.length) field.marks = marks;
out.push(field);
continue;
}
if (e.textContent) pushText(e.textContent, props, linkHref);
continue;
}
case 'bookmark':
case 'soft-page-break':
case 'change':
case 'change-start':
case 'change-end':
continue;
default:
// In headers/footers, page fields stay live fields (pageField.ts). The atom
// carries the run's marks so its digits render in the run's font/size.
if (hfFields && (e.localName === 'page-number' || e.localName === 'page-count')) {
const field: Node = { type: e.localName === 'page-count' ? 'pageCount' : 'pageNumber' };
const marks = marksFor(props, ctx.resolver, defaults);
if (marks.length) field.marks = marks;
out.push(field);
continue;
}
// Date/time fields become live dateTimeField nodes in the body (a known
// format; falls through to the shown text otherwise). The one-paragraph
// header/footer schema has no such node, so there they stay text.
if (!hfFields && (e.localName === 'date' || e.localName === 'time')) {
const field = convertDateTimeField(e, ctx);
if (field) {
// The field is an atom without inline children, so it can't inherit
// the surrounding run's font from a sibling span — carry the run's
// marks on the node so it renders in the same font/size/etc.
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
if (marks.length) field.marks = marks;
out.push(field);
continue;
}
}
// Other text fields (title, …) store their evaluated value as element
// text — keep what the source document showed.
if (e.textContent) pushText(e.textContent, props, linkHref);
continue;
}
}
if (e.namespaceURI === NS.draw) {
const conv = convertDrawElement(e, ctx);
// The one-paragraph header/footer schema holds inline images (forced
// as-character) but not boxes — those are dropped with a warning.
if (hfFields) {
if (conv?.inline?.type === 'image') out.push({ ...conv.inline, attrs: { ...conv.inline.attrs, wrap: 'inline' } });
else if (conv) ctx.warnings.add('Drawings were removed');
continue;
}
if (conv?.inline) out.push(conv.inline);
else if (conv?.block) ctx.pendingBlocks.push(conv.block);
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;
}
// ODF names the line's shape and how many lines it draws; CSS has one property for
// both, and a double line wins over the shape (it is what the reader sees).
const LINE_SHAPE: Record<string, LineStyle> = {
dotted: 'dotted', 'dot-dash': 'dotted', 'dot-dot-dash': 'dotted',
dash: 'dashed', 'long-dash': 'dashed',
wave: 'wavy',
};
function lineAttrs(style: string, type: string | undefined, color: string | undefined): Record<string, unknown> | undefined {
const attrs: Record<string, unknown> = {};
const shape = type === 'double' ? 'double' : LINE_SHAPE[style];
if (shape) attrs.lineStyle = shape;
const c = color && color !== 'font-color' ? normalizeColor(color) : undefined;
if (c) attrs.lineColor = c;
return Object.keys(attrs).length ? attrs : undefined;
}
function capsFromOdf(props: PropMap): CapsMode | null {
if (props['fo:font-variant'] === 'small-caps') return 'smallCaps';
const t = props['fo:text-transform'];
return t === 'uppercase' || t === 'lowercase' || t === 'capitalize' ? t : null;
}
function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaults): 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;
// Headings and header-row cells render bold by default (CSS): only a *non*-bold run
// needs a mark (fontWeight:normal), and a bold run needs none.
if (defaults.boldByDefault) {
if (bold === false) textStyle.fontWeight = 'normal';
} else if (bold === true) {
marks.push({ type: 'bold' });
}
// Marks the block's named style already renders need no mark of their own.
const fs = props['fo:font-style'];
if ((fs === 'italic' || fs === 'oblique') && !defaults.italic) marks.push({ type: 'italic' });
const ul = props['style:text-underline-style'];
if (ul && ul !== 'none' && !defaults.underline) {
const line = lineAttrs(ul, props['style:text-underline-type'], props['style:text-underline-color']);
marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' });
}
const lt = props['style:text-line-through-style'];
if (lt && lt !== 'none' && !defaults.strike) {
const line = lineAttrs(lt, props['style:text-line-through-type'], undefined);
marks.push(line ? { type: 'strike', attrs: line } : { type: 'strike' });
}
// "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered). A
// percentage that is not one of the two presets is a freely raised run, kept in pt.
const pos = props['style:text-position'];
if (pos) {
const pct = parseFloat(pos);
if (pos.startsWith('super')) marks.push({ type: 'superscript' });
else if (pos.startsWith('sub')) marks.push({ type: 'subscript' });
else if (Number.isFinite(pct) && pct) {
const size = lengthToPt(props['fo:font-size']) ?? defaults.fontSizePt;
textStyle.textPosition = Math.round((pct / 100) * size * 10) / 10;
}
}
const caps = capsFromOdf(props);
if (caps && caps !== defaults.caps) textStyle.caps = caps;
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;
// The style's own color (black by default; an explicit black mark would fight dark/allBlack).
if (color && color !== defaults.color) textStyle.color = color;
const sizePt = lengthToPt(props['fo:font-size']);
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = formatPt(sizePt);
const family = resolver.fontFamilyOf(props);
if (family && !defaults.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 -----------------------------------------------------------------------
// The heading elements of a <text:list> whose leaves are all headings (each list-item
// holds only a text:h and/or nested such lists) — ODF outline/chapter numbering, not a
// real list, so they import as plain headings. null when it's a genuine list.
function outlineHeadingEls(listEl: Element): Element[] | null {
const out: Element[] = [];
for (const item of Array.from(listEl.children)) {
if (item.namespaceURI !== NS.text || (item.localName !== 'list-item' && item.localName !== 'list-header')) continue;
for (const child of Array.from(item.children)) {
if (child.namespaceURI !== NS.text) return null;
if (child.localName === 'h') {
out.push(child);
} else if (child.localName === 'list') {
const nested = outlineHeadingEls(child);
if (!nested) return null;
out.push(...nested);
} else {
return null; // a paragraph or other content ⇒ a genuine list
}
}
}
return out.length ? out : null;
}
// `inheritedStyleName`: a nested text:list usually carries no style-name of its own — the
// outermost list's style governs, one level def per depth. `govMultilevel`: inside a
// display-levels chain, so an explicit numbering here is never suppressed (null = rejoin).
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false, baseCycle: OrderedCycle = ROOT_ORDERED_CYCLE): 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 displayLevels = parseInt(levelDef?.getAttributeNS(NS.text, 'display-levels') ?? '1', 10);
// A multilevel top shows plain numbers itself — detect it from its level-2 def.
const isMultilevelTop = ordered && depth === 1
&& parseInt(listLevelDef(ctx.resolver.listStyle(styleName), 2)?.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) > 1;
const inChain = ordered && (isMultilevelTop || displayLevels > 1);
// This list's rendered numbering re-anchors its children's default cycle (slot + suffix).
const renderedKey = ordered && !inChain
? orderedTypeFromFormat(levelDef!.getAttributeNS(NS.style, 'num-format'), levelDef!.getAttributeNS(NS.style, 'num-suffix'))
: null;
const childBaseCycle = childCycle(baseCycle, inChain ? 'multilevel' : renderedKey, ordered);
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, inChain, childBaseCycle);
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;
// A level's margin-left is absolute, the editor nests one LIST_BASE_MARGIN_CM per
// level — so the attr is this level's step past the one above, which DOM nesting adds
// to. Signed, floored there: a list may sit left of the base, never of the column.
let indent: number | null = null;
if (listLevelMarginLeftCm(levelDef) != null) {
const marginCm = (d: number) =>
listLevelMarginLeftCm(listLevelDef(ctx.resolver.listStyle(styleName), d)) ?? d * LIST_BASE_MARGIN_CM;
const step = marginCm(depth) - (depth > 1 ? marginCm(depth - 1) : 0);
const extra = Math.round(Math.max(-LIST_BASE_MARGIN_CM, step - LIST_BASE_MARGIN_CM) * 100) / 100;
if (Math.abs(extra) > LIST_INDENT_EPS_CM) indent = extra;
}
if (!ordered) {
const raw = levelDef?.getAttributeNS(NS.text, 'bullet-char') ?? null;
const bulletChar = bulletCharAttr(bulletCharFromOdf(raw, listLevelFontName(levelDef)), depth - 1);
const attrs: Record<string, unknown> = {};
if (bulletChar) attrs.bulletChar = bulletChar;
if (indent != null) attrs.indent = indent;
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
const node: Node = { type: 'bulletList', content: items };
if (Object.keys(attrs).length) node.attrs = attrs;
return node;
}
let listStyleType: string | null;
if (isMultilevelTop) {
listStyleType = 'multilevel';
} else if (displayLevels > 1) {
listStyleType = null; // chain member — the multilevel top carries the attr
} else {
const key = renderedKey!;
// Explicit styles inside a chain stay explicit; elsewhere the cycle default
// (re-anchored per ancestor) imports as null so round trips don't accrete attrs.
listStyleType = govMultilevel ? key : orderedTypeAttrAt(key, baseCycle);
}
const attrs: Record<string, unknown> = {};
if (start != null) attrs.start = start;
if (listStyleType) attrs.listStyleType = listStyleType;
if (indent != null) attrs.indent = indent;
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
const node: Node = { type: 'orderedList', content: items };
if (Object.keys(attrs).length) node.attrs = attrs;
return node;
}
// fo:text-align="end" on the level properties: the label is set against the far end of
// its hanging indent (Word's w:lvlJc="right"), so a wide number grows into the margin.
function listLevelRightAligned(levelDef: Element | null): boolean {
for (const props of Array.from(levelDef?.children ?? [])) {
if (props.namespaceURI !== NS.style || props.localName !== 'list-level-properties') continue;
const align = props.getAttributeNS(NS.fo, 'text-align');
return align === 'end' || align === 'right';
}
return false;
}
// Level's fo:margin-left (cm) from its <style:list-level-label-alignment> (the
// label-alignment mode odf-kit/LibreOffice use). null when absent.
function listLevelMarginLeftCm(levelDef: Element | null): number | null {
if (!levelDef) return null;
for (const props of Array.from(levelDef.children)) {
if (props.namespaceURI !== NS.style || props.localName !== 'list-level-properties') continue;
for (const la of Array.from(props.children)) {
if (la.namespaceURI === NS.style && la.localName === 'list-level-label-alignment') {
return lengthToCm(la.getAttributeNS(NS.fo, 'margin-left'));
}
}
}
return null;
}
// The level def's declared font (Word-written .odt puts Wingdings/Symbol here for
// its private-use bullet chars; LibreOffice writes real Unicode chars instead).
function listLevelFontName(levelDef: Element | null): string | null {
if (!levelDef) return null;
for (const props of Array.from(levelDef.children)) {
if (props.namespaceURI !== NS.style || props.localName !== 'text-properties') continue;
return props.getAttributeNS(NS.style, 'font-name') ?? props.getAttributeNS(NS.fo, 'font-family');
}
return null;
}
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 ------------------------------------------------------------------------
// ODF border ("0.5pt solid #000000" / "none" / absent) → the canonical '<W>pt solid
// #RRGGBB', 'none', or null; non-solid styles coerce to solid. treatDefaultAsNull folds
// the 0.5pt-black default to null — cells inherit the table's, paragraph borders don't.
export function borderAttrFromOdf(raw: string | null | undefined, treatDefaultAsNull = true): string | null {
if (!raw || raw === 'none' || raw === 'hidden') return 'none';
let widthPt: number | null = null;
let color: string | null = null;
let styleTok: string | null = null;
for (const part of raw.trim().split(/\s+/)) {
if (/^-?[\d.]+\s*(pt|cm|mm|in|px|pc)?$/.test(part)) widthPt = lengthToPt(part);
else if (part.startsWith('#')) color = normalizeColor(part) ?? part;
else styleTok = part;
}
if (styleTok === 'none' || styleTok === 'hidden') return 'none';
if (widthPt != null && widthPt <= 0) return 'none';
const w = Math.round((widthPt ?? 0.5) * 100) / 100;
const c = (color ?? '#000000').toUpperCase();
if (treatDefaultAsNull && Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
return `${w}pt solid ${c}`;
}
function convertTable(el: Element, ctx: Ctx): Node | null {
const weights = columnWeights(el, ctx.resolver);
const rows: Node[] = [];
let cellPad: CellPadding | null | undefined;
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 cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name');
// ODF has no table-level cell margin — it sits on each cell's style, so the first
// cell's stands for the table and any cell that disagrees keeps its own.
const padSides = ctx.resolver.cellPadding(cellStyleName);
if (cellPad === undefined) cellPad = cellPaddingAttr(padSides);
const ownPad = cellPaddingAttr(padSides, cellPad ?? DEFAULT_CELL_PADDING);
const rawBg = ctx.resolver.cellBackgroundColor(cellStyleName);
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
// Per-side borders; an undeclared side means no border in ODF → 'none'.
// Only null (= the editor's 0.5pt-black default) is left off the attrs.
const rawBorders = ctx.resolver.cellBorders(cellStyleName);
const borders: Record<string, string> = {};
for (const [attr, side] of [
['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'],
] as const) {
const v = borderAttrFromOdf(rawBorders[side]);
if (v !== null) borders[attr] = v;
}
// Header-shaded cells render bold by default (CSS); convert their runs like headings
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
for (let r = 0; r < repeated; r++) {
const attrs: Record<string, unknown> = { colspan, rowspan, ...borders };
if (ownPad) attrs.cellPadding = ownPad;
if (backgroundColor) attrs.backgroundColor = backgroundColor;
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);
}
}
}
if (rows.length === 0) return null;
// The named table style behind the automatic one. ODF stores no banding, so only the
// name comes back — the look rides on the cell attrs above, and the editor re-derives
// the regions from the registry (refreshTableStyles).
const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table');
const attrs: Record<string, unknown> = { ...(tableMargins(el, ctx) ?? {}) };
if (cellPad) attrs.cellPadding = cellPad;
if (named) {
attrs.tableStyle = displayStyleName(named);
// Which conditional areas the table opts into (ODF's table template attributes).
// Absent ⇒ leave the attr null, so parseTableLook falls back to the default.
const look = odfTableLook(el);
if (look) attrs.tableLook = look;
}
return Object.keys(attrs).length ? { type: 'table', attrs, content: rows } : { type: 'table', content: rows };
}
// The six table:use-*-styles attributes → the editor's space-separated tableLook attr.
// null when the file declares none (a foreign producer), so the default look applies.
function odfTableLook(el: Element): string | null {
const on: TableRegion[] = [];
let declared = false;
for (const [region, attr] of Object.entries(ODF_LOOK_ATTRS) as [TableRegion, string][]) {
// ODF_LOOK_ATTRS holds the qualified names the exporter writes; getAttributeNS
// wants the local one.
const v = el.getAttributeNS(NS.table, attr.replace('table:', ''));
if (v == null) continue;
declared = true;
if (v === 'true') on.push(region);
}
return declared ? tableLookAttr(Object.fromEntries(
TABLE_REGIONS.map(r => [r, on.includes(r)]),
) as TableLook) : null;
}
// A table narrower than the text width → the editor's marginLeft/marginRight attrs.
// LibreOffice states the width plus one margin (per table:align), the other follows.
function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight: number } | null {
const props = ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name'));
const content = ctx.contentWidthCm;
const rel = parseFloat(props['style:rel-width'] ?? '');
const width = lengthToCm(props['style:width'])
?? (Number.isFinite(rel) && rel > 0 ? (Math.min(rel, 100) / 100) * content : null);
let left = lengthToCm(props['fo:margin-left']);
let right = lengthToCm(props['fo:margin-right']);
if (left == null && width != null) {
const align = props['table:align'];
left = align === 'center' ? (content - width) / 2 : align === 'right' ? content - width : 0;
}
left = Math.max(0, left ?? 0);
if (right == null) right = width != null ? content - left - width : 0;
right = Math.max(0, right);
// Rounding noise from the producer, or a table that would be left with no room.
if (left < 0.05 && right < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
}
// 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;
}