762 lines
32 KiB
TypeScript
762 lines
32 KiB
TypeScript
import { unzipSync, strFromU8 } from 'fflate';
|
||
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
|
||
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
|
||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
|
||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||
import type { Orientation } from '../storage/pageOrientation';
|
||
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
|
||
import type { HfDoc } from '../storage/headerFooter';
|
||
|
||
// .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;
|
||
// Single-paragraph docs in the hfExtensions schema; null = no zone.
|
||
header: HfDoc;
|
||
footer: HfDoc;
|
||
// 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;
|
||
warnings: string[];
|
||
}
|
||
|
||
// 'list' zeroes the default bottom margin (export rewrites List_20_* to 0cm to
|
||
// match the editor); body/cell paragraphs default to Standard's 0.212cm.
|
||
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.
|
||
type Ctx = {
|
||
resolver: StyleResolver;
|
||
warnings: Set<string>;
|
||
files: Record<string, Uint8Array>;
|
||
imageCache: Map<string, string>;
|
||
};
|
||
|
||
const MIME_BY_EXT: Record<string, string> = {
|
||
png: 'image/png',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
gif: 'image/gif',
|
||
svg: 'image/svg+xml',
|
||
webp: 'image/webp',
|
||
bmp: 'image/bmp',
|
||
};
|
||
|
||
function bytesToBase64(bytes: Uint8Array): string {
|
||
let bin = '';
|
||
const chunk = 0x8000; // chunk so String.fromCharCode doesn't blow the call stack
|
||
for (let i = 0; i < bytes.length; i += chunk) {
|
||
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||
}
|
||
return btoa(bin);
|
||
}
|
||
|
||
// Read a Pictures/ entry from the archive into a base64 data-URI (mime by extension).
|
||
function loadImageDataUrl(href: string, ctx: Ctx): string | null {
|
||
const cached = ctx.imageCache.get(href);
|
||
if (cached) return cached;
|
||
const bytes = ctx.files[href];
|
||
if (!bytes) return null;
|
||
const ext = href.split('.').pop()?.toLowerCase() ?? '';
|
||
const url = `data:${MIME_BY_EXT[ext] ?? 'image/png'};base64,${bytesToBase64(bytes)}`;
|
||
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';
|
||
}
|
||
|
||
// 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 {
|
||
const image = frame.getElementsByTagNameNS(NS.draw, 'image')[0];
|
||
if (!image) return null;
|
||
const href = (image.getAttributeNS(NS.xlink, 'href') ?? '').replace(/^\.\//, '');
|
||
if (!href) return null;
|
||
const src = loadImageDataUrl(href, ctx);
|
||
if (!src) {
|
||
ctx.warnings.add('Some images could not be read and were skipped');
|
||
return null;
|
||
}
|
||
const attrs: Record<string, unknown> = { src };
|
||
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
|
||
const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
|
||
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;
|
||
// draw:transform rotate() is CCW radians; the editor stores CW degrees.
|
||
const transform = frame.getAttributeNS(NS.draw, 'transform');
|
||
const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform);
|
||
if (rot) {
|
||
const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
|
||
if (deg) attrs.rotation = deg;
|
||
}
|
||
// Floating frame → wrap mode (side). (as-char stays inline.)
|
||
const anchor = frame.getAttributeNS(NS.text, 'anchor-type');
|
||
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
|
||
const wrapVal = gp['style:wrap'];
|
||
if ((anchor && anchor !== 'as-char') || wrapVal) {
|
||
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
|
||
}
|
||
return { type: 'image', attrs };
|
||
}
|
||
|
||
// ---- editor defaults to suppress on import -----------------------------------
|
||
|
||
const BODY_FONT_SIZE_PT = 12;
|
||
// Standard paragraph style's fo:margin-bottom as odf-kit emits it.
|
||
const STD_MARGIN_BOTTOM_PT = lengthToPt('0.212cm')!;
|
||
// 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.
|
||
const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
|
||
|
||
// 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): 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 ctx: Ctx = { resolver, warnings, files, imageCache: new Map() };
|
||
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||
|
||
const hf = resolver.masterPageHF();
|
||
if (hf.hasVariants) {
|
||
warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used');
|
||
}
|
||
|
||
const geometry = resolver.pageGeometry();
|
||
const edge = resolver.edgeDistancesCm();
|
||
|
||
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`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: { type: 'doc', content: blocks },
|
||
margins: geometry?.margins ?? null,
|
||
orientation: geometry?.orientation ?? null,
|
||
header: hf.header ? convertHfZone(hf.header, ctx) : null,
|
||
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
|
||
headerDistanceCm: hf.header ? edge?.top ?? null : null,
|
||
footerDistanceCm: hf.footer ? edge?.bottom ?? null : null,
|
||
language,
|
||
warnings: [...warnings],
|
||
};
|
||
}
|
||
|
||
// 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;
|
||
|
||
const addPara = (p: Element) => {
|
||
if (inline.length) inline.push({ type: 'hardBreak' });
|
||
const styleName = p.getAttributeNS(NS.text, 'style-name');
|
||
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
|
||
}
|
||
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), null, 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);
|
||
}
|
||
}
|
||
// Trim leading/trailing breaks from empty source paragraphs.
|
||
while (inline[0]?.type === 'hardBreak') inline.shift();
|
||
while (inline[inline.length - 1]?.type === 'hardBreak') inline.pop();
|
||
if (inline.length === 0) return null;
|
||
|
||
const para: Node = { type: 'paragraph', content: inline };
|
||
if (textAlign) para.attrs = { textAlign };
|
||
return { type: 'doc', content: [para] };
|
||
}
|
||
|
||
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 -----------------------------------------------------------
|
||
|
||
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
|
||
const out: Node[] = [];
|
||
for (const el of elements) {
|
||
if (el.namespaceURI === NS.text) {
|
||
if (el.localName === 'p' || el.localName === 'h') {
|
||
out.push(convertParaLike(el, ctx, kind, boldByDefault));
|
||
} else if (el.localName === 'list') {
|
||
const list = convertList(el, ctx, null, 1);
|
||
if (list) out.push(list);
|
||
} else if (el.localName === 'section') {
|
||
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
|
||
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
|
||
// Generated indexes (ToC, …): 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 && el.localName === 'frame') {
|
||
// A frame at block level (rare) → wrap the inline image in a paragraph.
|
||
const img = convertFrame(el, ctx);
|
||
if (img) out.push({ type: 'paragraph', content: [img] });
|
||
} else if (el.namespaceURI === NS.draw) {
|
||
ctx.warnings.add('Drawings were removed');
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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(3, Math.max(1, Number.isFinite(raw) ? raw : 1));
|
||
}
|
||
|
||
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
|
||
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null, false, boldByDefault);
|
||
|
||
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
|
||
if (isHeading) attrs.level = level;
|
||
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, headingLevel: number | null, kind: BlockKind): Record<string, unknown> {
|
||
const attrs: Record<string, unknown> = {};
|
||
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
|
||
|
||
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']) ?? hdef?.fontSizePt ?? BODY_FONT_SIZE_PT;
|
||
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 = hdef ? hdef.marginTopPt : 0;
|
||
const defBottom = hdef ? hdef.marginBottomPt : kind === 'list' ? 0 : STD_MARGIN_BOTTOM_PT;
|
||
// An unspecified fo:margin is ODF's default of 0 (not the editor's 6pt body
|
||
// default), so a foreign paragraph that omits it gets 0 spacing like
|
||
// Word/LibreOffice. Editor exports write the margins explicitly, so suppressed.
|
||
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. Default 0 is suppressed.
|
||
if (kind !== 'list') {
|
||
const ml = lengthToPt(paraProps['fo:margin-left']);
|
||
if (ml != null && ml > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 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';
|
||
|
||
return attrs;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// ---- inline conversion --------------------------------------------------------
|
||
|
||
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false, boldByDefault = false): Node[] {
|
||
const out: Node[] = [];
|
||
|
||
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, headingLevel, boldByDefault);
|
||
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
|
||
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':
|
||
walk(e, layerTextProps(props, ctx.resolver.spanTextProps(e.getAttributeNS(NS.text, 'style-name'))), linkHref);
|
||
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':
|
||
out.push({ type: 'hardBreak' });
|
||
continue;
|
||
case 'a': {
|
||
// ODF hyperlink → link mark on the contained text (internal #bookmark
|
||
// links are kept as-is though the editor has no bookmark targets).
|
||
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':
|
||
case 'bookmark-start':
|
||
case 'bookmark-end':
|
||
case 'soft-page-break':
|
||
case 'change':
|
||
case 'change-start':
|
||
case 'change-end':
|
||
continue;
|
||
default:
|
||
// In headers/footers, page fields stay live fields (pageField.ts).
|
||
if (hfFields && e.localName === 'page-number') {
|
||
out.push({ type: 'pageNumber' });
|
||
continue;
|
||
}
|
||
if (hfFields && e.localName === 'page-count') {
|
||
out.push({ type: 'pageCount' });
|
||
continue;
|
||
}
|
||
// Other text fields (date, 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 && e.localName === 'frame') {
|
||
const img = convertFrame(e, ctx);
|
||
if (img) out.push(img);
|
||
continue;
|
||
}
|
||
if (e.namespaceURI === NS.draw) {
|
||
ctx.warnings.add('Drawings were removed');
|
||
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;
|
||
}
|
||
|
||
function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null, boldByDefault = false): 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 (headingLevel != null || boldByDefault) {
|
||
if (bold === false) textStyle.fontWeight = 'normal';
|
||
} else if (bold === true) {
|
||
marks.push({ type: 'bold' });
|
||
}
|
||
|
||
const fs = props['fo:font-style'];
|
||
if (fs === 'italic' || fs === 'oblique') marks.push({ type: 'italic' });
|
||
const ul = props['style:text-underline-style'];
|
||
if (ul && ul !== 'none') marks.push({ type: 'underline' });
|
||
const lt = props['style:text-line-through-style'];
|
||
if (lt && lt !== 'none') marks.push({ type: 'strike' });
|
||
|
||
// "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered).
|
||
const pos = props['style:text-position'];
|
||
if (pos) {
|
||
if (pos.startsWith('super')) marks.push({ type: 'superscript' });
|
||
else if (pos.startsWith('sub')) marks.push({ type: 'subscript' });
|
||
else {
|
||
const p = parseFloat(pos);
|
||
if (p > 0) marks.push({ type: 'superscript' });
|
||
else if (p < 0) marks.push({ type: 'subscript' });
|
||
}
|
||
}
|
||
|
||
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;
|
||
// Pure black is the theme default; an explicit mark would fight dark/allBlack.
|
||
if (color && color !== '#000000') textStyle.color = color;
|
||
|
||
const sizePt = lengthToPt(props['fo:font-size']);
|
||
const defSize = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1].fontSizePt : BODY_FONT_SIZE_PT;
|
||
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = formatPt(sizePt);
|
||
|
||
const family = resolver.fontFamilyOf(props);
|
||
if (family && !DEFAULT_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 -----------------------------------------------------------------------
|
||
|
||
// `inheritedStyleName`: nested text:list elements usually carry no style-name of
|
||
// their own — the outermost list's style governs, with one level def per depth.
|
||
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number): 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 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);
|
||
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;
|
||
|
||
// Whole-list indent (top level only): margin beyond the level-1 base. Nested
|
||
// lists inherit it visually through DOM nesting, so only depth 1 carries it.
|
||
let indent: number | null = null;
|
||
if (depth === 1) {
|
||
const mlCm = listLevelMarginLeftCm(levelDef);
|
||
if (mlCm != null) {
|
||
const extra = Math.round((mlCm - LIST_BASE_MARGIN_CM) * 100) / 100;
|
||
if (extra > LIST_INDENT_EPS_CM) indent = extra;
|
||
}
|
||
}
|
||
|
||
if (!ordered) {
|
||
const node: Node = { type: 'bulletList', content: items };
|
||
if (indent != null) node.attrs = { indent };
|
||
return node;
|
||
}
|
||
|
||
const listStyleType = orderedTypeFromFormat(
|
||
levelDef!.getAttributeNS(NS.style, 'num-format'),
|
||
levelDef!.getAttributeNS(NS.style, 'num-suffix'),
|
||
);
|
||
const attrs: Record<string, unknown> = {};
|
||
if (start != null) attrs.start = start;
|
||
if (listStyleType !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = listStyleType;
|
||
if (indent != null) attrs.indent = indent;
|
||
const node: Node = { type: 'orderedList', content: items };
|
||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||
return node;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
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 ------------------------------------------------------------------------
|
||
|
||
function convertTable(el: Element, ctx: Ctx): Node | null {
|
||
const weights = columnWeights(el, ctx.resolver);
|
||
|
||
const rows: Node[] = [];
|
||
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 rawBg = ctx.resolver.cellBackgroundColor(cellEl.getAttributeNS(NS.table, 'style-name'));
|
||
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
|
||
// 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 };
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
return rows.length ? { type: 'table', content: rows } : null;
|
||
}
|
||
|
||
// 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;
|
||
}
|