The editor's a-rule and LibreOffice's Internet Link style both outrank the region CSS, so neither export lets the baked region color override a non-plain link's paint: ODT leaves the color off the link run, DOCX paints the link blue unless the run carries a direct color — the DOCX import otherwise read its own link back as plain.
5148 lines
252 KiB
TypeScript
5148 lines
252 KiB
TypeScript
import { tiptapToOdt, type TiptapNode, type TiptapMark, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type CellOptions, type HeaderFooterBuilder } from 'odf-kit';
|
||
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||
import type { Orientation } from '../storage/pageOrientation';
|
||
import type { SpacingModel } from '../storage/spacingModel';
|
||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||
import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
|
||
import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties';
|
||
import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering';
|
||
import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pageDecor';
|
||
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
|
||
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||
import {
|
||
TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion,
|
||
} from '../styles/tableStyles';
|
||
|
||
// A table's named style plus the conditional areas it opts into (Word's Table Style
|
||
// Options), collected by exportTable in document order.
|
||
type TableStyleRef = { name: string; look: TableLook };
|
||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||
import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||
import { numberLocale, parseCellNumber, toWriterFormula, type CellRef, type NumberLocale } from '../utils/tableFormula';
|
||
import { SHAPES, arrowHeadCm, isShapeKind, isLineKind, odfEnhancedGeometry, odfEnhancedPath, type ShapeKind } from '../utils/shapes';
|
||
import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
|
||
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, formatOrdinal, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||
import { ODF_SEQ_NAME, seqCategoryOf, type SeqCategory } from '../editor/extensions/caption';
|
||
import { indexKindOf, INDEX_TITLES, type IndexKind } from '../editor/extensions/tableOfContents';
|
||
import { citationText, isBibType } from '../editor/extensions/bibliographyEntry';
|
||
import { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle';
|
||
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
|
||
import { CELL_FORMAT_SPECS, currencyParts, datePattern, isCellFormat, type CellFormat } from '../utils/cellFormat';
|
||
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
|
||
import { parseLatex } from '../math/latex';
|
||
import { mathmlDocument } from '../math/mathml';
|
||
|
||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||
|
||
// tiptapToOdt ignores paragraph/heading node attrs (including lineHeight and
|
||
// textAlign). We rename nodes that carry either attr to custom types and
|
||
// handle them via unknownNodeHandler, which has access to the full OdtDocument API.
|
||
|
||
const CUST_P = '__cust_p__';
|
||
const CUST_H = '__cust_h__';
|
||
// Tables are renamed to this so odf-kit routes them to unknownNodeHandler. Its
|
||
// native path emits cells with no border (invisible in LibreOffice/Word), so we
|
||
// build the table ourselves with an explicit border.
|
||
const CUST_TABLE = '__cust_table__';
|
||
// Synthetic first node: routes header/footer emission through unknownNodeHandler,
|
||
// which is the only hook with access to the OdtDocument (setHeader/setFooter).
|
||
const CUST_HF = '__cust_hf__';
|
||
|
||
// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css)
|
||
// so the .odt matches the on-screen preview.
|
||
const TABLE_BORDER = '0.5pt solid #000000';
|
||
|
||
// odf-kit emits this as the document's default font (Standard style). The editor
|
||
// renders the bundled, metric-identical Liberation Serif on screen.
|
||
const ODFKIT_DEFAULT_FONT = 'Liberation Serif';
|
||
// …but we declare Times New Roman in the .odt: it is metric-identical to
|
||
// Liberation Serif, so LibreOffice (substitutes TNR→Liberation Serif) and Word
|
||
// (has the real TNR) both render with the same metrics as the editor.
|
||
const EXPORT_FONT = 'Times New Roman';
|
||
// The body size a run without one of its own renders at (LibreOffice's default).
|
||
const DEFAULT_FONT_SIZE_PT = 12;
|
||
const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte
|
||
|
||
// Sentinel between a cell's blocks (and list items) so the single <text:p> odf-kit
|
||
// emits per cell can be split back into real blocks in applyCellBlocks. A private-use
|
||
// char: never in user text, and passes odf-kit's XML escaper (only & < >) untouched.
|
||
const SEG = '';
|
||
|
||
// Sentinel for hardBreak nodes: replaceHardBreaks turns them into text so every
|
||
// odf-kit path (plain paragraphs, list items, cells) carries them; applyInlineSentinels
|
||
// swaps the char for <text:line-break/> in content.xml. U+E001 (SEG is U+E000).
|
||
const LBR = '';
|
||
|
||
// Sentinel wrapping page-count digits in header/footer runs; applyHfPostProcess
|
||
// rewrites it to <text:page-count> in styles.xml. U+E002 (SEG/LBR are E000/E001).
|
||
const PGC = '';
|
||
|
||
// Sentinel for tab chars: \t collapses to a space in ODF, so replaceTabs swaps
|
||
// each tab for this before odf-kit serializes; applyInlineSentinels → <text:tab/>. U+E003.
|
||
const TAB = '';
|
||
|
||
// Sentinel wrapping an image's index (IMG{i}IMG) emitted as plain run text by
|
||
// replaceImages, so it rides every odf-kit path (body paragraphs and table cells);
|
||
// applyImages rewrites it to a <draw:frame> in content.xml. U+E004.
|
||
const IMG = '';
|
||
|
||
// Sentinel prepended to a top-level paragraph/heading carrying a manual page break
|
||
// (breakBefore: 'page'); applyPageBreaks finds the marked block in content.xml, mints a
|
||
// fo:break-before="page" paragraph style for it, and strips the sentinel. U+E005.
|
||
const PGB = '';
|
||
|
||
// Sentinel wrapping an empty line's font size (FSZ<size>FSZ), the sole text of a
|
||
// top-level empty CUST_P/CUST_H; applyEmptyLineFontSizes turns it into a paragraph
|
||
// style fo:font-size (odf-kit has no paragraph font-size option). U+E007.
|
||
const FSZ = '\uE007';
|
||
|
||
// Sentinel wrapping a top-level paragraph's box spec (PBX<bg|bt|br|bb|bl>PBX), emitted
|
||
// as the paragraph's first run; applyParagraphBoxes mints a paragraph style with
|
||
// fo:background-color/fo:border-* (odf-kit has no such options). U+E00C.
|
||
const PBX = '\uE00C';
|
||
|
||
// Sentinel wrapping a table-of-contents index (TOC{i}TOC), emitted as a marker
|
||
// paragraph's run text by replaceTableOfContents so it rides odf-kit's paragraph path;
|
||
// applyToc rewrites the whole marker <text:p> to a <text:table-of-content>. U+E006.
|
||
const TOC_SENT = '';
|
||
|
||
// Sentinel bracketing a hoisted text box's blocks (marker paragraphs TBX S{i} TBX …
|
||
// TBX E{i} TBX): hoisted to top level, they ride every existing pass; applyTextBoxes
|
||
// wraps the region back into a <draw:frame>/<draw:text-box> or custom shape. U+E008.
|
||
const TBX = '';
|
||
|
||
// Sentinel bracketing a hoisted multi-column section's blocks, same mechanism as
|
||
// TBX above; applyColumns wraps the region into a <text:section> with a minted
|
||
// section style carrying <style:columns>. U+E009.
|
||
const COL = '';
|
||
|
||
// Sentinel wrapping a date/time field's index (DTF{i}DTF), emitted as plain run text
|
||
// by replaceDateTimeFields so it rides every odf-kit path; applyDateTimeFields
|
||
// rewrites it to <text:date>/<text:time> + a minted number style. U+E00A.
|
||
const DTF = '';
|
||
|
||
// Sentinel wrapping a header/footer image's index (HFIMG{i}HFIMG), emitted as plain
|
||
// run text by replaceHfImages so it rides odf-kit's header/footer path (styles.xml);
|
||
// applyHfPostProcess rewrites it to an as-char <draw:frame>. U+E00B.
|
||
const HFIMG = '';
|
||
|
||
// Sentinel wrapping the named paragraph style of a block whose style isn't the ODF
|
||
// default for its node type (STY<name>STY), emitted as plain run text so it rides the
|
||
// odf-kit path; applyParagraphStyles points the block at that style. U+E00D.
|
||
const STY = '';
|
||
|
||
// Sentinel prefixed to a run that carries a named character style (CST<name>CST);
|
||
// applyCharacterStyles points the run's span at that style. U+E00E.
|
||
const CST = '\uE00E';
|
||
|
||
// Sentinel prefixed to a run carrying character effects odf-kit's run formatting has no
|
||
// field for (TEF<odf attrs>TEF); applyTextEffects folds them into the run's own
|
||
// automatic text style. U+E00F.
|
||
const TEF = '\uE00F';
|
||
|
||
// Sentinel wrapping the index of the section a block opens (SEC{i}SEC), emitted as the
|
||
// block's first run; applySectionMasterPages points it at that section's master page,
|
||
// which is what carries the section's own header/footer. U+E010.
|
||
const SEC = '\uE010';
|
||
|
||
// Separates a tab stop's alignment from its leader character inside style:type,
|
||
// which odf-kit writes verbatim. U+E011.
|
||
const LEAD = '\uE011';
|
||
|
||
// Sentinel wrapping a formula's index (MTH{i}MTH), emitted as plain run text by
|
||
// replaceFormulas so it rides every odf-kit path; applyFormulas rewrites it to a
|
||
// <draw:frame>/<draw:object> and writes the embedded formula object. U+E012.
|
||
const MTH = '\uE012';
|
||
|
||
// Sentinels bracketing a bookmark's text (BMS<name>BMS \u2026 BME<name>BME), spliced into
|
||
// the run text by replaceBookmarks so they ride every odf-kit path; applyBookmarks
|
||
// rewrites them to <text:bookmark-start/>/<text:bookmark-end/>. U+E013/U+E014.
|
||
const BMS = '\uE013';
|
||
const BME = '\uE014';
|
||
|
||
// Sentinel wrapping a cross-reference's index and its display text
|
||
// (XRF{i}XRF<text>XRF); applyBookmarks rewrites it to <text:bookmark-ref>. U+E015.
|
||
const XRF = '\uE015';
|
||
|
||
// Sentinel wrapping a header/footer chapter field (CHP<level>CHP<name>CHP);
|
||
// applyHfPostProcess rewrites it to <text:chapter> in styles.xml. U+E016.
|
||
const CHP = '\uE016';
|
||
|
||
// Sentinels bracketing a comment's text (CMS{i}CMS \u2026 CME{i}CME), spliced into the run
|
||
// text like the bookmark pair; applyComments rewrites them to <office:annotation> and
|
||
// <office:annotation-end/>. U+E018/U+E019.
|
||
const CMS = '\uE018';
|
||
const CME = '\uE019';
|
||
|
||
// Sentinels for footnotes/endnotes (notes.ts): FNT A{i} FNT marks the anchor in the
|
||
// running text, FNT B{i} FNT opens the note's own hoisted paragraph. applyNotes cuts
|
||
// the body out and splices it into a <text:note> at the anchor. U+E017.
|
||
const FNT = '\uE017';
|
||
|
||
// Sentinel wrapping a caption's sequence-field index (SEQ{i}SEQ); applySequenceFields
|
||
// rewrites it to <text:sequence>. U+E01A.
|
||
const SEQ = '\uE01A';
|
||
// Sentinel wrapping an index entry's index (IXE{i}IXE); applyIndexEntries resolves it.
|
||
const IXE = '\uE01E';
|
||
// Sentinel wrapping a citation's index (BIB{i}BIB); applyBibEntries resolves it.
|
||
const BIB = '\uE01F';
|
||
// Sentinel wrapping a ruby annotation's index (RBY{i}RBY); applyRuby rewrites it to
|
||
// <text:ruby>. U+E021.
|
||
const RBY = '\uE021';
|
||
|
||
// Sentinels for recorded revisions (trackChanges.ts): TCI{i}TCI brackets an insertion's
|
||
// runs, TCD{i}TCD stands where a deletion's text was cut out. applyRevisions rewrites
|
||
// both and builds the <text:tracked-changes> registry. U+E01B/U+E01C.
|
||
const TCI = '\uE01B';
|
||
const TCD = '\uE01C';
|
||
|
||
const EXT_BY_MIME: Record<string, string> = {
|
||
'image/png': 'png',
|
||
'image/jpeg': 'jpg',
|
||
'image/jpg': 'jpg',
|
||
'image/gif': 'gif',
|
||
'image/svg+xml': 'svg',
|
||
'image/webp': 'webp',
|
||
'image/bmp': 'bmp',
|
||
};
|
||
|
||
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
|
||
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
|
||
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
|
||
const CELL_LIST_BULLET_STYLE = 'TblListBullet';
|
||
const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
|
||
|
||
// LibreOffice's heading defaults, shown in the editor (editor.css) and written over
|
||
// odf-kit's Heading_20_N styles on export: its sizes plus the Heading style's margins.
|
||
// The importers use them as the fallback yardstick when a file declares no style.
|
||
// Levels 4 and 6 are italic, as they are in LibreOffice (probed against its style pool).
|
||
export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string; italic?: true }[] = [
|
||
{ name: 'Heading_20_1', fontSize: '18pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_4', fontSize: '13pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
|
||
{ name: 'Heading_20_5', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_6', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
|
||
// 7–10 continue level 6 rather than adding a step of our own: probed, LibreOffice
|
||
// writes these styles with no properties at all and resolves them from its own pool.
|
||
{ name: 'Heading_20_7', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_8', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
|
||
{ name: 'Heading_20_9', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' },
|
||
{ name: 'Heading_20_10', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm', italic: true },
|
||
];
|
||
|
||
// Headings are sans (LibreOffice's Heading style). On screen the bundled 'Arial'
|
||
// @font-face maps to Liberation Sans, so the declared name is metric-identical —
|
||
// the same trick as EXPORT_FONT for the serif body font.
|
||
export const HEADING_FONT = 'Arial';
|
||
|
||
// What both formats name as the producing application (ODF meta:generator).
|
||
export const GENERATOR = 'EdenText';
|
||
|
||
// Highest heading level the editor offers (extensions.ts, both importers, TOC).
|
||
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
|
||
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
|
||
|
||
// The bundled screen font → the metric-identical name declared in files, so
|
||
// LibreOffice and Word render with the editor's metrics.
|
||
export function twinFontName(family: string): string {
|
||
if (family === ODFKIT_DEFAULT_FONT) return EXPORT_FONT;
|
||
if (family === 'Liberation Sans') return HEADING_FONT;
|
||
return family;
|
||
}
|
||
|
||
// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1").
|
||
export function odfStyleName(name: string): string {
|
||
return name.replace(/ /g, '_20_');
|
||
}
|
||
|
||
// The style name a block carries: its own, else the node type's default. Mirrors
|
||
// blockStyleName in editor/extensions/paragraphStyle.ts (kept local: this module is
|
||
// framework-free).
|
||
function styleOf(node: TiptapNode): string {
|
||
const own = node.attrs?.styleName;
|
||
if (typeof own === 'string' && own) return own;
|
||
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
|
||
}
|
||
|
||
// Headings reach the handler renamed to CUST_H (injectCustomTypes).
|
||
function isHeadingNode(node: TiptapNode): boolean {
|
||
return node.type === 'heading' || node.type === CUST_H;
|
||
}
|
||
|
||
// Styles to define in the file: every built-in (Word/LibreOffice always define their
|
||
// standard styles) plus the user styles the document actually references, with their
|
||
// parent chains.
|
||
function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
|
||
const used = new Set<string>();
|
||
const addChain = (name: string) => {
|
||
let cur: string | null | undefined = name;
|
||
while (cur && sheet.paragraph[cur] && !used.has(cur)) {
|
||
used.add(cur);
|
||
cur = sheet.paragraph[cur].parent;
|
||
}
|
||
};
|
||
for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name);
|
||
const walk = (node: TiptapNode) => {
|
||
if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node));
|
||
node.content?.forEach(walk);
|
||
};
|
||
walk(doc);
|
||
return used;
|
||
}
|
||
|
||
// What odf-kit itself puts on the block, so only a differing style needs a sentinel.
|
||
function odfDefaultStyleOf(node: TiptapNode): string {
|
||
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
|
||
}
|
||
|
||
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
|
||
if (!attrs) return false;
|
||
// A named style is emitted via the STY sentinel, which needs the applyRuns path.
|
||
if (typeof attrs.styleName === 'string' && attrs.styleName) return true;
|
||
if (attrs.lineHeight) return true;
|
||
if (attrs.spaceBefore != null) return true;
|
||
if (attrs.spaceAfter != null) return true;
|
||
if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true;
|
||
if (typeof attrs.fontFamily === 'string' && attrs.fontFamily) return true;
|
||
if (typeof attrs.indent === 'number' && attrs.indent > 0) return true;
|
||
if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) return true;
|
||
if (typeof attrs.indentRight === 'number' && attrs.indentRight > 0) return true;
|
||
if (typeof attrs.tabStops === 'string' && attrs.tabStops) return true;
|
||
if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true;
|
||
if (attrs.widowControl === false) return true;
|
||
if (attrs.keepNext === true) return true;
|
||
if (attrs.keepLines === true) return true;
|
||
if (attrs.noHyphenation === true) return true;
|
||
if (attrs.dir === 'rtl' || attrs.dir === 'ltr') return true;
|
||
for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'])
|
||
if (typeof attrs[s] === 'string' && attrs[s] && attrs[s] !== 'none') return true;
|
||
const ta = attrs.textAlign;
|
||
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
|
||
}
|
||
|
||
// odf-kit's native run handling ignores the fontWeight textStyle attr (used to
|
||
// un-bold heading text), so such blocks must go through applyRuns via CUST_P/_H.
|
||
function hasFontWeightRun(content: TiptapNode['content']): boolean {
|
||
return !!content?.some(c =>
|
||
c.type === 'text' && c.marks?.some(m => m.type === 'textStyle' && m.attrs?.fontWeight),
|
||
);
|
||
}
|
||
|
||
// Same for a named character style: only applyRuns emits its CST sentinel.
|
||
function hasCharStyleRun(content: TiptapNode['content']): boolean {
|
||
return !!content?.some(c => c.type === 'text' && !!charStyleOf(c.marks));
|
||
}
|
||
|
||
function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
|
||
// Rename tables so odf-kit routes them to our unknownNodeHandler (see
|
||
// CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT
|
||
// renamed — our table handler walks them by type === "paragraph".
|
||
if (node.type === 'table') {
|
||
return {
|
||
...node,
|
||
type: CUST_TABLE,
|
||
content: node.content?.map(c => injectCustomTypes(c, true)),
|
||
};
|
||
}
|
||
// Don't rename paragraphs inside list items or table cells — tiptapToOdt's list
|
||
// builder (and our table handler) walk them by type === "paragraph"; renaming
|
||
// breaks that.
|
||
if (!inContainer && (hasCustomAttrs(node.attrs) || hasFontWeightRun(node.content) || hasCharStyleRun(node.content))) {
|
||
if (node.type === 'paragraph') return { ...node, type: CUST_P };
|
||
if (node.type === 'heading') return { ...node, type: CUST_H };
|
||
}
|
||
if (node.content?.length) {
|
||
const childInContainer = inContainer
|
||
|| node.type === 'bulletList'
|
||
|| node.type === 'orderedList'
|
||
|| node.type === 'listItem';
|
||
return { ...node, content: node.content.map(c => injectCustomTypes(c, childInContainer)) };
|
||
}
|
||
return node;
|
||
}
|
||
|
||
// Turn hardBreak nodes into LBR-sentinel text nodes so they ride through every
|
||
// odf-kit serialization path as plain run text; applyInlineSentinels rewrites the char
|
||
// to <text:line-break/> in content.xml afterwards.
|
||
function replaceHardBreaks(node: TiptapNode): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
return {
|
||
...node,
|
||
content: node.content.map(c =>
|
||
c.type === 'hardBreak'
|
||
? { type: 'text', text: LBR, ...(c.marks ? { marks: c.marks } : {}) }
|
||
: replaceHardBreaks(c),
|
||
),
|
||
};
|
||
}
|
||
|
||
// Swap each tab character in run text for the TAB sentinel so it survives odf-kit
|
||
// serialization (a literal \t would collapse to a space); applyInlineSentinels
|
||
// rewrites it to <text:tab/> in content.xml afterwards.
|
||
function replaceTabs(node: TiptapNode): TiptapNode {
|
||
if (node.type === 'text' && node.text?.includes('\t')) {
|
||
return { ...node, text: node.text.split('\t').join(TAB) };
|
||
}
|
||
if (!node.content?.length) return node;
|
||
return { ...node, content: node.content.map(replaceTabs) };
|
||
}
|
||
|
||
// Prepend a PGB sentinel run to each top-level paragraph/heading with breakBefore so it
|
||
// rides every odf-kit path; applyPageBreaks resolves it. Top-level only: a sentinel in a
|
||
// cell/list paragraph would corrupt the SEG-based cell/list rebuild (applyCellBlocks).
|
||
function replacePageBreaks(doc: TiptapNode): TiptapNode {
|
||
if (!doc.content?.length) return doc;
|
||
return {
|
||
...doc,
|
||
content: doc.content.map(child => {
|
||
if ((child.type === 'paragraph' || child.type === 'heading') && child.attrs?.breakBefore === 'page') {
|
||
return { ...child, content: [{ type: 'text', text: PGB }, ...(child.content ?? [])] };
|
||
}
|
||
return child;
|
||
}),
|
||
};
|
||
}
|
||
|
||
// Mark the first block of each section after the first, so applySectionMasterPages can
|
||
// point it at that section's master page. Runs after the column hoist, so a section
|
||
// starting inside one is top-level by now; the PGB marker keeps its place at the front.
|
||
function replaceSectionBreaks(doc: TiptapNode): TiptapNode {
|
||
if (!doc.content?.length) return doc;
|
||
let index = 0;
|
||
return {
|
||
...doc,
|
||
content: doc.content.map(child => {
|
||
if (!(child.type === 'paragraph' || child.type === 'heading') || child.attrs?.sectionBreak !== true) return child;
|
||
const inner = child.content ?? [];
|
||
const at = inner[0]?.type === 'text' && inner[0].text === PGB ? 1 : 0;
|
||
const mark: TiptapNode = { type: 'text', text: `${SEC}${++index}${SEC}` };
|
||
return { ...child, content: [...inner.slice(0, at), mark, ...inner.slice(at)] };
|
||
}),
|
||
};
|
||
}
|
||
|
||
// One embedded picture, collected by replaceImages and emitted by applyImages.
|
||
// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW;
|
||
// wrap floats the frame at its anchor paragraph (left/right/top-bottom).
|
||
type WrapMode = 'inline' | 'left' | 'right' | 'topBottom';
|
||
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null; wrapOffsetYCm: number | null; wrapDistCm: number | null; wrapAlign: string | null; anchorPage: number | null; vAlign: string | null; inFront: boolean };
|
||
|
||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||
const bin = atob(b64);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
return bytes;
|
||
}
|
||
|
||
// Decode an `image` node's data-URI src into bytes + geometry. width/height are
|
||
// px @96dpi → cm via round3 (sub-pixel, matches table column widths), so an
|
||
// integer-px image round-trips exactly. Returns null for a non-data/empty src.
|
||
function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'): ImageExport | null {
|
||
const src = node.attrs?.src;
|
||
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
|
||
const m = /^data:([^;,]+)(;[^,]*)?,([\s\S]*)$/.exec(src);
|
||
if (!m) return null;
|
||
const mimeType = m[1] || 'image/png';
|
||
const isB64 = (m[2] ?? '').includes('base64');
|
||
let bytes: Uint8Array<ArrayBuffer>;
|
||
try {
|
||
bytes = isB64 ? base64ToBytes(m[3]) : new Uint8Array(new TextEncoder().encode(decodeURIComponent(m[3])));
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!bytes.length) return null;
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const pxToCm = (px: number) => round3((px * 2.54) / 96);
|
||
const w = typeof node.attrs?.width === 'number' ? node.attrs.width : 0;
|
||
const h = typeof node.attrs?.height === 'number' ? node.attrs.height : 0;
|
||
const wrapAttr = node.attrs?.wrap;
|
||
const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline';
|
||
return {
|
||
path: `Pictures/${namePrefix}${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`,
|
||
bytes,
|
||
mimeType,
|
||
widthCm: w > 0 ? pxToCm(w) : 0,
|
||
heightCm: h > 0 ? pxToCm(h) : 0,
|
||
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
|
||
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
|
||
wrap,
|
||
wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null,
|
||
wrapOffsetYCm: typeof node.attrs?.wrapOffsetY === 'number' ? round3(node.attrs.wrapOffsetY) : null,
|
||
wrapDistCm: typeof node.attrs?.wrapDist === 'number' ? round3(node.attrs.wrapDist) : null,
|
||
wrapAlign: node.attrs?.wrapAlign === 'left' || node.attrs?.wrapAlign === 'right' ? node.attrs.wrapAlign : null,
|
||
anchorPage: typeof node.attrs?.anchorPage === 'number' && node.attrs.anchorPage > 0 ? node.attrs.anchorPage : null,
|
||
vAlign: typeof node.attrs?.vAlign === 'string' ? node.attrs.vAlign : null,
|
||
inFront: node.attrs?.inFront === true,
|
||
};
|
||
}
|
||
|
||
// Replace every inline `image` node with an IMG-sentinel text node and collect its
|
||
// bytes/geometry. Runs before odf-kit serialization so the sentinel rides through
|
||
// both the native paragraph path and our cell path; applyImages resolves it later.
|
||
function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'image') {
|
||
const desc = imageDescriptor(child, images.length);
|
||
if (desc) {
|
||
images.push(desc);
|
||
content.push({ type: 'text', text: `${IMG}${images.length - 1}${IMG}` });
|
||
}
|
||
continue; // invalid image → dropped
|
||
}
|
||
content.push(replaceImages(child, images));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// A header/footer paragraph's inline images → HFIMG-sentinel text runs (forced
|
||
// as-character), collected into `images` under a distinct Pictures/hfImage* name so
|
||
// applyHfRuns/hfFirstZoneXml carry the sentinel and applyHfPostProcess resolves it.
|
||
function replaceHfImages(para: TiptapNode | null, images: ImageExport[]): TiptapNode | null {
|
||
if (!para?.content?.length) return para;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of para.content) {
|
||
if (child.type === 'image') {
|
||
const desc = imageDescriptor(child, images.length, 'hfImage');
|
||
if (desc) {
|
||
images.push(desc);
|
||
content.push({ type: 'text', text: `${HFIMG}${images.length - 1}${HFIMG}` });
|
||
}
|
||
continue; // invalid image → dropped
|
||
}
|
||
content.push(child);
|
||
}
|
||
return { ...para, content };
|
||
}
|
||
|
||
// One formula, collected by replaceFormulas and emitted by applyFormulas as an
|
||
// embedded ODF formula object (Formula{n}/content.xml) plus the frame referencing it.
|
||
type FormulaExport = { latex: string; display: boolean };
|
||
|
||
// Replace every inline `formula` node with an MTH-sentinel text run (carrying the
|
||
// node's marks) and collect its source; mirrors replaceImages so the sentinel rides
|
||
// every odf-kit path. applyFormulas resolves it after serialization.
|
||
function replaceFormulas(node: TiptapNode, formulas: FormulaExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'formula') {
|
||
const a = child.attrs ?? {};
|
||
const latex = typeof a.latex === 'string' ? a.latex : '';
|
||
if (!latex) continue;
|
||
formulas.push({ latex, display: a.display === true });
|
||
content.push({ type: 'text', text: `${MTH}${formulas.length - 1}${MTH}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceFormulas(child, formulas));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// One inserted date/time field, collected by replaceDateTimeFields and emitted by
|
||
// applyDateTimeFields. `value` is the field's stored moment (ISO local datetime).
|
||
type DateTimeFieldExport = { kind: 'date' | 'time'; format: string; fixed: boolean; value: string };
|
||
|
||
// Replace every inline `dateTimeField` node with a DTF-sentinel text run (carrying
|
||
// the node's marks) and collect its attrs; mirrors replaceImages so the sentinel
|
||
// rides every odf-kit path. applyDateTimeFields resolves it after serialization.
|
||
function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'dateTimeField') {
|
||
const a = child.attrs ?? {};
|
||
fields.push({
|
||
kind: a.kind === 'time' ? 'time' : 'date',
|
||
format: typeof a.format === 'string' ? a.format : '',
|
||
fixed: a.fixed === true,
|
||
value: typeof a.value === 'string' ? a.value : '',
|
||
});
|
||
content.push({ type: 'text', text: `${DTF}${fields.length - 1}${DTF}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceDateTimeFields(child, fields));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// One recorded revision. Runs of the same change share an id, so the registry holds one
|
||
// entry per id and a deletion's text is the concatenation of its runs.
|
||
type RevisionExport = { kind: 'insertion' | 'deletion'; id: string; author: string; date: string; text: string; nodes: TiptapNode[] };
|
||
|
||
// Bracket every insertion run with TCI sentinels and cut every deletion run out, leaving
|
||
// a TCD sentinel where it was — ODF keeps a deletion's text in the registry, not inline.
|
||
// The deleted runs are hoisted into a marker paragraph at document end so they ride every
|
||
// serialization pass (styles, tabs, effects); applyRevisions splices them into the
|
||
// registry, which is how the deleted text keeps its formatting (LibreOffice does the same).
|
||
function replaceRevisions(node: TiptapNode, out: Map<string, RevisionExport>): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
const mark = child.type === 'text'
|
||
? child.marks?.find((m) => m.type === 'insertion' || m.type === 'deletion')
|
||
: undefined;
|
||
if (!mark) { content.push(replaceRevisions(child, out)); continue; }
|
||
const a = mark.attrs ?? {};
|
||
const id = String(a.id ?? '');
|
||
const kind = mark.type as 'insertion' | 'deletion';
|
||
const known = out.has(id);
|
||
const entry = out.get(id) ?? {
|
||
kind, id,
|
||
author: String(a.author ?? '') || GENERATOR,
|
||
date: typeof a.date === 'string' && a.date ? a.date : new Date().toISOString(),
|
||
text: '', nodes: [],
|
||
};
|
||
entry.text += child.text ?? '';
|
||
out.set(id, entry);
|
||
// The marks stay off the sentinel runs: a change is not formatting, and odf-kit
|
||
// would otherwise split the paragraph's runs around it.
|
||
const rest = child.marks?.filter((m) => m !== mark);
|
||
if (kind === 'deletion') {
|
||
entry.nodes.push({ ...child, ...(rest?.length ? { marks: rest } : { marks: undefined }) });
|
||
// One point marker per change: its registry region holds every fragment.
|
||
if (!known) content.push({ type: 'text', text: `${TCD}${id}${TCD}` });
|
||
continue;
|
||
}
|
||
content.push({ type: 'text', text: `${TCI}${id}${TCI}` });
|
||
content.push({ ...child, ...(rest?.length ? { marks: rest } : { marks: undefined }) });
|
||
content.push({ type: 'text', text: `${TCI}${id}${TCI}` });
|
||
}
|
||
if (node.type === 'doc') {
|
||
for (const entry of out.values()) {
|
||
if (entry.kind !== 'deletion' || !entry.nodes.length) continue;
|
||
content.push({ type: 'paragraph',
|
||
content: [{ type: 'text', text: `${TCD}#${entry.id}${TCD}` }, ...entry.nodes] });
|
||
}
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// Resolve the revision sentinels and prepend the registry the body points into. The
|
||
// registry also carries whether the document goes on recording — LibreOffice writes it
|
||
// with no changes in it for exactly that (probed).
|
||
function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>, recording: boolean): Uint8Array {
|
||
if (!list.size && !recording) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
// ODF ids are per document; the editor's are opaque strings.
|
||
const odfId = new Map([...list.keys()].map((id, i) => [id, `ct${i + 1}`]));
|
||
// Cut the hoisted deletion paragraphs (before the point-marker pass, whose regex would
|
||
// eat their markers) and keep their serialized runs for the registry.
|
||
const hoisted = new Map<string, string>();
|
||
content = content.replace(
|
||
new RegExp(`<text:p\\b[^>]*>\\s*${TCD}#([^${TCD}]*)${TCD}([\\s\\S]*?)</text:p>`, 'g'),
|
||
(_m, id: string, inner: string) => { hoisted.set(id, inner); return ''; },
|
||
);
|
||
let open = new Map<string, boolean>();
|
||
content = content
|
||
.replace(new RegExp(`${TCI}([^${TCI}]*)${TCI}`, 'g'), (_m, id: string) => {
|
||
const ct = odfId.get(id);
|
||
if (!ct) return '';
|
||
const isEnd = open.get(id) === true;
|
||
open.set(id, !isEnd);
|
||
return isEnd
|
||
? `<text:change-end text:change-id="${ct}"/>`
|
||
: `<text:change-start text:change-id="${ct}"/>`;
|
||
})
|
||
.replace(new RegExp(`${TCD}([^${TCD}]*)${TCD}`, 'g'), (_m, id: string) => {
|
||
const ct = odfId.get(id);
|
||
return ct ? `<text:change text:change-id="${ct}"/>` : '';
|
||
});
|
||
|
||
const regions = [...list.values()].map((r) => {
|
||
const ct = odfId.get(r.id)!;
|
||
const info = `<office:change-info><dc:creator>${escapeXml(r.author)}</dc:creator>`
|
||
+ `<dc:date>${escapeXml(r.date.replace(/\.\d+Z?$/, ''))}</dc:date></office:change-info>`;
|
||
const runs = hoisted.get(r.id) ?? escapeXml(r.text);
|
||
const body = r.kind === 'deletion'
|
||
? `<text:deletion>${info}<text:p text:style-name="Standard">${runs}</text:p></text:deletion>`
|
||
: `<text:insertion>${info}</text:insertion>`;
|
||
return `<text:changed-region xml:id="${ct}" text:id="${ct}">${body}</text:changed-region>`;
|
||
}).join('');
|
||
content = content.replace(
|
||
/(<office:text\b[^>]*>)/,
|
||
`$1<text:tracked-changes text:track-changes="${recording}">${regions}</text:tracked-changes>`,
|
||
);
|
||
content = ensureDcNamespace(content);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The change registry names its author with dc:creator; odf-kit declares no dc: prefix
|
||
// in content.xml, and an undeclared one loses the whole element.
|
||
function ensureDcNamespace(content: string): string {
|
||
if (content.includes('xmlns:dc=')) return content;
|
||
return content.replace(/<office:document-content\b/, '<office:document-content xmlns:dc="http://purl.org/dc/elements/1.1/"');
|
||
}
|
||
|
||
// One caption sequence field, collected by replaceSequenceFields and emitted by
|
||
// applySequenceFields. `number` is the rank the editor resolved; both formats cache it.
|
||
type SequenceExport = { category: SeqCategory; format: string; number: number };
|
||
|
||
// Replace every inline `sequenceField` node with a SEQ-sentinel text run, as
|
||
// replaceDateTimeFields does, so it rides every odf-kit path.
|
||
function replaceSequenceFields(node: TiptapNode, fields: SequenceExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'sequenceField') {
|
||
const a = child.attrs ?? {};
|
||
fields.push({
|
||
category: seqCategoryOf(a.category as string),
|
||
format: typeof a.format === 'string' && a.format ? a.format : '1',
|
||
number: typeof a.number === 'number' && a.number > 0 ? a.number : 1,
|
||
});
|
||
content.push({ type: 'text', text: `${SEQ}${fields.length - 1}${SEQ}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceSequenceFields(child, fields));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// Resolve SEQ sentinels into <text:sequence>. The formula is LibreOffice's own
|
||
// "ooow:<name>+1", which is what makes its reader recount the captions on load; the
|
||
// element text is the cached number, and text:ref-name is the anchor an index links to.
|
||
function applySequenceFields(odtBytes: Uint8Array, fields: SequenceExport[]): Uint8Array {
|
||
if (!fields.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${SEQ}(\\d+)${SEQ}`, 'g'), (_m, idx: string) => {
|
||
const f = fields[Number(idx)];
|
||
if (!f) return '';
|
||
const name = ODF_SEQ_NAME[f.category];
|
||
const display = escapeXml(formatOrdinal(f.number, f.format as NoteNumFormat));
|
||
return `<text:sequence text:ref-name="ref${name}${idx}" text:name="${name}"`
|
||
+ ` text:formula="ooow:${name}+1" style:num-format="${f.format}">${display}</text:sequence>`;
|
||
});
|
||
|
||
// Every sequence needs its variable declared, as LibreOffice writes them.
|
||
const used = [...new Set(fields.map((f) => ODF_SEQ_NAME[f.category]))];
|
||
const decls = used.map((n) => `<text:sequence-decl text:display-outline-level="0" text:name="${n}"/>`).join('');
|
||
content = /<text:sequence-decls>/.test(content)
|
||
? content.replace('<text:sequence-decls>', `<text:sequence-decls>${decls}`)
|
||
: content.replace('<office:text>', `<office:text><text:sequence-decls>${decls}</text:sequence-decls>`);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// One alphabetical-index entry, collected by replaceIndexEntries.
|
||
type IndexEntryExport = { term: string; key1: string };
|
||
|
||
function replaceIndexEntries(node: TiptapNode, marks: IndexEntryExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'indexEntry') {
|
||
const a = child.attrs ?? {};
|
||
marks.push({ term: String(a.term ?? ''), key1: String(a.key1 ?? '') });
|
||
content.push({ type: 'text', text: `${IXE}${marks.length - 1}${IXE}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceIndexEntries(child, marks));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// Resolve IXE sentinels into the point mark LibreOffice writes for an index entry.
|
||
function applyIndexEntries(odtBytes: Uint8Array, marks: IndexEntryExport[]): Uint8Array {
|
||
if (!marks.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${IXE}(\\d+)${IXE}`, 'g'), (_m, idx: string) => {
|
||
const m = marks[Number(idx)];
|
||
if (!m) return '';
|
||
return `<text:alphabetical-index-mark text:string-value="${escapeXml(m.term)}"`
|
||
+ (m.key1 ? ` text:key1="${escapeXml(m.key1)}"` : '') + '/>';
|
||
});
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// One citation, collected by replaceBibEntries. ODF keeps the whole source record on
|
||
// the mark, so nothing else has to travel with the document.
|
||
type BibExport = { identifier: string; type: string; fields: Record<string, string>; text: string };
|
||
|
||
function replaceBibEntries(node: TiptapNode, marks: BibExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'bibliographyEntry') {
|
||
const a = child.attrs ?? {};
|
||
const identifier = String(a.identifier ?? '');
|
||
marks.push({
|
||
identifier,
|
||
type: isBibType(a.type) ? String(a.type) : 'misc',
|
||
fields: (a.fields ?? {}) as Record<string, string>,
|
||
text: String(a.text || citationText(identifier)),
|
||
});
|
||
content.push({ type: 'text', text: `${BIB}${marks.length - 1}${BIB}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceBibEntries(child, marks));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// BIB sentinels → <text:bibliography-mark>, which unlike an index mark wraps the text
|
||
// the citation shows. Every field is an attribute of its own, LibreOffice's own form.
|
||
function applyBibEntries(odtBytes: Uint8Array, marks: BibExport[]): Uint8Array {
|
||
if (!marks.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${BIB}(\\d+)${BIB}`, 'g'), (_m, idx: string) => {
|
||
const m = marks[Number(idx)];
|
||
if (!m) return '';
|
||
const fields = Object.entries(m.fields)
|
||
.filter(([, v]) => String(v ?? '').trim())
|
||
.map(([k, v]) => ` text:${k}="${escapeXml(String(v))}"`)
|
||
.join('');
|
||
return `<text:bibliography-mark text:identifier="${escapeXml(m.identifier)}"`
|
||
+ ` text:bibliography-type="${m.type}"${fields}>${escapeXml(m.text)}</text:bibliography-mark>`;
|
||
});
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// One ruby annotation, collected by replaceRuby: the base text and the reading over it.
|
||
type RubyExport = { base: string; text: string };
|
||
|
||
function replaceRuby(node: TiptapNode, rubies: RubyExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of node.content) {
|
||
if (child.type === 'ruby') {
|
||
const a = child.attrs ?? {};
|
||
rubies.push({ base: String(a.base ?? ''), text: String(a.text ?? '') });
|
||
content.push({ type: 'text', text: `${RBY}${rubies.length - 1}${RBY}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
content.push(replaceRuby(child, rubies));
|
||
}
|
||
return { ...node, content };
|
||
}
|
||
|
||
// RBY sentinels → <text:ruby>. The alignment and the position ride a ruby-family
|
||
// automatic style, which is where LibreOffice keeps them (probed: it writes the style
|
||
// out again unchanged, adding only its own loext: mirror of the position).
|
||
function applyRuby(odtBytes: Uint8Array, rubies: RubyExport[]): Uint8Array {
|
||
if (!rubies.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${RBY}(\\d+)${RBY}`, 'g'), (_m, idx: string) => {
|
||
const r = rubies[Number(idx)];
|
||
if (!r) return '';
|
||
return `<text:ruby text:style-name="${RUBY_STYLE}"><text:ruby-base>${escapeXml(r.base)}</text:ruby-base>`
|
||
+ `<text:ruby-text>${escapeXml(r.text)}</text:ruby-text></text:ruby>`;
|
||
});
|
||
content = injectAutomaticStyles(
|
||
content,
|
||
`<style:style style:name="${RUBY_STYLE}" style:family="ruby">`
|
||
+ '<style:ruby-properties style:ruby-align="center" style:ruby-position="above"/></style:style>',
|
||
);
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
const RUBY_STYLE = 'Ru1';
|
||
|
||
// One cross-reference, collected by replaceCrossRefs and emitted by applyBookmarks.
|
||
type CrossRefExport = { name: string; format: 'text' | 'page' };
|
||
|
||
const bookmarkNameOf = (node: TiptapNode): string =>
|
||
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
|
||
|
||
// One comment, collected by replaceComments and emitted by applyComments.
|
||
type CommentExport = { name: string; author: string; date: string; text: string; resolved: boolean };
|
||
|
||
// Bracket each comment's text with the CMS/CME sentinels, the same shape as the bookmark
|
||
// pair — spliced into the run text, so a comment inside a cell or a list rides along too.
|
||
function replaceComments(node: TiptapNode, out: CommentExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
let openId = '';
|
||
let openIndex = -1;
|
||
let lastOfRange = -1;
|
||
const closeRange = () => {
|
||
if (!openId) return;
|
||
const run = content[lastOfRange];
|
||
content[lastOfRange] = { ...run, text: `${run.text ?? ''}${CME}${openIndex}${CME}` };
|
||
openId = '';
|
||
};
|
||
for (const child of node.content) {
|
||
const mark = child.type === 'text' ? child.marks?.find(m => m.type === 'comment') : undefined;
|
||
const id = mark ? String(mark.attrs?.id ?? '') : '';
|
||
if (id !== openId) closeRange();
|
||
if (id && !openId) {
|
||
openId = id;
|
||
openIndex = out.length;
|
||
out.push({
|
||
name: id,
|
||
author: String(mark!.attrs?.author ?? ''),
|
||
date: String(mark!.attrs?.date ?? ''),
|
||
text: String(mark!.attrs?.text ?? ''),
|
||
resolved: mark!.attrs?.resolved === true,
|
||
});
|
||
content.push({ ...child, text: `${CMS}${openIndex}${CMS}${child.text ?? ''}` });
|
||
lastOfRange = content.length - 1;
|
||
continue;
|
||
}
|
||
content.push(id ? child : replaceComments(child, out));
|
||
if (id) lastOfRange = content.length - 1;
|
||
}
|
||
closeRange();
|
||
return { ...node, content };
|
||
}
|
||
|
||
// Bracket each bookmark's text with the BMS/BME sentinels and every `crossRef` node with
|
||
// an XRF-sentinel run; applyBookmarks resolves them after serialization. Spliced into the
|
||
// run text, not a run emitter, so both ride every odf-kit path (cells included).
|
||
function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode {
|
||
if (!node.content?.length) return node;
|
||
const content: TiptapNode[] = [];
|
||
// Index of the run the open bookmark started on, so its end sentinel lands on the
|
||
// last run of the range.
|
||
let open = '';
|
||
let lastOfRange = -1;
|
||
const closeRange = () => {
|
||
if (!open) return;
|
||
const run = content[lastOfRange];
|
||
content[lastOfRange] = { ...run, text: `${run.text ?? ''}${BME}${open}${BME}` };
|
||
open = '';
|
||
};
|
||
for (const child of node.content) {
|
||
if (child.type === 'crossRef') {
|
||
closeRange();
|
||
const a = child.attrs ?? {};
|
||
refs.push({ name: String(a.name ?? ''), format: a.format === 'page' ? 'page' : 'text' });
|
||
content.push({ type: 'text', text: `${XRF}${refs.length - 1}${XRF}${String(a.text ?? '')}${XRF}`, marks: child.marks });
|
||
continue;
|
||
}
|
||
const name = child.type === 'text' ? bookmarkNameOf(child) : '';
|
||
if (name !== open) closeRange();
|
||
if (name && !open) {
|
||
open = name;
|
||
content.push({ ...child, text: `${BMS}${name}${BMS}${child.text ?? ''}` });
|
||
lastOfRange = content.length - 1;
|
||
continue;
|
||
}
|
||
content.push(name ? child : replaceBookmarks(child, refs));
|
||
if (name) lastOfRange = content.length - 1;
|
||
}
|
||
closeRange();
|
||
return { ...node, content };
|
||
}
|
||
|
||
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
|
||
type TextBoxExport = {
|
||
widthCm: number;
|
||
heightCm: number;
|
||
rotationDeg: number;
|
||
wrap: WrapMode;
|
||
wrapOffsetCm: number | null;
|
||
wrapOffsetYCm: number | null;
|
||
wrapDistCm: number | null;
|
||
wrapAlign: string | null;
|
||
paddingCm: number;
|
||
shapeKind: ShapeKind;
|
||
shapePath: string | null;
|
||
flipV: boolean;
|
||
textVertical: boolean;
|
||
fill: string | null;
|
||
stroke: string | null;
|
||
strokeWidthPt: number;
|
||
// The anchor paragraph's own spacing in pt, which the box stands in for.
|
||
spaceBeforePt: number;
|
||
spaceAfterPt: number;
|
||
};
|
||
|
||
function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const pxToCm = (px: number) => round3((px * 2.54) / 96);
|
||
const a = node.attrs ?? {};
|
||
const wrapAttr = a.wrap;
|
||
return {
|
||
widthCm: pxToCm(typeof a.width === 'number' && a.width > 0 ? a.width : 280),
|
||
heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96),
|
||
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
|
||
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
|
||
wrapOffsetCm: typeof a.wrapOffset === 'number' ? round3(a.wrapOffset) : null,
|
||
wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null,
|
||
wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null,
|
||
wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null,
|
||
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
|
||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||
shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null,
|
||
flipV: a.flipV === true,
|
||
textVertical: a.textVertical === true,
|
||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||
strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1,
|
||
spaceBeforePt: typeof a.spaceBefore === 'number' ? round3(a.spaceBefore) : 0,
|
||
spaceAfterPt: typeof a.spaceAfter === 'number' ? round3(a.spaceAfter) : 0,
|
||
};
|
||
}
|
||
|
||
// Swap each top-level textBox for a pair of marker paragraphs bracketing its child blocks,
|
||
// hoisted to top level so they ride every existing export pass unchanged (custom attrs,
|
||
// list styles, inline sentinels, images); applyTextBoxes re-wraps the serialized region.
|
||
function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
|
||
if (!doc.content?.length) return doc;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of doc.content) {
|
||
if (child.type === 'textBox') {
|
||
const i = boxes.length;
|
||
boxes.push(textBoxDescriptor(child));
|
||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}S${i}${TBX}` }] });
|
||
content.push(...(child.content ?? []));
|
||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TBX}E${i}${TBX}` }] });
|
||
continue;
|
||
}
|
||
content.push(child);
|
||
}
|
||
return { ...doc, content };
|
||
}
|
||
|
||
// One footnote/endnote, collected by replaceNotes and emitted by applyNotes.
|
||
type NoteExport = { kind: 'footnote' | 'endnote'; citation: string; label: string | null; styleName: string | null };
|
||
|
||
// Anchors become an FNT A{i} sentinel run in the running text; the note section is
|
||
// dissolved and each note re-emitted as its own top-level paragraph opening with an
|
||
// FNT B{i} sentinel. Hoisted like a text box, so the note text rides every existing
|
||
// pass — marks, character styles, links, bookmarks, fields and formulas all included.
|
||
function replaceNotes(doc: TiptapNode, notes: NoteExport[]): TiptapNode {
|
||
const bodies = new Map<string, { content: TiptapNode[]; label: string | null; styleName: string | null }>();
|
||
const body: TiptapNode[] = [];
|
||
for (const child of doc.content ?? []) {
|
||
if (child.type !== 'noteSection') { body.push(child); continue; }
|
||
for (const note of child.content ?? []) {
|
||
const a = note.attrs ?? {};
|
||
bodies.set(String(a.id ?? ''), {
|
||
content: note.content ?? [],
|
||
label: typeof a.label === 'string' && a.label ? a.label : null,
|
||
styleName: typeof a.styleName === 'string' && a.styleName ? a.styleName : null,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Anchor order is the numbering order, so it is also the index order.
|
||
const order: string[] = [];
|
||
const swapRefs = (node: TiptapNode): TiptapNode => {
|
||
if (!node.content?.length) return node;
|
||
return {
|
||
...node,
|
||
content: node.content.map((child) => {
|
||
if (child.type !== 'noteRef') return swapRefs(child);
|
||
const a = child.attrs ?? {};
|
||
const id = String(a.id ?? '');
|
||
const i = notes.length;
|
||
order.push(id);
|
||
notes.push({
|
||
kind: a.kind === 'endnote' ? 'endnote' : 'footnote',
|
||
citation: String(a.text ?? ''),
|
||
label: bodies.get(id)?.label ?? null,
|
||
styleName: bodies.get(id)?.styleName ?? null,
|
||
});
|
||
return { type: 'text', text: `${FNT}A${i}${FNT}` };
|
||
}),
|
||
};
|
||
};
|
||
|
||
const hoisted = body.map(swapRefs);
|
||
order.forEach((id, i) => {
|
||
hoisted.push({
|
||
type: 'paragraph',
|
||
content: [{ type: 'text', text: `${FNT}B${i}${FNT}` }, ...(bodies.get(id)?.content ?? [])],
|
||
});
|
||
});
|
||
return { ...doc, content: hoisted };
|
||
}
|
||
|
||
// A multi-column section's geometry, collected by replaceColumns.
|
||
type ColumnsExport = { count: number; gapCm: number };
|
||
|
||
function columnsDescriptor(node: TiptapNode): ColumnsExport {
|
||
const a = node.attrs ?? {};
|
||
const count = typeof a.count === 'number' ? Math.min(3, Math.max(2, Math.round(a.count))) : 2;
|
||
const gapRaw = typeof a.gapCm === 'number' && Number.isFinite(a.gapCm) ? a.gapCm : 0.5;
|
||
const clamped = Math.min(5, Math.max(0, gapRaw));
|
||
return { count, gapCm: Math.round(clamped * 1000) / 1000 };
|
||
}
|
||
|
||
// Re-merge paragraphs split by columnsFlow at a page boundary (the second part
|
||
// carries the layout-internal joinPrev attr) — the file must hold ONE paragraph.
|
||
// Also used by the DOCX exporter.
|
||
export function mergeJoinedParagraphsJson(blocks: TiptapNode[]): TiptapNode[] {
|
||
const out: TiptapNode[] = [];
|
||
for (const b of blocks) {
|
||
const prev = out[out.length - 1];
|
||
if (b.type === 'paragraph' && b.attrs?.joinPrev && prev?.type === 'paragraph') {
|
||
out[out.length - 1] = { ...prev, content: [...(prev.content ?? []), ...(b.content ?? [])] };
|
||
} else {
|
||
out.push(b);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Swap each top-level columns section for marker paragraphs (same mechanism as
|
||
// replaceTextBoxes). Adjacent equal-attr fragments are a columnsFlow.ts page-split
|
||
// chain — one section, so they coalesce here.
|
||
function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode {
|
||
if (!doc.content?.length) return doc;
|
||
const content: TiptapNode[] = [];
|
||
const children = doc.content;
|
||
for (let c = 0; c < children.length; c++) {
|
||
const child = children[c];
|
||
if (child.type === 'columns') {
|
||
const i = cols.length;
|
||
const desc = columnsDescriptor(child);
|
||
cols.push(desc);
|
||
const inner: TiptapNode[] = [...(child.content ?? [])];
|
||
while (c + 1 < children.length && children[c + 1].type === 'columns') {
|
||
const next = columnsDescriptor(children[c + 1]);
|
||
if (next.count !== desc.count || next.gapCm !== desc.gapCm) break;
|
||
inner.push(...(children[c + 1].content ?? []));
|
||
c++;
|
||
}
|
||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}S${i}${COL}` }] });
|
||
content.push(...mergeJoinedParagraphsJson(inner));
|
||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${COL}E${i}${COL}` }] });
|
||
continue;
|
||
}
|
||
content.push(child);
|
||
}
|
||
return { ...doc, content };
|
||
}
|
||
|
||
// One generated table of contents, collected by replaceTableOfContents and emitted by
|
||
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
|
||
type TocEntry = { text: string; level: number; page: number; pages?: number[] };
|
||
type TocExport = { kind: IndexKind; entries: TocEntry[]; title: string | null; maxLevel: number; leader: string | null; tabPosCm: number | null; levelStyles: (string | null)[] | null; citationStyle: CitationStyle };
|
||
|
||
// Swap each top-level tableOfContents node for a marker paragraph carrying the TOC
|
||
// sentinel and collect its cached entries. Top-level only (like replacePageBreaks): a
|
||
// sentinel in a cell/list paragraph would corrupt the SEG-based cell rebuild.
|
||
function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode {
|
||
if (!doc.content?.length) return doc;
|
||
const content: TiptapNode[] = [];
|
||
for (const child of doc.content) {
|
||
if (child.type === 'tableOfContents') {
|
||
const raw = Array.isArray(child.attrs?.entries) ? (child.attrs!.entries as TocEntry[]) : [];
|
||
const entries = raw
|
||
.filter(e => e && typeof e.text === 'string')
|
||
.map(e => ({
|
||
text: String(e.text),
|
||
level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)),
|
||
page: Math.max(1, Number(e.page) || 1),
|
||
// An alphabetical row lists every page its term appears on.
|
||
...(Array.isArray(e.pages) && e.pages.length ? { pages: e.pages.map((n: unknown) => Math.max(1, Number(n) || 1)) } : {}),
|
||
}));
|
||
const rawTitle = child.attrs?.title;
|
||
const depth = Number(child.attrs?.maxLevel);
|
||
tocs.push({
|
||
kind: indexKindOf(child.attrs?.index),
|
||
entries,
|
||
title: typeof rawTitle === 'string' ? rawTitle : null,
|
||
maxLevel: depth >= 1 ? Math.min(MAX_HEADING_LEVEL, depth) : MAX_HEADING_LEVEL,
|
||
leader: normalizeLeader(child.attrs?.leader),
|
||
tabPosCm: typeof child.attrs?.tabPosCm === 'number' ? child.attrs.tabPosCm : null,
|
||
levelStyles: Array.isArray(child.attrs?.levelStyles) ? (child.attrs!.levelStyles as (string | null)[]) : null,
|
||
citationStyle: isCitationStyle(child.attrs?.citationStyle) ? child.attrs!.citationStyle : 'key',
|
||
});
|
||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] });
|
||
continue;
|
||
}
|
||
content.push(child);
|
||
}
|
||
return { ...doc, content };
|
||
}
|
||
|
||
function escapeXml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
|
||
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
|
||
function normalizeLineHeight(lh: number | string): string {
|
||
return typeof lh === 'number' ? `${Math.round(lh * 100)}%` : lh;
|
||
}
|
||
|
||
// Paragraph property overrides for the exported .odt, for both list-item and
|
||
// table-cell paragraphs (odf-kit's List/TableBuilder support no per-paragraph
|
||
// alignment/spacing/line-height), injected as automatic styles in post-processing.
|
||
type ParaStyle = {
|
||
align: AlignValue | null;
|
||
spaceBefore: number | null;
|
||
spaceAfter: number | null;
|
||
lineHeight: number | string | null;
|
||
// Paragraph background ("colored field") + per-side border ("rule line"). The border
|
||
// attr value ('<W>pt solid #RRGGBB') is already a valid ODF fo:border value.
|
||
background: string | null;
|
||
borderTop: string | null;
|
||
borderRight: string | null;
|
||
borderBottom: string | null;
|
||
borderLeft: string | null;
|
||
// The block's own base direction (textDirection.ts); null inherits the page's.
|
||
dir: 'ltr' | 'rtl' | null;
|
||
// Left/first-line/right indents in cm — cell blocks only: a list item's indent
|
||
// lives in its list style, and the importer skips paraProps indents there.
|
||
indent: number | null;
|
||
indentFirst: number | null;
|
||
indentRight: number | null;
|
||
};
|
||
|
||
function paraStyleIsEmpty(s: ParaStyle): boolean {
|
||
return s.align === null && s.spaceBefore === null && s.spaceAfter === null && s.lineHeight === null
|
||
&& s.background === null && s.borderTop === null && s.borderRight === null
|
||
&& s.borderBottom === null && s.borderLeft === null && s.dir === null
|
||
&& s.indent === null && s.indentFirst === null && s.indentRight === null;
|
||
}
|
||
|
||
// ODF writes a direction as a writing mode; the vertical ones are not ours to emit.
|
||
export function writingModeOf(dir: 'ltr' | 'rtl' | null | undefined): string | null {
|
||
return dir === 'rtl' ? 'rl-tb' : dir === 'ltr' ? 'lr-tb' : null;
|
||
}
|
||
|
||
// Extract the overridable paragraph properties from a node's attrs. Left
|
||
// alignment yields null (it's the Standard-style default, so no override needed).
|
||
function paraStyleFromAttrs(attrs: TiptapNode['attrs'], withIndents = true): ParaStyle {
|
||
const ta = attrs?.textAlign as AlignValue | undefined;
|
||
const cm = (v: unknown) => (withIndents && typeof v === 'number' && v !== 0 ? v : null);
|
||
const sb = attrs?.spaceBefore;
|
||
const sa = attrs?.spaceAfter;
|
||
const lh = attrs?.lineHeight;
|
||
let lineHeight: number | string | null = null;
|
||
if (lh != null) {
|
||
const lhNum = parseFloat(String(lh));
|
||
lineHeight = isNaN(lhNum) ? String(lh) : lhNum;
|
||
}
|
||
const border = (v: unknown) => (typeof v === 'string' && v && v !== 'none' ? v : null);
|
||
return {
|
||
align: ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null,
|
||
spaceBefore: typeof sb === 'number' ? sb : null,
|
||
spaceAfter: typeof sa === 'number' ? sa : null,
|
||
lineHeight,
|
||
background: typeof attrs?.backgroundColor === 'string' && attrs.backgroundColor ? attrs.backgroundColor : null,
|
||
borderTop: border(attrs?.borderTop),
|
||
borderRight: border(attrs?.borderRight),
|
||
borderBottom: border(attrs?.borderBottom),
|
||
borderLeft: border(attrs?.borderLeft),
|
||
dir: attrs?.dir === 'rtl' || attrs?.dir === 'ltr' ? attrs.dir : null,
|
||
indent: cm(attrs?.indent),
|
||
indentFirst: cm(attrs?.indentFirst),
|
||
indentRight: cm(attrs?.indentRight),
|
||
};
|
||
}
|
||
|
||
// fo:* paragraph-properties attribute strings for a ParaStyle override.
|
||
function paraStyleProps(style: ParaStyle): string[] {
|
||
const props: string[] = [];
|
||
if (style.align) props.push(`fo:text-align="${style.align}"`);
|
||
if (style.spaceBefore != null) props.push(`fo:margin-top="${style.spaceBefore}pt"`);
|
||
if (style.spaceAfter != null) props.push(`fo:margin-bottom="${style.spaceAfter}pt"`);
|
||
if (style.lineHeight != null) props.push(`fo:line-height="${normalizeLineHeight(style.lineHeight)}"`);
|
||
if (style.indent != null) props.push(`fo:margin-left="${style.indent}cm"`);
|
||
if (style.indentFirst != null) props.push(`fo:text-indent="${style.indentFirst}cm"`);
|
||
if (style.indentRight != null) props.push(`fo:margin-right="${style.indentRight}cm"`);
|
||
if (style.background) props.push(`fo:background-color="${style.background}"`);
|
||
// The canonical border attr ('<W>pt solid #RRGGBB') is itself a valid fo:border value.
|
||
for (const [attr, side] of [
|
||
['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'],
|
||
] as const) {
|
||
const v = style[attr];
|
||
if (v) props.push(`fo:border-${side}="${v}"`);
|
||
}
|
||
const wm = writingModeOf(style.dir);
|
||
if (wm) props.push(`style:writing-mode="${wm}"`);
|
||
return props;
|
||
}
|
||
|
||
// Per-cell block descriptors, built in exportTable and consumed in document order by
|
||
// applyCellBlocks to rebuild real <text:h>/<text:p>/<text:list> from the SEG-segmented
|
||
// <text:p> odf-kit emits. Each paragraph/heading/list item is one segment, in DFS order.
|
||
type CellListItem = { style: ParaStyle; nested: CellListBlock | null };
|
||
// listStyleType is the ordered list's raw attr (null = depth default / inherited
|
||
// multilevel chain); bulletChar the bullet list's marker attr (null = default cycle).
|
||
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; listStyleType: string | null; bulletChar: string | null; items: CellListItem[] };
|
||
type CellBlock =
|
||
| { kind: 'paragraph'; style: ParaStyle }
|
||
| { kind: 'heading'; level: number; style: ParaStyle }
|
||
| CellListBlock;
|
||
// A table's own margins in cm (0/0 tables are recorded as null).
|
||
type TableProps = { ml: number; mr: number; mt: number; mb: number; keepRows: boolean; repeatHeader: boolean };
|
||
|
||
// Collect the alignment + paragraph spacing of each listItem's first paragraph,
|
||
// in DFS order — matching the order that odf-kit emits <text:list-item> elements
|
||
// into content.xml. Items without overrides yield an all-null descriptor.
|
||
function collectListItemStyles(node: TiptapNode, result: ParaStyle[]): void {
|
||
if (node.type === 'listItem') {
|
||
const firstPara = node.content?.find(c => c.type === 'paragraph');
|
||
result.push(paraStyleFromAttrs(firstPara?.attrs, false));
|
||
// Recurse into nested lists only (their listItems extend the DFS sequence).
|
||
for (const child of node.content ?? []) {
|
||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||
collectListItemStyles(child, result);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
for (const child of node.content ?? []) {
|
||
collectListItemStyles(child, result);
|
||
}
|
||
}
|
||
|
||
// Collect each table row's explicit height (px → cm), in DFS order matching odf-kit's
|
||
// <table:table-row> emission. rowHeight is unscaled px @96dpi (tableRow.ts), converted
|
||
// to cm (px × 2.54 / 96). Rows without an explicit height yield null.
|
||
function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): void {
|
||
if (node.type === 'table') {
|
||
for (const row of node.content ?? []) {
|
||
if (row.type !== 'tableRow') continue;
|
||
const h = row.attrs?.rowHeight;
|
||
if (typeof h === 'number' && h > 0) {
|
||
const cm = Math.round(((h * 2.54) / 96) * 1000) / 1000;
|
||
result.push(`${cm}cm`);
|
||
} else {
|
||
result.push(null);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
for (const child of node.content ?? []) {
|
||
collectTableRowHeights(child, result);
|
||
}
|
||
}
|
||
|
||
// odf-kit's TableBuilder has no row-height option, so post-process content.xml: each
|
||
// <table:table-row> with a height gets an automatic style with style:min-row-height
|
||
// (a minimum) + use-optimal-row-height="false". One heights[] entry per row, in order.
|
||
function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): Uint8Array {
|
||
if (heights.every(h => h === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
const styleDefs: { name: string; height: string }[] = [];
|
||
const nameByHeight = new Map<string, string>();
|
||
let counter = 0;
|
||
let idx = 0;
|
||
|
||
content = content.replace(/<table:table-row\b([^>]*)>/g, (match, attrs: string) => {
|
||
const height = heights[idx++];
|
||
if (!height) return match;
|
||
if (/\btable:style-name=/.test(attrs)) return match;
|
||
let name = nameByHeight.get(height);
|
||
if (!name) {
|
||
counter++;
|
||
name = `TRH${counter}`;
|
||
nameByHeight.set(height, name);
|
||
styleDefs.push({ name, height });
|
||
}
|
||
return `<table:table-row table:style-name="${name}"${attrs}>`;
|
||
});
|
||
|
||
if (styleDefs.length === 0) return odtBytes;
|
||
|
||
const newStyles = styleDefs.map(({ name, height }) =>
|
||
`<style:style style:name="${name}" style:family="table-row"><style:table-row-properties style:min-row-height="${height}" style:use-optimal-row-height="false"/></style:style>`,
|
||
).join('\n');
|
||
|
||
content = injectAutomaticStyles(content, `${newStyles}\n`);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// odf-kit only writes the fo:padding shorthand, which ODF defines as one length —
|
||
// LibreOffice drops a multi-value form. The cells carry their table's margins as a
|
||
// CSS-style TRBL shorthand, which this expands per side (scoped to cell properties).
|
||
function expandCellPadding(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
const content = strFromU8(contentBytes).replace(
|
||
/(<style:table-cell-properties\b[^>]*?)fo:padding="([^"]*)"/g,
|
||
(_m, head: string, value: string) => {
|
||
const [top, right, bottom, left] = value.trim().split(/\s+/);
|
||
if (!left) return head + `fo:padding="${value}"`;
|
||
return head + `fo:padding-top="${top}" fo:padding-right="${right}"`
|
||
+ ` fo:padding-bottom="${bottom}" fo:padding-left="${left}"`;
|
||
},
|
||
);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// A dragged table edge → the style's fo:margin-left/-right + style:width (with odf-kit's
|
||
// table:align="margins" the table fills what's left); keep-rows → may-break-between-rows.
|
||
// odf-kit names table styles Table1, Table2, … in document order, as the descriptors are.
|
||
function applyTableProps(odtBytes: Uint8Array, margins: (TableProps | null)[], contentWidthCm: number): Uint8Array {
|
||
if (margins.every(m => m === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
margins.forEach((m, i) => {
|
||
if (!m) return;
|
||
const width = Math.round((contentWidthCm - m.ml - m.mr) * 1000) / 1000;
|
||
const horiz = m.ml || m.mr
|
||
? ` style:width="${width}cm" fo:margin-left="${m.ml}cm" fo:margin-right="${m.mr}cm"` : '';
|
||
const vert = `${m.mt ? ` fo:margin-top="${m.mt}cm"` : ''}${m.mb ? ` fo:margin-bottom="${m.mb}cm"` : ''}`;
|
||
const keep = m.keepRows ? ' style:may-break-between-rows="false"' : '';
|
||
content = content.replace(
|
||
new RegExp(`(<style:style[^>]*style:name="Table${i + 1}"[^>]*>\\s*<style:table-properties)`),
|
||
`$1${horiz}${vert}${keep}`,
|
||
);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// <table:table-header-rows> around a table's first row, which is what makes both word
|
||
// processors repeat it at the top of every page the table continues on. The tables come
|
||
// out in document order, the same order applyTableProps walks.
|
||
function applyTableHeaderRows(odtBytes: Uint8Array, tables: (TableProps | null)[]): Uint8Array {
|
||
if (!tables.some(t => t?.repeatHeader)) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
let index = -1;
|
||
content = content.replace(/<table:table\b[\s\S]*?<\/table:table>/g, (table) => {
|
||
index++;
|
||
if (!tables[index]?.repeatHeader) return table;
|
||
const open = table.indexOf('<table:table-row');
|
||
if (open < 0) return table;
|
||
const close = table.indexOf('</table:table-row>', open);
|
||
if (close < 0) return table;
|
||
const end = close + '</table:table-row>'.length;
|
||
return table.slice(0, open)
|
||
+ `<table:table-header-rows>${table.slice(open, end)}</table:table-header-rows>`
|
||
+ table.slice(end);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// A table's named style: odf-kit's automatic Table{n} style gets it as its parent, and
|
||
// applyNamedStyles defines it in styles.xml. ODF has no banding, so only the name
|
||
// travels — the look rides on the cell attrs the style painted.
|
||
function applyTableStyleNames(odtBytes: Uint8Array, tables: (TableStyleRef | null)[]): Uint8Array {
|
||
if (tables.every(t => t === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
let nth = 0;
|
||
tables.forEach((ref, i) => {
|
||
if (!ref) return;
|
||
content = content.replace(
|
||
new RegExp(`(<style:style[^>]*style:name="Table${i + 1}")`),
|
||
`$1 style:parent-style-name="${odfStyleName(ref.name)}"`,
|
||
);
|
||
});
|
||
// Which conditional areas the table opts into (ODF's own table:use-*-styles), so the
|
||
// toggles survive a round trip. Lookahead, not \b: a hyphen is a word boundary, so \b
|
||
// would also match <table:table-cell/-row/-column and consume the counter.
|
||
content = content.replace(/<table:table(?=[\s>])[^>]*/g, (tag) => {
|
||
const ref = tables[nth++];
|
||
return ref ? `${tag} ${odfLookAttrs(ref.look)}` : tag;
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// ODF names these per conditional area on <table:table> (§ table template attributes).
|
||
export const ODF_LOOK_ATTRS: Record<TableRegion, string> = {
|
||
headerRow: 'table:use-first-row-styles',
|
||
lastRow: 'table:use-last-row-styles',
|
||
firstColumn: 'table:use-first-column-styles',
|
||
lastColumn: 'table:use-last-column-styles',
|
||
bandedRow: 'table:use-banding-rows-styles',
|
||
bandedColumn: 'table:use-banding-columns-styles',
|
||
};
|
||
|
||
function odfLookAttrs(look: TableLook): string {
|
||
return TABLE_REGIONS.map(r => `${ODF_LOOK_ATTRS[r]}="${look[r]}"`).join(' ');
|
||
}
|
||
|
||
// odf-kit's ListBuilder has no per-item paragraph options, so list-item paragraphs all
|
||
// emit as List_20_Bullet/Number. Rewrite content.xml to point those at automatic styles
|
||
// that inherit the list style and add fo:text-align / fo:margin-top / fo:margin-bottom.
|
||
function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Array {
|
||
if (styles.every(paraStyleIsEmpty)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
const styleDefs: { name: string; parent: string; style: ParaStyle }[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let counter = 0;
|
||
let idx = 0;
|
||
|
||
// Each text:list-item directly contains the item's paragraph as its first child;
|
||
// the item tag may carry attributes (applyListStartValues adds text:start-value).
|
||
content = content.replace(
|
||
/(<text:list-item[^>]*>\s*<text:p text:style-name=")(List_20_Bullet|List_20_Number)(")/g,
|
||
(_match, pre, parentStyle, post) => {
|
||
const style = styles[idx++];
|
||
if (!style || paraStyleIsEmpty(style)) return `${pre}${parentStyle}${post}`;
|
||
// Key on the emitted properties, so the key can never lag behind ParaStyle's fields.
|
||
const key = `${parentStyle}|${paraStyleProps(style).join('|')}`;
|
||
let name = nameByKey.get(key);
|
||
if (!name) {
|
||
counter++;
|
||
name = `LP${counter}`;
|
||
nameByKey.set(key, name);
|
||
styleDefs.push({ name, parent: parentStyle, style });
|
||
}
|
||
return `${pre}${name}${post}`;
|
||
},
|
||
);
|
||
|
||
if (styleDefs.length === 0) return odtBytes;
|
||
|
||
const newStyles = styleDefs.map(({ name, parent, style }) =>
|
||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${paraStyleProps(style).join(' ')}/></style:style>`,
|
||
).join('\n');
|
||
|
||
content = injectAutomaticStyles(content, `${newStyles}\n`);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The whole <style:style …>…</style:style> (or self-closing) for an automatic style by
|
||
// name from content.xml. null when the name is a common/named style (defined in
|
||
// styles.xml), so applyPageBreaks mints a child of it instead of cloning.
|
||
function findAutoStyle(content: string, name: string): string | null {
|
||
const open = content.indexOf(`<style:style style:name="${name}"`);
|
||
if (open < 0) return null;
|
||
const gt = content.indexOf('>', open);
|
||
if (gt < 0) return null;
|
||
if (content[gt - 1] === '/') return content.slice(open, gt + 1);
|
||
const closeTag = '</style:style>';
|
||
const close = content.indexOf(closeTag, gt);
|
||
if (close < 0) return null;
|
||
return content.slice(open, close + closeTag.length);
|
||
}
|
||
|
||
// Clone a <style:style> definition under newName, ensuring its paragraph-properties
|
||
// carry fo:break-before="page" (paragraph-properties precedes text-properties in ODF, so
|
||
// a freshly inserted one stays first child).
|
||
function cloneStyleWithBreak(def: string, newName: string): string {
|
||
const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`);
|
||
if (/fo:break-before=/.test(s)) return s;
|
||
const pp = '<style:paragraph-properties fo:break-before="page"/>';
|
||
if (/^<style:style\b[^>]*\/>\s*$/.test(s)) {
|
||
return s.replace(/\/>\s*$/, `>${pp}</style:style>`);
|
||
}
|
||
if (/<style:paragraph-properties\b[^>]*\/>/.test(s)) {
|
||
return s.replace(/<style:paragraph-properties\b([^>]*?)\s*\/>/, '<style:paragraph-properties$1 fo:break-before="page"/>');
|
||
}
|
||
if (/<style:paragraph-properties\b/.test(s)) {
|
||
return s.replace(/<style:paragraph-properties\b([^>]*)>/, '<style:paragraph-properties$1 fo:break-before="page">');
|
||
}
|
||
return s.replace(/(<style:style\b[^>]*[^/]>)/, `$1${pp}`);
|
||
}
|
||
|
||
// Resolve PGB sentinels: per source style, mint a fo:break-before="page" paragraph style
|
||
// (cloning its props so blocks sharing the style aren't affected, reused across blocks),
|
||
// reassign each marked block's style-name, and strip the sentinel.
|
||
function applyPageBreaks(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(PGB)) return odtBytes;
|
||
|
||
const minted: string[] = [];
|
||
const nameBySource = new Map<string, string>();
|
||
let counter = 0;
|
||
|
||
const breakStyleFor = (source: string): string => {
|
||
const existing = nameBySource.get(source);
|
||
if (existing) return existing;
|
||
// PBK, not PB: applyParagraphBoxes mints PB names, and a duplicate style:name
|
||
// makes the importer resolve both paragraphs to whichever definition it finds.
|
||
const name = `PBK${++counter}`;
|
||
nameBySource.set(source, name);
|
||
const def = source ? findAutoStyle(content, source) : null;
|
||
minted.push(def
|
||
? cloneStyleWithBreak(def, name)
|
||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:paragraph-properties fo:break-before="page"/></style:style>`,
|
||
);
|
||
return name;
|
||
};
|
||
|
||
// The sentinel sits anywhere in the block: a custom paragraph's serialized text
|
||
// starts with the FSZ/STY/PBX payloads (their passes run later), then the PGB.
|
||
content = content.replace(
|
||
new RegExp(`<text:(p|h)\\b((?:[^>]*[^/>])?)>([\\s\\S]*?)</text:\\1>`, 'g'),
|
||
(m, tag: string, attrs: string, inner: string) => {
|
||
if (!inner.includes(PGB)) return m;
|
||
const sm = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = breakStyleFor(sm ? sm[1] : '');
|
||
const newAttrs = sm
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:${tag}${newAttrs}>${inner.split(PGB).join('')}</text:${tag}>`;
|
||
},
|
||
);
|
||
// Drop any sentinels not consumed above (defensive — never legitimate text).
|
||
content = content.split(PGB).join('');
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The paragraph mark's font as an FSZ payload ("<size>|<family>", either half empty).
|
||
function markFontPayload(attrs: TiptapNode['attrs']): string {
|
||
const size = typeof attrs?.fontSize === 'string' ? attrs.fontSize : '';
|
||
const family = typeof attrs?.fontFamily === 'string' ? attrs.fontFamily : '';
|
||
return size || family ? `${size}|${family}` : '';
|
||
}
|
||
|
||
// The paragraph style's own text properties for that payload (+ asian/complex aliases).
|
||
function fontSizeProps(payload: string): string {
|
||
const [size, family] = payload.split('|');
|
||
const font = family ? twinFontName(family) : '';
|
||
return [
|
||
size ? `fo:font-size="${size}" style:font-size-asian="${size}" style:font-size-complex="${size}"` : '',
|
||
font ? `style:font-name="${font}" style:font-name-asian="${font}" style:font-name-complex="${font}"` : '',
|
||
].filter(Boolean).join(' ');
|
||
}
|
||
|
||
// Clone a <style:style> def under newName, adding fo:font-size to its text-properties.
|
||
function cloneStyleWithFontSize(def: string, newName: string, size: string): string {
|
||
const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`);
|
||
const props = fontSizeProps(size);
|
||
if (/<style:text-properties\b[^>]*\/>/.test(s)) {
|
||
return s.replace(/<style:text-properties\b([^>]*?)\s*\/>/, `<style:text-properties$1 ${props}/>`);
|
||
}
|
||
if (/<style:text-properties\b/.test(s)) {
|
||
return s.replace(/<style:text-properties\b([^>]*)>/, `<style:text-properties$1 ${props}>`);
|
||
}
|
||
if (/^<style:style\b[^>]*\/>\s*$/.test(s)) {
|
||
return s.replace(/\s*\/>\s*$/, `><style:text-properties ${props}/></style:style>`);
|
||
}
|
||
return s.replace('</style:style>', `<style:text-properties ${props}/></style:style>`);
|
||
}
|
||
|
||
// Resolve FSZ sentinels (empty-line font size): per (source style, size) mint a style
|
||
// cloning the source's props plus that fo:font-size, reassign the block's style-name,
|
||
// and strip the sentinel. Mirrors applyPageBreaks.
|
||
function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(FSZ)) return odtBytes;
|
||
|
||
const minted: string[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let counter = 0;
|
||
|
||
const sizeStyleFor = (source: string, size: string): string => {
|
||
const key = `${source}|${size}`;
|
||
const existing = nameByKey.get(key);
|
||
if (existing) return existing;
|
||
const name = `EF${++counter}`;
|
||
nameByKey.set(key, name);
|
||
const def = source ? findAutoStyle(content, source) : null;
|
||
minted.push(def
|
||
? cloneStyleWithFontSize(def, name, size)
|
||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"><style:text-properties ${fontSizeProps(size)}/></style:style>`,
|
||
);
|
||
return name;
|
||
};
|
||
|
||
content = content.replace(
|
||
new RegExp(`<text:(p|h)\\b([^>]*)>${FSZ}([^${FSZ}]*)${FSZ}`, 'g'),
|
||
(_m, tag: string, attrs: string, size: string) => {
|
||
const sm = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = sizeStyleFor(sm ? sm[1] : '', size);
|
||
const newAttrs = sm
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:${tag}${newAttrs}>`;
|
||
},
|
||
);
|
||
// Drop any sentinels not consumed above (defensive — never legitimate text).
|
||
content = content.replace(new RegExp(`${FSZ}[^${FSZ}]*${FSZ}`, 'g'), '');
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left,
|
||
// each the raw value (canonical border '<W>pt solid #RRGGBB' is a valid fo:border) or '',
|
||
// then a widow flag, the right indent, a keep-with-next flag, the writing mode and a
|
||
// no-hyphenation flag. '' when it needs none.
|
||
function paraBoxSpec(attrs: TiptapNode['attrs']): string {
|
||
const s = paraStyleFromAttrs(attrs);
|
||
const noWidow = attrs?.widowControl === false;
|
||
const keepNext = attrs?.keepNext === true;
|
||
const keepLines = attrs?.keepLines === true;
|
||
const noHyphen = attrs?.noHyphenation === true;
|
||
// odf-kit has a paragraph option for the left indent but none for the right one.
|
||
const right = typeof attrs?.indentRight === 'number' && attrs.indentRight > 0 ? attrs.indentRight : 0;
|
||
const wm = writingModeOf(s.dir);
|
||
if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext && !keepLines && !wm && !noHyphen) return '';
|
||
return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft]
|
||
.map((v) => v ?? '')
|
||
.concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '', keepLines ? 'g1' : '', wm ?? '', noHyphen ? 'h0' : '').join('|');
|
||
}
|
||
|
||
function boxSpecToProps(spec: string): string {
|
||
const [bg, bt, br, bb, bl, widow, marginRight, keepNext, keepLines, writingMode] = spec.split('|');
|
||
const props: string[] = [];
|
||
if (bg) props.push(`fo:background-color="${bg}"`);
|
||
if (bt) props.push(`fo:border-top="${bt}"`);
|
||
if (br) props.push(`fo:border-right="${br}"`);
|
||
if (bb) props.push(`fo:border-bottom="${bb}"`);
|
||
if (bl) props.push(`fo:border-left="${bl}"`);
|
||
// LibreOffice writes 0/0 for "off"; absent means the XSL-FO default of 2.
|
||
if (widow === 'w0') props.push('fo:orphans="0"', 'fo:widows="0"');
|
||
if (marginRight) props.push(`fo:margin-right="${marginRight}"`);
|
||
if (keepNext === 'k1') props.push('fo:keep-with-next="always"');
|
||
if (keepLines === 'g1') props.push('fo:keep-together="always"');
|
||
if (writingMode) props.push(`style:writing-mode="${writingMode}"`);
|
||
return props.join(' ');
|
||
}
|
||
|
||
// Add fo:* into a cloned style's <style:paragraph-properties> (which precedes text-props
|
||
// in ODF). Mirrors cloneStyleWithFontSize but for paragraph-properties.
|
||
function cloneStyleWithParaProps(def: string, newName: string, props: string): string {
|
||
const s = def.replace(/style:name="[^"]*"/, `style:name="${newName}"`);
|
||
if (!props) return s;
|
||
if (/<style:paragraph-properties\b[^>]*\/>/.test(s))
|
||
return s.replace(/<style:paragraph-properties\b([^>]*?)\s*\/>/, `<style:paragraph-properties$1 ${props}/>`);
|
||
if (/<style:paragraph-properties\b/.test(s))
|
||
return s.replace(/<style:paragraph-properties\b([^>]*)>/, `<style:paragraph-properties$1 ${props}>`);
|
||
if (/^<style:style\b[^>]*\/>\s*$/.test(s))
|
||
return s.replace(/\s*\/>\s*$/, `><style:paragraph-properties ${props}/></style:style>`);
|
||
if (/<style:text-properties\b/.test(s))
|
||
return s.replace(/<style:text-properties\b/, `<style:paragraph-properties ${props}/><style:text-properties`);
|
||
return s.replace('</style:style>', `<style:paragraph-properties ${props}/></style:style>`);
|
||
}
|
||
|
||
// ---- named paragraph styles (styles/styleSheet.ts) --------------------------------
|
||
|
||
// Set or replace attributes on an element's opening tag.
|
||
function setTagAttrs(tag: string, attrs: Record<string, string>): string {
|
||
let out = tag;
|
||
for (const [name, value] of Object.entries(attrs)) {
|
||
const re = new RegExp(`\\s${name}="[^"]*"`);
|
||
out = re.test(out)
|
||
? out.replace(re, ` ${name}="${value}"`)
|
||
: out.replace(/(\/?>)$/, ` ${name}="${value}"$1`);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Merge attributes into a style's <style:paragraph-properties>/<style:text-properties>,
|
||
// keeping whatever the producer already put there (language, keep-with-next, …).
|
||
function upsertProps(block: string, kind: 'paragraph' | 'text', attrs: Record<string, string>): string {
|
||
if (!Object.keys(attrs).length) return block;
|
||
const el = `style:${kind}-properties`;
|
||
const open = new RegExp(`<${el}\\b[^>]*>`);
|
||
const m = open.exec(block);
|
||
if (m) return block.replace(open, setTagAttrs(m[0], attrs));
|
||
const props = setTagAttrs(`<${el}/>`, attrs);
|
||
// paragraph-properties precedes text-properties in ODF.
|
||
if (kind === 'paragraph' && block.includes('<style:text-properties'))
|
||
return block.replace('<style:text-properties', `${props}<style:text-properties`);
|
||
if (/^<style:style\b[^>]*\/>$/.test(block.trim()))
|
||
return block.trim().replace(/\/>$/, `>${props}</style:style>`);
|
||
return block.replace('</style:style>', `${props}</style:style>`);
|
||
}
|
||
|
||
// A style's OWN properties as ODF attributes — the chain stays a chain in the file,
|
||
// so only what the style itself declares is written.
|
||
function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<string, unknown> }) {
|
||
const p = style.para as Record<string, string | number | undefined>;
|
||
const t = style.text as Record<string, string | number | boolean | undefined>;
|
||
const para: Record<string, string> = {};
|
||
if (p.textAlign) para['fo:text-align'] = String(p.textAlign);
|
||
if (p.lineHeight != null) para['fo:line-height'] = normalizeLineHeight(p.lineHeight as string | number);
|
||
if (p.spaceBefore != null) para['fo:margin-top'] = `${p.spaceBefore}pt`;
|
||
if (p.spaceAfter != null) para['fo:margin-bottom'] = `${p.spaceAfter}pt`;
|
||
if (p.indent != null) para['fo:margin-left'] = `${p.indent}cm`;
|
||
if (p.backgroundColor) para['fo:background-color'] = String(p.backgroundColor);
|
||
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
|
||
const v = p[key];
|
||
if (!v || v === 'none') continue;
|
||
para[`fo:border-${side}`] = String(v);
|
||
// The gap to the rule, on the ruled sides only — fo:padding would ring the block.
|
||
if (p.borderPadding != null) para[`fo:padding-${side}`] = `${p.borderPadding}pt`;
|
||
}
|
||
const text: Record<string, string> = {};
|
||
if (t.fontFamily) {
|
||
// The registry holds the on-screen family; the file declares its metric twin.
|
||
const font = twinFontName(String(t.fontFamily));
|
||
text['style:font-name'] = font;
|
||
text['style:font-name-asian'] = font;
|
||
text['style:font-name-complex'] = font;
|
||
}
|
||
if (t.fontSizePt != null) {
|
||
const size = `${t.fontSizePt}pt`;
|
||
text['fo:font-size'] = size;
|
||
text['style:font-size-asian'] = size;
|
||
text['style:font-size-complex'] = size;
|
||
}
|
||
if (t.letterSpacingPt) text['fo:letter-spacing'] = `${t.letterSpacingPt}pt`;
|
||
if (t.kerning != null) text['style:letter-kerning'] = String(t.kerning);
|
||
if (t.bold != null) text['fo:font-weight'] = t.bold ? 'bold' : 'normal';
|
||
if (t.italic != null) text['fo:font-style'] = t.italic ? 'italic' : 'normal';
|
||
if (t.underline) text['style:text-underline-style'] = 'solid';
|
||
if (t.strike) text['style:text-line-through-style'] = 'solid';
|
||
if (t.color) text['fo:color'] = String(t.color);
|
||
if (t.caps === 'smallCaps') text['fo:font-variant'] = 'small-caps';
|
||
else if (t.caps) text['fo:text-transform'] = String(t.caps);
|
||
return { para, text };
|
||
}
|
||
|
||
// Attributes the style model owns: dropped from a producer's block before the style's
|
||
// own values go in, so anything the style leaves open really comes from its parent.
|
||
const MANAGED_STYLE_ATTRS = [
|
||
'fo:margin-top', 'fo:margin-bottom', 'fo:margin-left', 'fo:text-align', 'fo:line-height',
|
||
'fo:background-color', 'fo:border-top', 'fo:border-right', 'fo:border-bottom', 'fo:border-left',
|
||
'fo:font-size', 'style:font-size-asian', 'style:font-size-complex',
|
||
'fo:font-weight', 'style:font-weight-asian', 'style:font-weight-complex',
|
||
'fo:font-style', 'style:font-style-asian', 'style:font-style-complex',
|
||
'style:text-underline-style', 'style:text-line-through-style', 'fo:color',
|
||
'style:font-name', 'style:font-name-asian', 'style:font-name-complex',
|
||
];
|
||
|
||
function stripManagedProps(block: string): string {
|
||
let out = block;
|
||
for (const attr of MANAGED_STYLE_ATTRS) out = out.replace(new RegExp(`\\s${attr}="[^"]*"`, 'g'), '');
|
||
return out;
|
||
}
|
||
|
||
// Write the document's named paragraph styles into styles.xml: merge into the blocks
|
||
// odf-kit already emits (Standard, Heading, Heading_20_N), append the rest. The parent
|
||
// chain is preserved, so LibreOffice/Word show them as real, inheriting styles.
|
||
function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set()): string {
|
||
const added: string[] = [];
|
||
// Table styles: ODF's table family carries no banding, so the name is all that travels
|
||
// (the look is baked into the cells). An empty style block is enough to make it real.
|
||
for (const name of usedTables) {
|
||
const odfName = odfStyleName(name);
|
||
if (findAutoStyle(styles, odfName)) continue;
|
||
added.push(setTagAttrs('<style:style/>', {
|
||
'style:name': odfName, 'style:family': 'table', 'style:display-name': name,
|
||
}));
|
||
}
|
||
// Character styles are the same shape, family "text" and text props only.
|
||
for (const style of Object.values(sheet.character ?? {})) {
|
||
const { text } = ownStyleAttrs(style);
|
||
if (!Object.keys(text).length) continue;
|
||
const odfName = odfStyleName(style.name);
|
||
if (findAutoStyle(styles, odfName)) continue;
|
||
const attrs: Record<string, string> = {
|
||
'style:name': odfName, 'style:family': 'text', 'style:display-name': style.name,
|
||
};
|
||
added.push(upsertProps(`${setTagAttrs('<style:style/>', attrs).replace('/>', '>')}</style:style>`, 'text', text));
|
||
}
|
||
for (const style of Object.values(sheet.paragraph)) {
|
||
if (!used.has(style.name)) continue;
|
||
const odfName = odfStyleName(style.name);
|
||
const { para, text } = ownStyleAttrs(style);
|
||
const existing = findAutoStyle(styles, odfName);
|
||
if (existing) {
|
||
let block = stripManagedProps(existing);
|
||
if (style.parent) block = block.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) =>
|
||
setTagAttrs(tag, { 'style:parent-style-name': odfStyleName(style.parent!) }));
|
||
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
|
||
styles = styles.replace(existing, block);
|
||
continue;
|
||
}
|
||
const attrs: Record<string, string> = {
|
||
'style:name': odfName, 'style:family': 'paragraph', 'style:display-name': style.name,
|
||
};
|
||
if (style.parent) attrs['style:parent-style-name'] = odfStyleName(style.parent);
|
||
if (style.next) attrs['style:next-style-name'] = odfStyleName(style.next);
|
||
let block = `${setTagAttrs('<style:style/>', attrs).replace('/>', '>')}</style:style>`;
|
||
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
|
||
added.push(block);
|
||
}
|
||
return added.length ? styles.replace('</office:styles>', `${added.join('')}</office:styles>`) : styles;
|
||
}
|
||
|
||
// Resolve STY sentinels: point each marked block at its named style — directly when it
|
||
// carries no direct formatting, else via a clone of its automatic style whose parent
|
||
// becomes the named one (so hard formatting keeps overriding the style, as in LO).
|
||
function applyParagraphStyles(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(STY)) return odtBytes;
|
||
|
||
const minted: string[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let counter = 0;
|
||
|
||
const styleFor = (source: string, styleName: string): string => {
|
||
const odfName = odfStyleName(styleName);
|
||
const auto = source ? findAutoStyle(content, source) : null;
|
||
if (!auto) return odfName; // no direct formatting: reference the style itself
|
||
const key = `${source}|${odfName}`;
|
||
const existing = nameByKey.get(key);
|
||
if (existing) return existing;
|
||
const name = `PS${++counter}`;
|
||
nameByKey.set(key, name);
|
||
minted.push(
|
||
auto
|
||
.replace(/style:name="[^"]*"/, `style:name="${name}"`)
|
||
.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) =>
|
||
setTagAttrs(tag, { 'style:parent-style-name': odfName })),
|
||
);
|
||
return name;
|
||
};
|
||
|
||
// Anchored right after the opening tag (where the sentinel is emitted): a scan to the
|
||
// closing tag would start at a text box's anchor paragraph and steal the first box
|
||
// child's sentinel — nested <text:p> (box children, note bodies) make spans unsafe.
|
||
content = content.replace(
|
||
new RegExp(`<text:(p|h)\\b((?:[^>]*[^/>])?)>${STY}([^${STY}]*)${STY}`, 'g'),
|
||
(_m, tag: string, attrs: string, styleName: string) => {
|
||
const srcM = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = styleFor(srcM ? srcM[1] : '', styleName);
|
||
const newAttrs = srcM
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:${tag}${newAttrs}>`;
|
||
},
|
||
);
|
||
content = content.replace(new RegExp(`${STY}[^${STY}]*${STY}`, 'g'), '');
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Resolve PBX sentinels: per (source style, box spec) mint a paragraph style cloning the
|
||
// source plus fo:background-color/fo:border-*, reassign the block, and strip the sentinel.
|
||
// Mirrors applyEmptyLineFontSizes; runs after it so an empty line's FSZ is already resolved.
|
||
function applyParagraphBoxes(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(PBX)) return odtBytes;
|
||
|
||
const minted: string[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let counter = 0;
|
||
|
||
const boxStyleFor = (source: string, spec: string): string => {
|
||
const key = `${source}|${spec}`;
|
||
const existing = nameByKey.get(key);
|
||
if (existing) return existing;
|
||
const name = `PB${++counter}`;
|
||
nameByKey.set(key, name);
|
||
const props = boxSpecToProps(spec);
|
||
const def = source ? findAutoStyle(content, source) : null;
|
||
let style = def
|
||
? cloneStyleWithParaProps(def, name, props)
|
||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}">${props ? `<style:paragraph-properties ${props}/>` : ''}</style:style>`;
|
||
// ODF counts fo:hyphenate as a *text* property — in paragraph-properties LibreOffice
|
||
// ignores it and drops it on the next save.
|
||
if (spec.split('|')[10] === 'h0') style = upsertProps(style, 'text', { 'fo:hyphenate': 'false' });
|
||
minted.push(style);
|
||
return name;
|
||
};
|
||
|
||
// Anchored right after the opening tag, over an optional STY run (its pass runs later;
|
||
// emission order is FSZ STY PBX). A scan to the closing tag would start at a text box's
|
||
// anchor paragraph and steal the first box child's sentinel.
|
||
content = content.replace(
|
||
new RegExp(`<text:(p|h)\\b((?:[^>]*[^/>])?)>((?:${STY}[^${STY}]*${STY})?)${PBX}([^${PBX}]*)${PBX}`, 'g'),
|
||
(_m, tag: string, attrs: string, sty: string, spec: string) => {
|
||
const srcM = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = boxStyleFor(srcM ? srcM[1] : '', spec);
|
||
const newAttrs = srcM
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:${tag}${newAttrs}>${sty}`;
|
||
},
|
||
);
|
||
// Drop any sentinels not consumed above (defensive — never legitimate text).
|
||
content = content.replace(new RegExp(`${PBX}[^${PBX}]*${PBX}`, 'g'), '');
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// odf-kit always emits ordered lists as "1." at every level. This rewrites the
|
||
// matching L# list style per level: the depth-default cycle (1. → a. → i.), explicit
|
||
// listStyleType attrs, or the multilevel chain (decimal + text:display-levels).
|
||
type OrderedFmt = { numFormat: string; numSuffix: string };
|
||
type OlStyleFix = { fmts: OrderedFmt[]; multilevel: boolean };
|
||
|
||
const DEFAULT_ORDERED_FMTS: OrderedFmt[] = Array.from({ length: 6 }, (_, i) => {
|
||
const d = effectiveOrderedDef(null, i);
|
||
return { numFormat: d.numFormat, numSuffix: d.numSuffix };
|
||
});
|
||
const MULTILEVEL_FMTS: OrderedFmt[] = Array.from({ length: 6 }, () => ({ numFormat: '1', numSuffix: '.' }));
|
||
|
||
// Effective numbering of an ordered list at a cycle position: its attr, else the
|
||
// cycle default (re-anchoring slot + suffix at explicit styles; 'multilevel' handled
|
||
// by the callers).
|
||
function effOrderedFmt(list: TiptapNode, cycle: OrderedCycle): OrderedFmt {
|
||
const key = list.attrs?.listStyleType as string | null | undefined;
|
||
const def = effectiveOrderedDefAt(key === 'multilevel' ? 'decimal' : key, cycle);
|
||
return { numFormat: def.numFormat, numSuffix: def.numSuffix };
|
||
}
|
||
|
||
// Per-level numbering for a list subtree — same DFS-first model as bulletCharVector,
|
||
// threading each list's cycle position down so nested defaults advance past their parent.
|
||
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[], startCycle: OrderedCycle): OrderedFmt[] {
|
||
const vec = [...base];
|
||
const seen = new Set<number>();
|
||
const walk = (node: TiptapNode, depth: number, cycle: OrderedCycle) => {
|
||
if (depth > vec.length) return;
|
||
if (node.type === 'orderedList' && !seen.has(depth)) {
|
||
seen.add(depth);
|
||
vec[depth - 1] = effOrderedFmt(node, cycle);
|
||
}
|
||
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
|
||
for (const item of node.content ?? []) {
|
||
if (item.type !== 'listItem') continue;
|
||
for (const child of item.content ?? []) {
|
||
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1, cChild);
|
||
}
|
||
}
|
||
};
|
||
walk(list, startDepth, startCycle);
|
||
return vec;
|
||
}
|
||
|
||
function collectOrderedListFormats(node: TiptapNode, result: (OlStyleFix | null)[]): void {
|
||
for (const child of node.content ?? []) {
|
||
if (child.type === 'bulletList') {
|
||
result.push(null);
|
||
} else if (child.type === 'orderedList') {
|
||
if (child.attrs?.listStyleType === 'multilevel') {
|
||
result.push({ fmts: MULTILEVEL_FMTS, multilevel: true });
|
||
} else {
|
||
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS, ROOT_ORDERED_CYCLE);
|
||
// odf-kit's own output is all-decimal; only an all-"1." vector needs no rewrite.
|
||
result.push(vec.every(f => f.numFormat === '1' && f.numSuffix === '.') ? null : { fmts: vec, multilevel: false });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OlStyleFix | null)[]): Uint8Array {
|
||
if (formats.every(f => f === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
formats.forEach((fix, i) => {
|
||
if (!fix) return;
|
||
// Rewrite only inside this list's own <text:list-style> block, level by level.
|
||
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
|
||
content = content.replace(re, (_m, open: string, body: string, close: string) =>
|
||
open +
|
||
body.replace(
|
||
/(<text:list-level-style-number text:level=")(\d)(" style:num-format=")[^"]*(" style:num-suffix=")[^"]*(")/g,
|
||
(_mm, a: string, lvl: string, b: string, c: string, d: string) => {
|
||
const f = fix.fmts[+lvl - 1] ?? DEFAULT_ORDERED_FMTS[+lvl - 1];
|
||
const disp = fix.multilevel && +lvl > 1 ? ` text:display-levels="${lvl}"` : '';
|
||
return a + lvl + b + f.numFormat + c + f.numSuffix + d + disp;
|
||
},
|
||
) +
|
||
close,
|
||
);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// One slot per top-level list (matching odf-kit's L# order): an ordered list's
|
||
// start value when >1 (a continued Word list), else null.
|
||
function collectListStartValues(node: TiptapNode, result: (number | null)[]): void {
|
||
for (const child of node.content ?? []) {
|
||
if (child.type === 'bulletList') {
|
||
result.push(null);
|
||
} else if (child.type === 'orderedList') {
|
||
const s = child.attrs?.start;
|
||
result.push(typeof s === 'number' && s > 1 ? s : null);
|
||
}
|
||
}
|
||
}
|
||
|
||
// odf-kit drops the ordered-list `start` attr, so a list continued across an intervening
|
||
// paragraph would restart at 1. Add text:start-value to each such list's first item.
|
||
function applyListStartValues(odtBytes: Uint8Array, starts: (number | null)[]): Uint8Array {
|
||
if (starts.every(s => s === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
starts.forEach((start, i) => {
|
||
if (start == null) return;
|
||
const re = new RegExp(`(<text:list text:style-name="L${i + 1}"[^>]*>\\s*<text:list-item)>`);
|
||
content = content.replace(re, `$1 text:start-value="${start}">`);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Effective marker char of a bullet list at a 1-based depth: its bulletChar attr,
|
||
// else the default cycle.
|
||
function effBulletChar(list: TiptapNode, depth: number): string {
|
||
const ch = list.attrs?.bulletChar;
|
||
return typeof ch === 'string' && ch ? ch : defaultBulletChar(depth - 1);
|
||
}
|
||
|
||
// Per-level marker chars for a list subtree: slot d-1 = the DFS-first bullet list
|
||
// at depth d (one list style governs the whole tree, so the first list met at a depth
|
||
// defines that level; differing siblings get NL mints; unvisited depths keep `base`).
|
||
function bulletCharVector(list: TiptapNode, startDepth: number, base: string[]): string[] {
|
||
const vec = [...base];
|
||
const seen = new Set<number>();
|
||
const walk = (node: TiptapNode, depth: number) => {
|
||
if (depth > vec.length) return;
|
||
if (node.type === 'bulletList' && !seen.has(depth)) {
|
||
seen.add(depth);
|
||
vec[depth - 1] = effBulletChar(node, depth);
|
||
}
|
||
for (const item of node.content ?? []) {
|
||
if (item.type !== 'listItem') continue;
|
||
for (const child of item.content ?? []) {
|
||
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1);
|
||
}
|
||
}
|
||
};
|
||
walk(list, startDepth);
|
||
return vec;
|
||
}
|
||
|
||
// odf-kit emits every bullet level with the default cycle char. This rewrites the
|
||
// matching L# list style to the per-level chars of the user's bulletChar attrs;
|
||
// chars[] has one entry per top-level list (null = ordered or all-default).
|
||
function collectBulletListChars(node: TiptapNode, result: (string[] | null)[]): void {
|
||
for (const child of node.content ?? []) {
|
||
if (child.type === 'orderedList') {
|
||
result.push(null);
|
||
} else if (child.type === 'bulletList') {
|
||
const vec = bulletCharVector(child, 1, DEFAULT_BULLET_CYCLE);
|
||
result.push(vec.every((c, i) => c === DEFAULT_BULLET_CYCLE[i]) ? null : vec);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyBulletListChars(odtBytes: Uint8Array, chars: (string[] | null)[]): Uint8Array {
|
||
if (chars.every(c => c === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
chars.forEach((vec, i) => {
|
||
if (!vec) return;
|
||
// Rewrite only inside this list's own <text:list-style> block (all 6 levels).
|
||
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
|
||
content = content.replace(re, (_m, open: string, body: string, close: string) =>
|
||
open +
|
||
body.replace(
|
||
/(<text:list-level-style-bullet text:level=")(\d)(" text:bullet-char=")[^"]*(")/g,
|
||
(_mm, a: string, lvl: string, b: string, c: string) => a + lvl + b + (vec[+lvl - 1] ?? defaultBulletChar(+lvl - 1)) + c,
|
||
) +
|
||
close,
|
||
);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Per-level indent (cm) and label alignment of each top-level list, from the first list
|
||
// at that level. odf-kit uses label-alignment mode (which ignores the paragraph margin),
|
||
// so both go onto the L# list-style's own level definitions.
|
||
type ListLevelProps = { indent: number; right: boolean };
|
||
|
||
function collectListLevelProps(node: TiptapNode, result: ListLevelProps[][]): void {
|
||
for (const child of node.content ?? []) {
|
||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||
const levels: ListLevelProps[] = [];
|
||
const visit = (list: TiptapNode, depth: number) => {
|
||
const ind = list.attrs?.indent;
|
||
if (levels[depth - 1] === undefined) {
|
||
levels[depth - 1] = { indent: typeof ind === 'number' ? ind : 0, right: list.attrs?.markerAlign === 'right' };
|
||
}
|
||
for (const item of list.content ?? []) {
|
||
for (const block of item.content ?? []) {
|
||
if (block.type === 'bulletList' || block.type === 'orderedList') visit(block, depth + 1);
|
||
}
|
||
}
|
||
};
|
||
visit(child, 1);
|
||
result.push(levels);
|
||
}
|
||
}
|
||
|
||
function applyListLevelProps(odtBytes: Uint8Array, props: ListLevelProps[][]): Uint8Array {
|
||
const plain = (l: ListLevelProps) => !l?.indent && !l?.right;
|
||
if (props.every(levels => levels.every(plain))) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
const bump = (cm: number) => (_m: string, attr: string, v: string) =>
|
||
`${attr}="${(parseFloat(v) + cm).toFixed(3)}cm"`;
|
||
// The editor nests the levels, so a level's margin grows by every indent above it too.
|
||
const rewriteLevels = (body: string, levels: ListLevelProps[]) =>
|
||
body.replace(/<text:list-level-style-[a-z]+[\s\S]*?(?=<text:list-level-style-|$)/g, (lvl) => {
|
||
const n = parseInt(/text:level="(\d+)"/.exec(lvl)?.[1] ?? '', 10);
|
||
if (!Number.isFinite(n)) return lvl;
|
||
const cm = levels.slice(0, n).reduce((a, b) => a + (b?.indent || 0), 0);
|
||
const shifted = cm
|
||
? lvl.replace(/(fo:margin-left)="([\d.]+)cm"/g, bump(cm))
|
||
.replace(/(text:list-tab-stop-position)="([\d.]+)cm"/g, bump(cm))
|
||
: lvl;
|
||
return levels[n - 1]?.right
|
||
? shifted.replace('<style:list-level-properties ', '<style:list-level-properties fo:text-align="end" ')
|
||
: shifted;
|
||
});
|
||
|
||
props.forEach((levels, i) => {
|
||
if (levels.every(plain)) return;
|
||
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
|
||
content = content.replace(re, (_m, open: string, body: string, close: string) =>
|
||
open + rewriteLevels(body, levels) + close);
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Marker formatting per nesting level of each top-level list, from the first list at
|
||
// that level (null unless its items agree — see listMarkerFormat). Nested lists that
|
||
// odf-kit restyles keep their plain marker.
|
||
function collectListMarkerFormats(doc: TiptapNode, result: (MarkerFormat | null)[][]): void {
|
||
for (const child of doc.content ?? []) {
|
||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||
const levels: (MarkerFormat | null)[] = [];
|
||
const visit = (list: TiptapNode, depth: number) => {
|
||
if (levels[depth - 1] === undefined) levels[depth - 1] = listMarkerFormat(list, charStyleProps(exportSheet));
|
||
for (const item of list.content ?? []) {
|
||
for (const block of item.content ?? []) {
|
||
if (block.type === 'bulletList' || block.type === 'orderedList') visit(block, depth + 1);
|
||
}
|
||
}
|
||
};
|
||
visit(child, 1);
|
||
result.push(levels);
|
||
}
|
||
}
|
||
|
||
// text:style-name on the level definition points at a style:family="text" style — how
|
||
// ODF formats a number/bullet. It has to be a **named** style in styles.xml:
|
||
// LibreOffice ignores the reference when it resolves to an automatic one (probed).
|
||
function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | null)[][]): Uint8Array {
|
||
if (!formats.some((levels) => levels.some(Boolean))) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
const stylesBytes = files['styles.xml'];
|
||
if (!contentBytes || !stylesBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
const minted: string[] = [];
|
||
const nameByProps = new Map<string, string>();
|
||
const styleFor = (format: MarkerFormat): string => {
|
||
const color = format.color ? normalizeColor(format.color) : undefined;
|
||
const props = (format.fontFamily ? ` fo:font-family="${twinFontName(format.fontFamily)}"` : '')
|
||
+ (format.fontWeight ? ` fo:font-weight="${format.fontWeight}"` : '')
|
||
+ (format.fontStyle ? ` fo:font-style="${format.fontStyle}"` : '')
|
||
+ (format.fontSize ? ` fo:font-size="${format.fontSize}"` : '')
|
||
+ (color ? ` fo:color="${color}"` : '');
|
||
let name = nameByProps.get(props);
|
||
if (!name) {
|
||
name = `MK${nameByProps.size + 1}`;
|
||
nameByProps.set(props, name);
|
||
minted.push(`<style:style style:name="${name}" style:family="text"><style:text-properties${props}/></style:style>`);
|
||
}
|
||
return name;
|
||
};
|
||
|
||
formats.forEach((levels, i) => {
|
||
if (!levels.some(Boolean)) return;
|
||
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
|
||
content = content.replace(re, (_m, open: string, body: string, close: string) =>
|
||
open +
|
||
body.replace(/<text:list-level-style-(number|bullet) text:level="(\d)"/g, (match, kind: string, level: string) => {
|
||
const format = levels[Number(level) - 1];
|
||
return format ? `<text:list-level-style-${kind} text:level="${level}" text:style-name="${styleFor(format)}"` : match;
|
||
}) +
|
||
close,
|
||
);
|
||
});
|
||
|
||
if (minted.length) {
|
||
files['styles.xml'] = strToU8(
|
||
strFromU8(stylesBytes).replace('</office:styles>', `${minted.join('\n')}\n</office:styles>`),
|
||
);
|
||
}
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// odf-kit emits nested lists as bare <text:list> sharing the top-level L# style, so a
|
||
// nested list of a different kind/format/marker loses its look. Mint it its own
|
||
// 6-level list style (same pattern applyCellBlocks uses for cell lists).
|
||
type ListDef = { ordered: boolean; multilevel: boolean; fmts: OrderedFmt[]; bulletChars: string[] };
|
||
|
||
function listDefOf(node: TiptapNode, depth: number, cycle: OrderedCycle, baseChars: string[], baseFmts: OrderedFmt[]): ListDef {
|
||
const multilevel = node.attrs?.listStyleType === 'multilevel';
|
||
return {
|
||
ordered: node.type === 'orderedList',
|
||
multilevel,
|
||
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts, cycle),
|
||
bulletChars: bulletCharVector(node, depth, baseChars),
|
||
};
|
||
}
|
||
|
||
// One entry per nested <text:list> in DFS order (null = inherits its governing
|
||
// style correctly). Only walks top-level lists — cell lists never emit bare tags.
|
||
function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): void {
|
||
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number, cycle: OrderedCycle) => {
|
||
let gov = governing;
|
||
if (!isTop) {
|
||
const ordered = list.type === 'orderedList';
|
||
let differs: boolean;
|
||
if (ordered !== governing.ordered) {
|
||
differs = true;
|
||
} else if (!ordered) {
|
||
differs = effBulletChar(list, depth) !== governing.bulletChars[(depth - 1) % governing.bulletChars.length];
|
||
} else if (governing.multilevel) {
|
||
// Attr-less lists inherit the chain; only an explicit style breaks out.
|
||
const attr = list.attrs?.listStyleType;
|
||
differs = !!attr && attr !== 'multilevel';
|
||
} else {
|
||
const f = effOrderedFmt(list, cycle);
|
||
const g = governing.fmts[(depth - 1) % governing.fmts.length];
|
||
differs = f.numFormat !== g.numFormat || f.numSuffix !== g.numSuffix;
|
||
}
|
||
const def = differs ? listDefOf(list, depth, cycle, governing.bulletChars, governing.fmts) : null;
|
||
result.push(def);
|
||
if (def) gov = def; // restyled list governs its own descendants
|
||
}
|
||
const cChild = childCycle(cycle, list.attrs?.listStyleType as string | null | undefined, list.type === 'orderedList');
|
||
for (const item of list.content ?? []) {
|
||
if (item.type !== 'listItem') continue;
|
||
for (const child of item.content ?? []) {
|
||
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1, cChild);
|
||
}
|
||
}
|
||
};
|
||
for (const child of doc.content ?? []) {
|
||
if (child.type === 'bulletList' || child.type === 'orderedList') {
|
||
walkList(child, listDefOf(child, 1, ROOT_ORDERED_CYCLE, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS), true, 1, ROOT_ORDERED_CYCLE);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]): Uint8Array {
|
||
if (fixes.every(f => f === null)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
const minted: string[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
const styleFor = (fix: ListDef): string => {
|
||
const key = fix.ordered
|
||
? `o|${fix.multilevel ? 'M' : ''}|${fix.fmts.map(f => f.numFormat + f.numSuffix).join('')}`
|
||
: `b|${fix.bulletChars.join('')}`;
|
||
let name = nameByKey.get(key);
|
||
if (!name) {
|
||
name = `NL${nameByKey.size + 1}`;
|
||
nameByKey.set(key, name);
|
||
minted.push(buildCellListStyle(name, fix.ordered, { fmts: fix.fmts, multilevel: fix.multilevel, bulletChars: fix.bulletChars }));
|
||
}
|
||
return name;
|
||
};
|
||
|
||
// Bare <text:list> tags are exactly odf-kit's nested lists, in DFS order.
|
||
let idx = 0;
|
||
content = content.replace(/<text:list>/g, (match) => {
|
||
const fix = fixes[idx++];
|
||
return fix ? `<text:list text:style-name="${styleFor(fix)}">` : match;
|
||
});
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, `${minted.join('\n')}\n`);
|
||
}
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
|
||
// in-cell lists reference. Six nesting levels with the given bullet chars /
|
||
// indents / numbering, so cell lists match the editor's top-level lists.
|
||
function buildCellListStyle(
|
||
styleName: string,
|
||
ordered: boolean,
|
||
opts: { fmts?: OrderedFmt[]; multilevel?: boolean; bulletChars?: string[] } = {},
|
||
): string {
|
||
const fmts = opts.multilevel ? MULTILEVEL_FMTS : opts.fmts ?? MULTILEVEL_FMTS;
|
||
const bulletChars = opts.bulletChars ?? DEFAULT_BULLET_CYCLE;
|
||
let levels = '';
|
||
for (let level = 1; level <= 6; level++) {
|
||
const indent = level * 0.635;
|
||
const marginLeft = `${(indent * 2).toFixed(3)}cm`;
|
||
const textIndent = `-${indent.toFixed(3)}cm`;
|
||
const labelAlign = `<style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="${marginLeft}" fo:text-indent="${textIndent}" fo:margin-left="${marginLeft}"/></style:list-level-properties>`;
|
||
if (ordered) {
|
||
const f = fmts[(level - 1) % fmts.length];
|
||
const disp = opts.multilevel && level > 1 ? ` text:display-levels="${level}"` : '';
|
||
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${f.numFormat}" style:num-suffix="${f.numSuffix}"${disp}>${labelAlign}</text:list-level-style-number>`;
|
||
} else {
|
||
const ch = bulletChars[(level - 1) % bulletChars.length];
|
||
levels += `<text:list-level-style-bullet text:level="${level}" text:bullet-char="${ch}">${labelAlign}</text:list-level-style-bullet>`;
|
||
}
|
||
}
|
||
return `<text:list-style style:name="${styleName}">${levels}</text:list-style>`;
|
||
}
|
||
|
||
// odf-kit serializes every cell to a single <text:p> of runs (no API for headings,
|
||
// lists, or multiple paragraphs). exportTable emits a cell's content into that paragraph
|
||
// SEG-separated; this pass splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
|
||
|
||
// Ordering: after applyListItemStyles (so it sees only top-level list items, not cell
|
||
// lists) and before collapseRunWhitespace (so new in-cell paragraphs get newlines stripped).
|
||
function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8Array {
|
||
const needsWork = (blocks: CellBlock[]): boolean => {
|
||
if (blocks.length !== 1) return true;
|
||
const b = blocks[0];
|
||
return b.kind !== 'paragraph' || !paraStyleIsEmpty(b.style);
|
||
};
|
||
if (!cellBlocks.some(needsWork)) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
|
||
// Minted paragraph automatic styles (parent = Standard / Heading_20_N /
|
||
// List_20_*), deduped by parent + properties. An empty ParaStyle reuses the
|
||
// parent directly (no minted style).
|
||
const styleDefs: { name: string; parent: string; style: ParaStyle }[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let styleCounter = 0;
|
||
const styleNameFor = (parent: string, style: ParaStyle): string => {
|
||
if (paraStyleIsEmpty(style)) return parent;
|
||
// Key on the emitted properties, so the key can never lag behind ParaStyle's fields.
|
||
const key = `${parent}|${paraStyleProps(style).join('|')}`;
|
||
let name = nameByKey.get(key);
|
||
if (!name) {
|
||
styleCounter++;
|
||
name = `CB${styleCounter}`;
|
||
nameByKey.set(key, name);
|
||
styleDefs.push({ name, parent, style });
|
||
}
|
||
return name;
|
||
};
|
||
|
||
let usedBulletList = false;
|
||
// Bullet cell lists with a custom marker char get their own style carrying that
|
||
// char at the list's own nesting depth (each <text:list> references its style
|
||
// directly, so only the level it sits at matters).
|
||
const bulletStyles: { name: string; chars: string[] }[] = [];
|
||
const bulletStyleByKey = new Map<string, string>();
|
||
const bulletStyleFor = (bulletChar: string | null, depth: number): string => {
|
||
const ch = bulletChar ?? defaultBulletChar(depth - 1);
|
||
if (ch === defaultBulletChar(depth - 1)) {
|
||
usedBulletList = true;
|
||
return CELL_LIST_BULLET_STYLE;
|
||
}
|
||
const key = `${ch}|${depth}`;
|
||
let name = bulletStyleByKey.get(key);
|
||
if (!name) {
|
||
name = `${CELL_LIST_BULLET_STYLE}${bulletStyleByKey.size + 1}`;
|
||
bulletStyleByKey.set(key, name);
|
||
const chars = [...DEFAULT_BULLET_CYCLE];
|
||
chars[(depth - 1) % chars.length] = ch;
|
||
bulletStyles.push({ name, chars });
|
||
}
|
||
return name;
|
||
};
|
||
// One minted style per distinct numbering format (first = CELL_LIST_NUMBER_STYLE,
|
||
// further ones counter-suffixed). The multilevel chain shares one style: nested lists
|
||
// reference the same name, and their nesting depth selects the display-levels def.
|
||
const numberStyles: { name: string; numFormat: string; numSuffix: string; multilevel?: boolean }[] = [];
|
||
const numberStyleByKey = new Map<string, string>();
|
||
const numberStyleFor = (numFormat: string, numSuffix: string, multilevel = false): string => {
|
||
const key = multilevel ? 'ML' : `${numFormat}|${numSuffix}`;
|
||
let name = numberStyleByKey.get(key);
|
||
if (!name) {
|
||
name = numberStyleByKey.size === 0 ? CELL_LIST_NUMBER_STYLE : `${CELL_LIST_NUMBER_STYLE}${numberStyleByKey.size}`;
|
||
numberStyleByKey.set(key, name);
|
||
numberStyles.push({ name, numFormat, numSuffix, multilevel });
|
||
}
|
||
return name;
|
||
};
|
||
|
||
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
|
||
// bullet/number nesting renders correctly; indent comes from nesting depth. One
|
||
// segment consumed per list item, DFS order (matching buildCellContent).
|
||
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1, mlGov = false, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string => {
|
||
const isBullet = !list.ordered;
|
||
const ml = !isBullet && (list.listStyleType === 'multilevel' || (mlGov && !list.listStyleType));
|
||
let listStyle: string;
|
||
if (isBullet) {
|
||
listStyle = bulletStyleFor(list.bulletChar, depth);
|
||
} else if (ml) {
|
||
listStyle = numberStyleFor('1', '.', true);
|
||
} else {
|
||
const def = effectiveOrderedDefAt(list.listStyleType, cycle);
|
||
listStyle = numberStyleFor(def.numFormat, def.numSuffix);
|
||
}
|
||
const cChild = childCycle(cycle, list.listStyleType, list.ordered);
|
||
const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number';
|
||
let out = `<text:list text:style-name="${listStyle}">`;
|
||
list.items.forEach((item, idx) => {
|
||
const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : '';
|
||
const seg = segments[cur.i++] ?? '';
|
||
out += `<text:list-item${startAttr}><text:p text:style-name="${styleNameFor(paraParent, item.style)}">${seg}</text:p>`;
|
||
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1, ml, cChild);
|
||
out += '</text:list-item>';
|
||
});
|
||
return out + '</text:list>';
|
||
};
|
||
|
||
const buildBlocks = (blocks: CellBlock[], inner: string): string => {
|
||
const segments = inner.split(SEG);
|
||
const cur = { i: 0 };
|
||
let out = '';
|
||
for (const block of blocks) {
|
||
if (block.kind === 'list') {
|
||
out += buildList(block, segments, cur);
|
||
} else if (block.kind === 'heading') {
|
||
const seg = segments[cur.i++] ?? '';
|
||
const name = styleNameFor(`Heading_20_${block.level}`, block.style);
|
||
out += `<text:h text:style-name="${name}" text:outline-level="${block.level}">${seg}</text:h>`;
|
||
} else {
|
||
const seg = segments[cur.i++] ?? '';
|
||
out += `<text:p text:style-name="${styleNameFor('Standard', block.style)}">${seg}</text:p>`;
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
|
||
// Match each real cell's single paragraph (filled or empty) in document order.
|
||
// Covered cells use <table:covered-table-cell> and never match; a cell holds exactly
|
||
// one <text:p> with no nesting here, so the non-greedy inner capture is safe.
|
||
let idx = 0;
|
||
content = content.replace(
|
||
/(<table:table-cell\b[^>]*>\s*)<text:p\b[^>]*?(?:\/>|>([\s\S]*?)<\/text:p>)/g,
|
||
(match, pre: string, innerRaw: string | undefined) => {
|
||
const blocks = cellBlocks[idx++];
|
||
if (!blocks || !needsWork(blocks)) return match;
|
||
const inner = innerRaw ?? '';
|
||
// Trivial single paragraph that only needs a style override: keep one
|
||
// <text:p>, just point it at the minted style.
|
||
if (blocks.length === 1 && blocks[0].kind === 'paragraph') {
|
||
const name = styleNameFor('Standard', blocks[0].style);
|
||
return innerRaw === undefined
|
||
? `${pre}<text:p text:style-name="${name}"/>`
|
||
: `${pre}<text:p text:style-name="${name}">${inner}</text:p>`;
|
||
}
|
||
return `${pre}${buildBlocks(blocks, inner)}`;
|
||
},
|
||
);
|
||
|
||
const additions: string[] = styleDefs.map(({ name, parent, style }) =>
|
||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${paraStyleProps(style).join(' ')}/></style:style>`,
|
||
);
|
||
if (usedBulletList) additions.push(buildCellListStyle(CELL_LIST_BULLET_STYLE, false));
|
||
for (const { name, chars } of bulletStyles) {
|
||
additions.push(buildCellListStyle(name, false, { bulletChars: chars }));
|
||
}
|
||
for (const { name, numFormat, numSuffix, multilevel } of numberStyles) {
|
||
additions.push(buildCellListStyle(name, true, { fmts: Array.from({ length: 6 }, () => ({ numFormat, numSuffix })), multilevel }));
|
||
}
|
||
|
||
if (additions.length) {
|
||
content = injectAutomaticStyles(content, `${additions.join('\n')}\n`);
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Re-zip an unpacked ODF, preserving the mimetype-first uncompressed requirement.
|
||
// Intermediate archive between two passes: stored, the next unzipSync is the only
|
||
// reader — deflating every entry (images included) once per pass dominated buildOdt.
|
||
function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
|
||
const out: Record<string, [Uint8Array, { level: 0 }]> = {};
|
||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 0 }];
|
||
return zipSync(out);
|
||
}
|
||
|
||
// The returned .odt: mimetype first and stored (ODF requirement), already-compressed
|
||
// picture formats stored (svg/bmp still deflate well), everything else deflated once.
|
||
function zipFinal(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
|
||
const mimetype = files['mimetype'];
|
||
if (mimetype) out['mimetype'] = [mimetype, { level: 0 }];
|
||
for (const [path, data] of Object.entries(files)) {
|
||
if (path === 'mimetype') continue;
|
||
const stored = /^Pictures\/.*\.(png|jpg|gif|webp)$/.test(path);
|
||
out[path] = [data, { level: stored ? 0 : 6 }];
|
||
}
|
||
return zipSync(out);
|
||
}
|
||
|
||
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
|
||
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
|
||
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
|
||
// LibreOffice's watermark is a fontwork shape in the master page's header, and the
|
||
// enhanced geometry below is the "fontwork-plain-text" preset it writes verbatim.
|
||
const WATERMARK_GEOMETRY =
|
||
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:text-areas="0 0 21600 21600"' +
|
||
' draw:text-path="true" draw:type="fontwork-plain-text" draw:modifiers="10800"' +
|
||
' draw:enhanced-path="M ?f3 0 L ?f5 0 N M ?f6 21600 L ?f7 21600 N">' +
|
||
'<draw:equation draw:name="f0" draw:formula="$0 -10800"/>' +
|
||
'<draw:equation draw:name="f1" draw:formula="?f0 *2"/>' +
|
||
'<draw:equation draw:name="f2" draw:formula="abs(?f1 )"/>' +
|
||
'<draw:equation draw:name="f3" draw:formula="if(?f1 ,0,?f2 )"/>' +
|
||
'<draw:equation draw:name="f4" draw:formula="21600-?f2 "/>' +
|
||
'<draw:equation draw:name="f5" draw:formula="if(?f1 ,?f4 ,21600)"/>' +
|
||
'<draw:equation draw:name="f6" draw:formula="if(?f1 ,?f2 ,0)"/>' +
|
||
'<draw:equation draw:name="f7" draw:formula="if(?f1 ,21600,?f4 )"/>' +
|
||
'</draw:enhanced-geometry>';
|
||
|
||
// LibreOffice's own shape name; Word looks for exactly this one, so a round trip
|
||
// through either keeps recognising the shape as a watermark rather than a drawing.
|
||
const WATERMARK_NAME = 'PowerPlusWaterMarkObject';
|
||
// The shape's aspect ratio, measured off LibreOffice's own output (17cm × 3.789cm).
|
||
const WATERMARK_RATIO = 4.487;
|
||
|
||
// The watermark shape plus its two automatic styles, spliced into the master page's
|
||
// header — where both word processors keep it. A document without a header gets an
|
||
// explicit zero-height header band, so the body's geometry is unchanged.
|
||
function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Array {
|
||
if (!wm?.text) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const stylesBytes = files['styles.xml'];
|
||
if (!stylesBytes) return odtBytes;
|
||
const styles = strFromU8(stylesBytes);
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const widthCm = Math.max(1, lengthOfPageLayout(styles) );
|
||
const heightCm = round3(widthCm / WATERMARK_RATIO);
|
||
const opacity = `${round3(100 - wm.transparency)}%`;
|
||
const minted =
|
||
`<style:style style:name="Wmk1" style:family="graphic"><style:graphic-properties` +
|
||
` draw:stroke="none" draw:fill="solid" draw:fill-color="${wm.color}" draw:opacity="${opacity}"` +
|
||
` draw:auto-grow-height="false" draw:auto-grow-width="false"` +
|
||
` fo:min-height="${heightCm}cm" fo:min-width="${round3(widthCm)}cm"` +
|
||
` style:run-through="background" style:wrap="run-through" style:number-wrapped-paragraphs="no-limit"` +
|
||
` style:vertical-pos="middle" style:vertical-rel="page-content"` +
|
||
` style:horizontal-pos="center" style:horizontal-rel="page-content" style:flow-with-text="false"/></style:style>` +
|
||
`<style:style style:name="WmkP1" style:family="paragraph"><style:text-properties` +
|
||
` fo:font-family="${escapeXml(`'${wm.font}'`)}" fo:font-size="1pt"/></style:style>`;
|
||
const shape =
|
||
`<draw:custom-shape text:anchor-type="char" draw:z-index="0" draw:name="${WATERMARK_NAME}"` +
|
||
` draw:style-name="Wmk1" draw:text-style-name="WmkP1"` +
|
||
` svg:width="${round3(widthCm)}cm" svg:height="${heightCm}cm"` +
|
||
` draw:transform="rotate (${(wm.angle * Math.PI) / 180})">` +
|
||
`<text:p>${escapeXml(wm.text)}</text:p>${WATERMARK_GEOMETRY}</draw:custom-shape>`;
|
||
|
||
// odf-kit declares no draw: prefix in styles.xml, and an undeclared one makes
|
||
// LibreOffice drop the whole shape without a word.
|
||
let out = styles.includes('xmlns:draw=')
|
||
? styles
|
||
: styles.replace(/<office:document-styles\b/, '<office:document-styles xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"');
|
||
out = injectAutomaticStyles(out, minted);
|
||
// Into the header the document already has — the shape is out of flow, so it changes
|
||
// neither the band nor the zone's text.
|
||
if (out.includes('<style:header>')) {
|
||
out = out.replace(/<style:header>(\s*<text:p[^>]*>)?/, (m) =>
|
||
m.includes('<text:p') ? `${m}${shape}` : `${m}<text:p text:style-name="Header">${shape}</text:p>`);
|
||
} else {
|
||
// No header: give the master page one that reserves no band of its own. LibreOffice
|
||
// still floors the band at 0.499cm, so the body shifts by that much — its own
|
||
// watermark does the same.
|
||
const zone = `<style:header><text:p text:style-name="Header">${shape}</text:p></style:header>`;
|
||
out = out.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
|
||
out = /<style:master-page\b[^>]*\/>/.test(out)
|
||
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
|
||
: out.replace(/(<style:master-page\b[^>]*>)/, `$1${zone}`);
|
||
}
|
||
files['styles.xml'] = strToU8(out);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The text width the page layout declares, in cm — the watermark spans it, as
|
||
// LibreOffice's does.
|
||
function lengthOfPageLayout(styles: string): number {
|
||
const num = (attr: string) => {
|
||
const m = new RegExp(`${attr}="([\\d.]+)cm"`).exec(styles);
|
||
return m ? parseFloat(m[1]) : 0;
|
||
};
|
||
const width = num('fo:page-width');
|
||
return width ? width - num('fo:margin-left') - num('fo:margin-right') : 17;
|
||
}
|
||
|
||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const stylesBytes = files['styles.xml'];
|
||
if (!stylesBytes) return odtBytes;
|
||
|
||
let styles = strFromU8(stylesBytes);
|
||
|
||
// Exact page box for the chosen format; odf-kit only emits its 5 presets, so
|
||
// override the single fo:page-width/height in the page layout (round to 3 dp).
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const dims = pageDimsCm(pageFormat, orientation);
|
||
styles = styles
|
||
.replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(dims.w)}cm"`)
|
||
.replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(dims.h)}cm"`);
|
||
|
||
// Mirrored margins: fo:margin-left/right are the inner/outer pair, which is what
|
||
// the editor already holds, so only the flag has to be written back.
|
||
if (mirrored) {
|
||
styles = styles.replace(/<style:page-layout /, '<style:page-layout style:page-usage="mirrored" ');
|
||
}
|
||
|
||
// A right-to-left page: its columns fill from the right, as the editor draws them.
|
||
if (rtl) {
|
||
styles = styles.replace(/<style:page-layout-properties /, '<style:page-layout-properties style:writing-mode="rl-tb" ');
|
||
}
|
||
|
||
// Page background and page border ride the page layout, exactly where LibreOffice
|
||
// keeps them (probed). The border is offset from the text area by fo:padding.
|
||
const decorProps = [
|
||
decor.border ? `fo:border="${round3(decor.border.widthPt)}pt solid ${decor.border.color}"` : '',
|
||
decor.border ? `fo:padding="${round3(decor.border.paddingCm)}cm"` : '',
|
||
decor.background ? `fo:background-color="${decor.background}"` : '',
|
||
].filter(Boolean).join(' ');
|
||
if (decorProps) {
|
||
styles = styles.replace(/<style:page-layout-properties /, `<style:page-layout-properties ${decorProps} `);
|
||
}
|
||
|
||
// Line numbering is one document-wide element in office:styles (probed). LibreOffice
|
||
// writes only what differs from the ODF defaults, so an unnumbered document has none.
|
||
if (lineNumbering.on) {
|
||
const cfg = `<text:linenumbering-configuration text:number-lines="true"`
|
||
+ ` text:offset="${round3(lineNumbering.distanceCm)}cm" style:num-format="1"`
|
||
+ ` text:number-position="left" text:increment="${lineNumbering.interval}"`
|
||
+ ` text:count-empty-lines="${lineNumbering.countEmpty}"`
|
||
+ ` text:restart-on-page="${lineNumbering.restart === 'page'}"/>`;
|
||
styles = styles.includes('</office:styles>')
|
||
? styles.replace('</office:styles>', `${cfg}</office:styles>`)
|
||
: styles.replace(/<office:automatic-styles\b/, `<office:styles>${cfg}</office:styles><office:automatic-styles`);
|
||
}
|
||
|
||
// How the page-number field counts. ODF keeps the format on the page layout; the start
|
||
// value is a property of the document's first paragraph (applyPageNumberStart).
|
||
if (pageNumbering.format !== '1') {
|
||
styles = styles.replace(
|
||
/<style:page-layout-properties /,
|
||
`<style:page-layout-properties style:num-format="${pageNumbering.format}" `,
|
||
);
|
||
}
|
||
|
||
// Document spell-check language: set fo:language/fo:country on the base
|
||
// Standard paragraph style, which every paragraph inherits from. LibreOffice
|
||
// and Word read this as the document default language.
|
||
if (lang) {
|
||
styles = styles.replace(
|
||
/(<style:style style:name="Standard"[\s\S]*?<style:text-properties\b[^>]*?)\/>/,
|
||
`$1 fo:language="${lang.language}" fo:country="${lang.country}"/>`,
|
||
);
|
||
}
|
||
|
||
// Automatic hyphenation. ODF counts fo:hyphenate as a *text* property, so it sits
|
||
// beside the language on the style every paragraph inherits from — probed: in
|
||
// paragraph-properties LibreOffice ignores it and drops it on the next save. Its two
|
||
// limits ride along at LibreOffice's own defaults.
|
||
if (hyphenate) {
|
||
styles = styles.replace(
|
||
/(<style:style style:name="Standard"[\s\S]*?<style:text-properties\b[^>]*?)\/>/,
|
||
'$1 fo:hyphenate="true" fo:hyphenation-remain-char-count="2" fo:hyphenation-push-char-count="2"/>',
|
||
);
|
||
}
|
||
|
||
// The only occurrences of "Liberation Serif" in styles.xml are the default
|
||
// font-face declaration and the Standard style's font-name attributes.
|
||
styles = styles.split(ODFKIT_DEFAULT_FONT).join(EXPORT_FONT);
|
||
|
||
styles = styles.replace(
|
||
'<style:master-page style:name="Default"',
|
||
'<style:master-page style:name="Standard"',
|
||
);
|
||
|
||
// The document's tab interval, on the paragraph default-style. Always written: a file
|
||
// that declares none falls back to ODF's own 2cm, which is not what the editor shows
|
||
// (measured — LibreOffice puts a bare A⇥X at 2cm, not at its UI default of 1.25).
|
||
styles = styles.replace('<office:styles>', '<office:styles>'
|
||
+ `<style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="${round3(tabIntervalCm)}cm"/></style:default-style>`
|
||
+ notesStyles() + notesConfiguration(notes));
|
||
|
||
// The separator belongs to the page layout, beside the margins it is measured against.
|
||
styles = styles.replace(
|
||
/<style:page-layout-properties\b[^>]*\/>/,
|
||
(props) => `${props.slice(0, -2)}>${footnoteSepXml(notes.separator)}</style:page-layout-properties>`,
|
||
);
|
||
|
||
// odf-kit's Standard style carries fo:margin-bottom="0.212cm", but the editor (like
|
||
// LibreOffice and Word) has no paragraph spacing by default — every paragraph and list
|
||
// item inherits this, so zero it or the export gains ~6pt per block vs. the preview.
|
||
styles = styles.replace(
|
||
/(<style:style style:name="Standard"[\s\S]*?<style:paragraph-properties[^>]*?)fo:margin-bottom="[^"]*"/,
|
||
`$1fo:margin-bottom="0cm"`,
|
||
);
|
||
|
||
|
||
// ODF requires a font-face declaration for every referenced font name.
|
||
styles = styles.replace(
|
||
'</office:font-face-decls>',
|
||
`<style:font-face style:name="${HEADING_FONT}" svg:font-family="${HEADING_FONT}" style:font-family-generic="swiss" style:font-pitch="variable"/></office:font-face-decls>`,
|
||
);
|
||
|
||
// The document's named paragraph styles (Standard, Heading, Heading_20_N, Title, …)
|
||
// with their parent chain: merged into odf-kit's own blocks, appended when new.
|
||
styles = applyNamedStyles(styles, sheet, used, usedTables);
|
||
|
||
files['styles.xml'] = strToU8(styles);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// ODF requires fo:color as #RRGGBB. TipTap may store hex (color picker) or rgb(r,g,b)
|
||
// after an HTML round-trip. Anything not valid hex is silently dropped by
|
||
// Word/LibreOffice → text renders black, so coerce it here. Also used on import.
|
||
export function normalizeColor(input: string): string | undefined {
|
||
const s = input.trim();
|
||
if (!s) return undefined;
|
||
|
||
const hex = s.match(/^#([0-9a-fA-F]{3,8})$/);
|
||
if (hex) {
|
||
const h = hex[1];
|
||
if (h.length === 3 || h.length === 4) {
|
||
return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`.toUpperCase();
|
||
}
|
||
if (h.length === 6 || h.length === 8) {
|
||
return `#${h.slice(0, 6)}`.toUpperCase();
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
const rgb = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*[\d.]+\s*)?\)$/i);
|
||
if (rgb) {
|
||
const toHex = (v: string) => {
|
||
const n = Math.max(0, Math.min(255, Math.round(parseFloat(v))));
|
||
return n.toString(16).padStart(2, '0');
|
||
};
|
||
return `#${toHex(rgb[1])}${toHex(rgb[2])}${toHex(rgb[3])}`.toUpperCase();
|
||
}
|
||
|
||
// Named colors (red, blue, …): pass through — odf-kit resolves them.
|
||
return s;
|
||
}
|
||
|
||
// Translate a TipTap mark set into odf-kit TextFormatting (bold/italic/underline,
|
||
// font family/size, colour, highlight). Shared by body runs and header/footer runs.
|
||
function formattingFromMarks(marks: TiptapNode['marks'] = []): TextFormatting {
|
||
const tsm = marks.find(m => m.type === 'textStyle');
|
||
const fmt: TextFormatting = {};
|
||
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
|
||
// Explicit weight (e.g. un-bolding heading text) overrides the bold shortcut.
|
||
if (tsm?.attrs?.fontWeight) {
|
||
const fw = String(tsm.attrs.fontWeight);
|
||
fmt.fontWeight = (/^\d+$/.test(fw) ? Number(fw) : fw) as TextFormatting['fontWeight'];
|
||
}
|
||
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
|
||
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
|
||
if (marks.some(m => m.type === 'strike')) fmt.strikethrough = true;
|
||
if (marks.some(m => m.type === 'superscript')) fmt.superscript = true;
|
||
else if (marks.some(m => m.type === 'subscript')) fmt.subscript = true;
|
||
if (tsm?.attrs?.fontFamily) {
|
||
const ff = String(tsm.attrs.fontFamily);
|
||
// Explicitly choosing the editor default should match the untagged
|
||
// default, which resolves to EXPORT_FONT via the Standard style.
|
||
fmt.fontFamily = ff === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : ff;
|
||
}
|
||
if (tsm?.attrs?.fontSize) fmt.fontSize = String(tsm.attrs.fontSize);
|
||
if (tsm?.attrs?.color) {
|
||
const c = normalizeColor(String(tsm.attrs.color));
|
||
if (c) fmt.color = c;
|
||
}
|
||
// Text highlight (background). odf-kit maps highlightColor → fo:background-color
|
||
// natively for normal paragraphs; this covers the custom-attr-paragraph path
|
||
// (CUST_P/CUST_H), which bypasses odf-kit's own mark handling.
|
||
const hl = marks.find(m => m.type === 'highlight');
|
||
if (hl?.attrs?.color) {
|
||
const c = normalizeColor(String(hl.attrs.color));
|
||
if (c) fmt.highlightColor = c;
|
||
}
|
||
return fmt;
|
||
}
|
||
|
||
// Hyperlink target of a run, if any. odf-kit's native run path handles the link mark
|
||
// itself; this covers the custom emitters (CUST_P/_H, cells) that bypass it.
|
||
function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined {
|
||
const href = marks.find(m => m.type === 'link')?.attrs?.href;
|
||
return href ? String(href) : undefined;
|
||
}
|
||
|
||
// The sheet of the export in flight, so the run emitters can resolve character styles
|
||
// without threading it through every cell/list helper (mirrors docLangTag in docx.ts).
|
||
let exportSheet: StyleSheet = builtinStyleSheet();
|
||
let exportSpacingModel: SpacingModel = 'add';
|
||
|
||
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
|
||
// `force` bakes formatting onto every run regardless of marks — for header/region cells,
|
||
// whose formatting is presentational (CSS) in the editor and so isn't stored as marks.
|
||
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [], force: TextProps = {}) {
|
||
const forced = Object.keys(force).length ? textFormatting(force) : null;
|
||
for (const node of content) {
|
||
if (node.type !== 'text' || !node.text) continue;
|
||
const fmt = formattingFromMarks(node.marks);
|
||
// A cell's header/region formatting is presentational (CSS) in the editor, so bake it
|
||
// under the run's own marks; an explicit un-bold (fontWeight:normal) still wins.
|
||
if (forced) {
|
||
const direct = { ...fmt };
|
||
Object.assign(fmt, forced, direct);
|
||
if (!direct.bold && fmt.fontWeight === 'normal') delete fmt.bold;
|
||
// A link keeps its own paint: LibreOffice's Internet Link style and the editor's
|
||
// a-rule both outrank the region color, so the bake must not override it.
|
||
if (!direct.color && node.marks?.some((m) => m.type === 'link' && !m.attrs?.plain)) {
|
||
delete fmt.color;
|
||
}
|
||
}
|
||
// A named character style: bake its resolved formatting (so odf-kit always mints a
|
||
// span) and mark the run, so applyCharacterStyles can re-point that span at the style.
|
||
const charStyle = charStyleOf(node.marks);
|
||
let prefix = '';
|
||
if (charStyle) {
|
||
// The style is the base, the run's own marks stay on top.
|
||
const direct = { ...fmt };
|
||
Object.assign(fmt, charFormatting(exportSheet, charStyle), direct);
|
||
prefix = `${CST}${charStyle}${CST}`;
|
||
}
|
||
const f = Object.keys(fmt).length ? fmt : undefined;
|
||
const href = linkHrefOf(node.marks);
|
||
if (href) p.addLink(prefix + node.text, href, f);
|
||
else p.addText(prefix + node.text, f);
|
||
}
|
||
}
|
||
|
||
// CSS line styles → ODF's own names for the same shapes.
|
||
const ODF_LINE_STYLE: Record<string, string> = { dotted: 'dotted', dashed: 'dash', wavy: 'wave' };
|
||
|
||
// The effects ODF carries but odf-kit's run formatting has no field for. Returned as
|
||
// the <style:text-properties> attributes applyTextEffects folds into the run's style.
|
||
export function odfExtraTextProps(marks: TiptapNode['marks'] = [], baseSizePt = DEFAULT_FONT_SIZE_PT): string {
|
||
const a: string[] = [];
|
||
const caps = marks.find(m => m.type === 'textStyle')?.attrs?.caps;
|
||
if (caps === 'smallCaps') a.push('fo:font-variant="small-caps"');
|
||
else if (caps === 'uppercase' || caps === 'lowercase' || caps === 'capitalize') a.push(`fo:text-transform="${caps}"`);
|
||
const u = marks.find(m => m.type === 'underline')?.attrs;
|
||
if (u?.lineStyle === 'double') a.push('style:text-underline-type="double"');
|
||
else if (typeof u?.lineStyle === 'string' && ODF_LINE_STYLE[u.lineStyle]) {
|
||
a.push(`style:text-underline-style="${ODF_LINE_STYLE[u.lineStyle]}"`);
|
||
}
|
||
if (u?.lineColor) {
|
||
const c = normalizeColor(String(u.lineColor));
|
||
if (c) a.push(`style:text-underline-color="${c}"`);
|
||
}
|
||
if (marks.find(m => m.type === 'strike')?.attrs?.lineStyle === 'double') {
|
||
a.push('style:text-line-through-type="double"');
|
||
}
|
||
// ODF places a raised run in percent of its font size, Word and the editor in pt.
|
||
const pos = marks.find(m => m.type === 'textStyle')?.attrs?.textPosition;
|
||
if (typeof pos === 'number' && pos) {
|
||
a.push(`style:text-position="${Math.round((pos / runSizePt(marks, baseSizePt)) * 10000) / 100}% 100%"`);
|
||
}
|
||
return a.join(' ');
|
||
}
|
||
|
||
// The run's own size where it declares one, else the block's (a heading run resolves to
|
||
// the level's size) — the reference for the percentage above; import mirrors this.
|
||
function runSizePt(marks: TiptapNode['marks'], baseSizePt: number): number {
|
||
const size = marks?.find(m => m.type === 'textStyle')?.attrs?.fontSize;
|
||
const pt = size ? parseFloat(String(size)) : NaN;
|
||
return Number.isFinite(pt) && pt > 0 ? pt : baseSizePt;
|
||
}
|
||
|
||
function charStyleOf(marks: TiptapNode['marks']): string | null {
|
||
const name = marks?.find(m => m.type === 'charStyle')?.attrs?.name;
|
||
return typeof name === 'string' && name ? name : null;
|
||
}
|
||
|
||
// A character style's resolved props as odf-kit run formatting (baked so the file still
|
||
// looks right for readers that ignore the style reference).
|
||
function charFormatting(sheet: StyleSheet, name: string): Record<string, unknown> {
|
||
return textFormatting(resolveStyle(sheet, name, 'character').text);
|
||
}
|
||
|
||
// Prefix the sentinel on every run carrying such an effect, wherever it sits — odf-kit
|
||
// reads marks itself on its native paths, so the pre-pass is what reaches all of them.
|
||
function markTextEffects(node: TiptapNode, baseSizePt = DEFAULT_FONT_SIZE_PT, sheet?: StyleSheet): TiptapNode {
|
||
if (node.type === 'text') {
|
||
const extra = odfExtraTextProps(node.marks, baseSizePt);
|
||
// The empty trailing pair marks where the run ends: a bare run following would
|
||
// otherwise be indistinguishable from the run's own text and get wrapped with it.
|
||
return extra ? { ...node, text: `${TEF}${extra}${TEF}${node.text ?? ''}${TEF}${TEF}` } : node;
|
||
}
|
||
if (!node.content?.length) return node;
|
||
// A named style's size is the reference for a raised run's percentage (a Title's
|
||
// -2pt is a smaller share of its 28pt); import resolves against the same chain.
|
||
const named = sheet && typeof node.attrs?.styleName === 'string'
|
||
? resolveStyle(sheet, node.attrs.styleName, 'paragraph').text.fontSizePt : null;
|
||
const base = node.type === 'heading' || node.type === CUST_H
|
||
? parseFloat(HEADING_STYLE_OVERRIDES[Math.min(Math.max(Number(node.attrs?.level) || 1, 1), HEADING_STYLE_OVERRIDES.length) - 1].fontSize)
|
||
: named ?? baseSizePt;
|
||
return { ...node, content: node.content.map((c) => markTextEffects(c, base, sheet)) };
|
||
}
|
||
|
||
// odf-kit's list builder reads a run's marks itself and knows nothing about charStyle,
|
||
// and applyRuns never sees a list paragraph — so do its two jobs here: bake the style
|
||
// into direct marks (the run then gets a span) and prefix the CST sentinel for it.
|
||
function bakeListCharStyles(node: TiptapNode, sheet: StyleSheet, inList = false): TiptapNode {
|
||
if (node.type === 'text') {
|
||
const name = inList ? charStyleOf(node.marks) : null;
|
||
if (!name) return node;
|
||
return {
|
||
...node,
|
||
text: `${CST}${name}${CST}${node.text ?? ''}`,
|
||
marks: bakeMarks(node.marks ?? [], resolveStyle(sheet, name, 'character').text),
|
||
};
|
||
}
|
||
if (!node.content?.length) return node;
|
||
const nested = inList || node.type === 'bulletList' || node.type === 'orderedList';
|
||
return { ...node, content: node.content.map(c => bakeListCharStyles(c, sheet, nested)) };
|
||
}
|
||
|
||
// The style's props as direct marks, with the run's own marks on top (as in applyRuns).
|
||
function bakeMarks(marks: TiptapMark[], t: TextProps): TiptapMark[] {
|
||
const out = marks.filter(m => m.type !== 'textStyle');
|
||
const own = marks.find(m => m.type === 'textStyle')?.attrs ?? {};
|
||
const attrs: Record<string, unknown> = {};
|
||
if (t.fontFamily) attrs.fontFamily = t.fontFamily;
|
||
if (t.fontSizePt != null) attrs.fontSize = `${t.fontSizePt}pt`;
|
||
if (t.color) attrs.color = t.color;
|
||
for (const [key, value] of Object.entries(own)) if (value != null) attrs[key] = value;
|
||
// An explicit weight is the un-bold channel and outranks the style's bold.
|
||
const flags = [[t.bold && own.fontWeight == null, 'bold'], [t.italic, 'italic'],
|
||
[t.underline, 'underline'], [t.strike, 'strike']] as const;
|
||
for (const [on, type] of flags) if (on && !marks.some(m => m.type === type)) out.push({ type });
|
||
if (Object.keys(attrs).length) out.push({ type: 'textStyle', attrs });
|
||
return out;
|
||
}
|
||
|
||
// TextProps as odf-kit run formatting.
|
||
function textFormatting(t: TextProps): Record<string, unknown> {
|
||
const fmt: Record<string, unknown> = {};
|
||
if (t.bold != null) fmt.bold = t.bold;
|
||
if (t.italic != null) fmt.italic = t.italic;
|
||
if (t.underline) fmt.underline = true;
|
||
if (t.strike) fmt.strikethrough = true;
|
||
if (t.fontFamily) fmt.fontFamily = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : t.fontFamily;
|
||
if (t.fontSizePt != null) fmt.fontSize = `${t.fontSizePt}pt`;
|
||
if (t.color) fmt.color = t.color;
|
||
return fmt;
|
||
}
|
||
|
||
// Resolve CST sentinels: point each marked run's span at its named character style
|
||
// (a clone of the automatic style whose parent becomes the named one), then strip it.
|
||
function applyCharacterStyles(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(CST)) return odtBytes;
|
||
|
||
const minted: string[] = [];
|
||
const nameByKey = new Map<string, string>();
|
||
let counter = 0;
|
||
|
||
const styleFor = (source: string, styleName: string): string => {
|
||
const odfName = odfStyleName(styleName);
|
||
const auto = source ? findAutoStyle(content, source) : null;
|
||
if (!auto) return odfName;
|
||
const key = `${source}|${odfName}`;
|
||
const existing = nameByKey.get(key);
|
||
if (existing) return existing;
|
||
const name = `TS${++counter}`;
|
||
nameByKey.set(key, name);
|
||
minted.push(
|
||
auto
|
||
.replace(/style:name="[^"]*"/, `style:name="${name}"`)
|
||
.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) => setTagAttrs(tag, { 'style:parent-style-name': odfName })),
|
||
);
|
||
return name;
|
||
};
|
||
|
||
const cstRe = new RegExp(`${CST}([^${CST}]*)${CST}`);
|
||
content = content.replace(
|
||
new RegExp(`<text:span\\b((?:[^>]*[^/>])?)>([\\s\\S]*?)</text:span>`, 'g'),
|
||
(m, attrs: string, inner: string) => {
|
||
const sm = cstRe.exec(inner);
|
||
if (!sm) return m;
|
||
const cleaned = inner.replace(cstRe, '');
|
||
const srcM = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = styleFor(srcM ? srcM[1] : '', sm[1]);
|
||
const newAttrs = srcM
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:span${newAttrs}>${cleaned}</text:span>`;
|
||
},
|
||
);
|
||
content = content.replace(new RegExp(`${CST}[^${CST}]*${CST}`, 'g'), '');
|
||
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Merge each TEF sentinel's effects into that run's own automatic text style (cloned,
|
||
// since the style is shared) and wrap a run that got no span in one. Shared by
|
||
// content.xml (body) and styles.xml (header/footer), which mint their styles separately.
|
||
function resolveTextEffects(xml: string, mint: (styleXml: string) => void, prefix: string): string {
|
||
if (!xml.includes(TEF)) return xml;
|
||
const nameByKey = new Map<string, string>();
|
||
const styleFor = (source: string, extra: string): string => {
|
||
const key = `${source}|${extra}`;
|
||
const existing = nameByKey.get(key);
|
||
if (existing) return existing;
|
||
const name = `${prefix}${nameByKey.size + 1}`;
|
||
nameByKey.set(key, name);
|
||
const auto = source ? findAutoStyle(xml, source) : null;
|
||
const names = [...extra.matchAll(/([\w:.-]+)=/g)].map(m => m[1]);
|
||
mint(auto
|
||
? auto.replace(/style:name="[^"]*"/, `style:name="${name}"`)
|
||
.replace(/<style:text-properties\b([^>]*?)(\/?)>/, (_m, attrs: string, slash: string) => {
|
||
const kept = names.reduce((acc, n) => acc.replace(new RegExp(`\\s${n}="[^"]*"`, 'g'), ''), attrs);
|
||
return `<style:text-properties${kept} ${extra}${slash}>`;
|
||
})
|
||
: `<style:style style:name="${name}" style:family="text"><style:text-properties ${extra}/></style:style>`);
|
||
return name;
|
||
};
|
||
|
||
const tefRe = new RegExp(`${TEF}([^${TEF}]*)${TEF}`);
|
||
let out = xml.replace(
|
||
new RegExp(`<text:span\\b((?:[^>]*[^/>])?)>([\\s\\S]*?)</text:span>`, 'g'),
|
||
(m, attrs: string, inner: string) => {
|
||
const sm = tefRe.exec(inner);
|
||
if (!sm) return m;
|
||
const srcM = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const name = styleFor(srcM ? srcM[1] : '', sm[1]);
|
||
const newAttrs = srcM
|
||
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: ` text:style-name="${name}"${attrs}`;
|
||
return `<text:span${newAttrs}>${inner.replace(new RegExp(tefRe.source, 'g'), '')}</text:span>`;
|
||
},
|
||
);
|
||
// A run whose only formatting is such an effect never got a span: give it one, up to
|
||
// the next real element — a tab/line-break/space, a bookmark marker or a comment's
|
||
// annotation inside the run rides along, or the text behind it would fall out of the
|
||
// span and lose the effect.
|
||
// The annotation block is tempered to its first close tag, and (?=[\s>]) keeps it off
|
||
// <office:annotation-end (\b sits before the hyphen) — either slip would stretch one
|
||
// annotation across the next one's body and misplace the minted span's close.
|
||
const inline = `<text:(?:tab|line-break|s|bookmark-start|bookmark-end)\\b[^>]*/>`
|
||
+ `|<office:annotation(?=[\\s>])(?:(?!</office:annotation>)[\\s\\S])*</office:annotation>`
|
||
+ `|<office:annotation-end\\b[^>]*/>`;
|
||
out = out.replace(
|
||
new RegExp(`${TEF}([^${TEF}]+)${TEF}((?:[^<${TEF}]|${inline})*)(?:${TEF}${TEF})?`, 'g'),
|
||
(_m, extra: string, text: string) => `<text:span text:style-name="${styleFor('', extra)}">${text}</text:span>`,
|
||
);
|
||
// Drop any sentinels not consumed above (defensive — never legitimate text).
|
||
return out.split(TEF).join('');
|
||
}
|
||
|
||
function applyTextEffects(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
const before = strFromU8(contentBytes);
|
||
const minted: string[] = [];
|
||
const content = resolveTextEffects(before, (s) => minted.push(s), 'TE');
|
||
if (content === before) return odtBytes;
|
||
|
||
files['content.xml'] = strToU8(injectAutomaticStyles(content, minted.join('')));
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Emit the header/footer paragraph into odf-kit's HeaderFooterBuilder. hardBreak
|
||
// and pageCount ride as sentinels (LBR / PGC-wrapped digits) and are rewritten to
|
||
// <text:line-break/> / <text:page-count> in applyHfPostProcess (styles.xml).
|
||
function applyHfRuns(b: HeaderFooterBuilder, para: TiptapNode, pageCount: number): void {
|
||
for (const node of para.content ?? []) {
|
||
const fmt = formattingFromMarks(node.marks);
|
||
const f = Object.keys(fmt).length ? fmt : undefined;
|
||
if (node.type === 'text' && node.text) b.addText(node.text, f);
|
||
else if (node.type === 'hardBreak') b.addText(LBR);
|
||
else if (node.type === 'pageNumber') b.addPageNumber(f);
|
||
else if (node.type === 'pageCount') b.addText(`${PGC}${pageCount}${PGC}`, f);
|
||
else if (node.type === 'chapterField') b.addText(chapterSentinel(node), f);
|
||
}
|
||
}
|
||
|
||
// CHP<level>CHP<cached name>CHP — the cached name is what a reader shows before it
|
||
// resolves the field itself, so it rides along like LibreOffice writes it.
|
||
function chapterSentinel(node: TiptapNode): string {
|
||
const level = Number(node.attrs?.level) || 1;
|
||
return `${CHP}${level}${CHP}${String(node.attrs?.text ?? '')}${CHP}`;
|
||
}
|
||
|
||
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in lockstep,
|
||
// return a CellBlock[] descriptor. odf-kit serializes the runs into one <text:p>;
|
||
// applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
|
||
|
||
// A segment is one paragraph, heading, or list item's paragraph; exactly one SEG
|
||
// between consecutive segments, so splitting yields one piece per segment in DFS order.
|
||
// Neither word processor passes the default style's spacing into a cell (LibreOffice's Table
|
||
// Contents zeroes both, Word's table styles the space below), and editor.css renders it that
|
||
// way — so the file says so too. A paragraph's own spacing still wins.
|
||
function cellParaStyle(attrs: TiptapNode['attrs']): ParaStyle {
|
||
const style = paraStyleFromAttrs(attrs);
|
||
if (attrs?.styleName) return style;
|
||
const def = resolveStyle(exportSheet, DEFAULT_STYLE).para;
|
||
if (!def.spaceBefore && !def.spaceAfter) return style;
|
||
const spaceBefore = exportSpacingModel === 'max' ? style.spaceBefore : style.spaceBefore ?? 0;
|
||
return { ...style, spaceBefore, spaceAfter: style.spaceAfter ?? 0 };
|
||
}
|
||
|
||
function buildCellContent(cell: TiptapNode, c: CellBuilder, force: TextProps = {}): CellBlock[] {
|
||
const blocks: CellBlock[] = [];
|
||
const state = { emitted: false }; // whether any segment has been emitted yet
|
||
|
||
const emitSegment = (content: TiptapNode[] | undefined) => {
|
||
if (state.emitted) c.addText(SEG);
|
||
state.emitted = true;
|
||
applyRuns(c, content ?? [], force);
|
||
};
|
||
|
||
const walkList = (listNode: TiptapNode): CellListBlock => {
|
||
const ordered = listNode.type === 'orderedList';
|
||
const start = ordered ? ((listNode.attrs?.start as number) ?? null) : null;
|
||
const items: CellListItem[] = [];
|
||
for (const item of listNode.content ?? []) {
|
||
if (item.type !== 'listItem') continue;
|
||
const firstPara = item.content?.find(x => x.type === 'paragraph');
|
||
emitSegment(firstPara?.content); // one segment per item, in DFS order
|
||
const nested = item.content?.find(x => x.type === 'bulletList' || x.type === 'orderedList');
|
||
items.push({ style: paraStyleFromAttrs(firstPara?.attrs, false), nested: nested ? walkList(nested) : null });
|
||
}
|
||
// Raw attrs; applyCellBlocks resolves the depth defaults when it knows the depth.
|
||
const listStyleType = ordered && typeof listNode.attrs?.listStyleType === 'string' ? (listNode.attrs.listStyleType as string) : null;
|
||
const bulletChar = !ordered && typeof listNode.attrs?.bulletChar === 'string' ? (listNode.attrs.bulletChar as string) : null;
|
||
return { kind: 'list', ordered, start, listStyleType, bulletChar, items };
|
||
};
|
||
|
||
for (const block of cell.content ?? []) {
|
||
if (block.type === 'paragraph') {
|
||
emitSegment(block.content);
|
||
blocks.push({ kind: 'paragraph', style: cellParaStyle(block.attrs) });
|
||
} else if (block.type === 'heading') {
|
||
emitSegment(block.content);
|
||
blocks.push({ kind: 'heading', level: (block.attrs?.level as number) ?? 1, style: paraStyleFromAttrs(block.attrs) });
|
||
} else if (block.type === 'bulletList' || block.type === 'orderedList') {
|
||
blocks.push(walkList(block));
|
||
}
|
||
}
|
||
|
||
// Empty cell: odf-kit emits an empty <text:p/>; record one empty paragraph so
|
||
// applyCellBlocks stays aligned and leaves it untouched.
|
||
if (blocks.length === 0) {
|
||
blocks.push({ kind: 'paragraph', style: cellParaStyle(undefined) });
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
// Per-column widths (cm) from the table's first row: the editor's proportional
|
||
// `colwidth` weights (tableView.ts) turned into absolute cm summing to the text width.
|
||
// With table:align="margins" this matches the preview. undefined → odf-kit even split.
|
||
function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[] | undefined {
|
||
const firstRow = (node.content ?? []).find(r => r.type === 'tableRow');
|
||
if (!firstRow) return undefined;
|
||
|
||
const weights: (number | null)[] = [];
|
||
for (const cell of firstRow.content ?? []) {
|
||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||
const cw = cell.attrs?.colwidth as number[] | null | undefined;
|
||
for (let k = 0; k < colspan; k++) weights.push(cw && cw[k] ? cw[k] : null);
|
||
}
|
||
if (weights.length === 0 || weights.every(w => w == null)) return undefined;
|
||
|
||
const present = weights.filter((w): w is number => w != null);
|
||
const avg = present.reduce((a, b) => a + b, 0) / present.length;
|
||
const filled = weights.map(w => (w != null ? w : avg));
|
||
const total = filled.reduce((a, b) => a + b, 0);
|
||
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const cm = filled.map(w => round3((w / total) * contentWidthCm));
|
||
// Absorb the rounding remainder into the last column so the widths sum exactly
|
||
// to the text width (table:align="margins" scales otherwise).
|
||
const sum = cm.reduce((a, b) => a + b, 0);
|
||
cm[cm.length - 1] = round3(cm[cm.length - 1] + (contentWidthCm - sum));
|
||
return cm.map(v => `${v}cm`);
|
||
}
|
||
|
||
// What rides on a table's own automatic style: its left/right margins (cm, from the
|
||
// outer-edge drag, clamped into the text width) and whether a page break may fall
|
||
// between its rows. null = a full-width table that may break, the common case.
|
||
function tablePropsOf(node: TiptapNode, contentWidthCm: number): TableProps | null {
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0);
|
||
let mr = Math.max(0, Number(node.attrs?.marginRight) || 0);
|
||
const mt = Math.max(0, Number(node.attrs?.marginTop) || 0);
|
||
const mb = Math.max(0, Number(node.attrs?.marginBottom) || 0);
|
||
const keepRows = node.attrs?.keepRows === true;
|
||
const repeatHeader = node.attrs?.repeatHeader === true;
|
||
if (ml + mr > contentWidthCm - 1) ml = mr = 0;
|
||
if (!ml && !mr && !mt && !mb && !keepRows && !repeatHeader) return null;
|
||
return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb), keepRows, repeatHeader };
|
||
}
|
||
|
||
// A formula cell as ODF writes it: LibreOffice's own formula language behind its
|
||
// `ooow:` prefix, plus the cached result as the cell's value.
|
||
type CellFormulaExport = { formula: string; value: number | null; format: CellFormat | null };
|
||
|
||
function ensureOoowNamespace(content: string): string {
|
||
if (content.includes('xmlns:ooow=')) return content;
|
||
return content.replace(/<office:document-content\b/, '<office:document-content xmlns:ooow="http://openoffice.org/2004/writer"');
|
||
}
|
||
|
||
// One entry per real cell in document order (covered cells carry no formula and never
|
||
// match the regex), the same walk applyCellBlocks makes.
|
||
function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | null)[], lang: string): Uint8Array {
|
||
if (!cells.some(Boolean)) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let idx = 0;
|
||
// A number format lives on the cell *style*, and odf-kit shares one style across
|
||
// every cell — so a formatted cell gets a copy of it carrying the data style.
|
||
const minted: string[] = [];
|
||
const derived = new Map<string, string>();
|
||
let source = strFromU8(contentBytes);
|
||
const styleFor = (base: string, format: CellFormat): string => {
|
||
const key = `${base}|${format}`;
|
||
const existing = derived.get(key);
|
||
if (existing) return existing;
|
||
const name = `Cf${derived.size + 1}`;
|
||
const dataName = `Ncell${derived.size + 1}`;
|
||
derived.set(key, name);
|
||
minted.push(odfCellNumberStyle(format, dataName, lang));
|
||
const props = new RegExp(`<style:style style:name="${base}" style:family="table-cell"[^>]*>([\\s\\S]*?)</style:style>`)
|
||
.exec(source)?.[1] ?? '';
|
||
minted.push(
|
||
`<style:style style:name="${name}" style:family="table-cell" style:data-style-name="${dataName}">`
|
||
+ `${props}</style:style>`,
|
||
);
|
||
return name;
|
||
};
|
||
|
||
let content = source.replace(
|
||
/<table:table-cell\b([^>]*?)(\/?)>/g,
|
||
(match, attrs: string, selfClose: string) => {
|
||
const cell = cells[idx++];
|
||
if (!cell) return match;
|
||
const value = cell.value == null ? '' : ` office:value-type="float" office:value="${cell.value}"`;
|
||
const base = /table:style-name="([^"]+)"/.exec(attrs)?.[1];
|
||
const styled = cell.format && base
|
||
? attrs.replace(`table:style-name="${base}"`, `table:style-name="${styleFor(base, cell.format)}"`)
|
||
: attrs;
|
||
return `<table:table-cell${styled} table:formula="ooow:${escapeXml(cell.formula)}"${value}${selfClose}>`;
|
||
},
|
||
);
|
||
if (minted.length) content = ensureNumberNamespace(injectAutomaticStyles(content, minted.join('')));
|
||
// `ooow:` in the value is a namespace prefix, so the reader resolves it against the
|
||
// declarations: undeclared, LibreOffice reads the prefix as part of the formula and
|
||
// prints "Expression is faulty" (probed).
|
||
files['content.xml'] = strToU8(ensureOoowNamespace(content));
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The data style a formatted cell points at: how many decimals, whether the thousands
|
||
// are grouped, and the style family — percentage, currency and date each have their own.
|
||
function odfCellNumberStyle(format: CellFormat, name: string, lang: string): string {
|
||
const spec = CELL_FORMAT_SPECS[format];
|
||
const [code, country] = lang.split('-');
|
||
// The style names the document's language: without it LibreOffice prints the
|
||
// separators and the symbol of its own locale, not the ones the editor shows (probed).
|
||
const at = `style:name="${name}" number:language="${code}"${country ? ` number:country="${country}"` : ''}`;
|
||
if (spec.kind === 'date') return odfDateStyle(at, lang);
|
||
const number = `<number:number number:decimal-places="${spec.decimals}"`
|
||
+ ` number:min-decimal-places="${spec.decimals}" number:min-integer-digits="1"`
|
||
+ `${spec.grouping ? ' number:grouping="true"' : ''}/>`;
|
||
if (spec.kind === 'percent') {
|
||
return `<number:percentage-style ${at}>${number}`
|
||
+ `<number:text>%</number:text></number:percentage-style>`;
|
||
}
|
||
if (spec.kind === 'currency') {
|
||
const { symbol, before, space } = currencyParts(lang);
|
||
const sym = `<number:currency-symbol number:language="${code}"`
|
||
+ `${country ? ` number:country="${country}"` : ''}>${escapeXml(symbol)}</number:currency-symbol>`;
|
||
const gap = space ? `<number:text>${escapeXml(space)}</number:text>` : '';
|
||
return `<number:currency-style ${at}>`
|
||
+ (before ? `${sym}${gap}${number}` : `${number}${gap}${sym}`)
|
||
+ `</number:currency-style>`;
|
||
}
|
||
return `<number:number-style ${at}>${number}</number:number-style>`;
|
||
}
|
||
|
||
// The locale's short date as ODF spells it: a `long` field is the zero-padded one, a
|
||
// long year the four-digit one.
|
||
function odfDateStyle(at: string, lang: string): string {
|
||
const body = datePattern(lang).map((f) => {
|
||
if (f.kind === 'literal') return `<number:text>${escapeXml(f.text)}</number:text>`;
|
||
const long = f.kind === 'year' ? f.digits >= 4 : f.digits >= 2;
|
||
return `<number:${f.kind}${long ? ' number:style="long"' : ''}/>`;
|
||
}).join('');
|
||
return `<number:date-style ${at}>${body}</number:date-style>`;
|
||
}
|
||
|
||
// A table's grid: where every real cell sits and what each slot reads, merged cells
|
||
// filling the box they span — so a formula's A1 is the cell the editor calls A1.
|
||
type CellGridJson = {
|
||
at: Map<TiptapNode, CellRef>;
|
||
rows: number;
|
||
cols: number;
|
||
text: (ref: CellRef) => string;
|
||
};
|
||
|
||
function cellText(node: TiptapNode): string {
|
||
if (node.type === 'text') return node.text ?? '';
|
||
return (node.content ?? []).map(cellText).join('');
|
||
}
|
||
|
||
function tableGridJson(rows: TiptapNode[]): CellGridJson {
|
||
const slots: (string | undefined)[][] = [];
|
||
const at = new Map<TiptapNode, CellRef>();
|
||
rows.forEach((row, r) => {
|
||
slots[r] ??= [];
|
||
let c = 0;
|
||
for (const cell of row.content ?? []) {
|
||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||
while (slots[r][c] !== undefined) c++;
|
||
at.set(cell, { row: r, col: c });
|
||
const text = cellText(cell);
|
||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
|
||
for (let dr = 0; dr < rowspan; dr++) {
|
||
slots[r + dr] ??= [];
|
||
for (let dc = 0; dc < colspan; dc++) slots[r + dr][c + dc] = text;
|
||
}
|
||
c += colspan;
|
||
}
|
||
});
|
||
return {
|
||
at,
|
||
rows: rows.length,
|
||
cols: Math.max(0, ...slots.map((s) => s.length)),
|
||
text: (ref) => slots[ref.row]?.[ref.col] ?? '',
|
||
};
|
||
}
|
||
|
||
// The cell's formula in LibreOffice's own language, plus the result it caches.
|
||
function cellFormulaOf(cell: TiptapNode, grid: CellGridJson, loc: NumberLocale): CellFormulaExport | null {
|
||
const formula = typeof cell.attrs?.formula === 'string' ? cell.attrs.formula : '';
|
||
const self = grid.at.get(cell);
|
||
if (!formula || !self) return null;
|
||
const ctx = {
|
||
rows: grid.rows, cols: grid.cols, self,
|
||
valueAt: (ref: CellRef) => parseCellNumber(grid.text(ref), loc),
|
||
};
|
||
return {
|
||
formula: toWriterFormula(formula, ctx),
|
||
value: parseCellNumber(grid.text(self), loc),
|
||
format: isCellFormat(cell.attrs?.cellFormat) ? cell.attrs.cellFormat : null,
|
||
};
|
||
}
|
||
|
||
// Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to
|
||
// pass an explicit cell border (the native path emits none → invisible). Column widths
|
||
// come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly.
|
||
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableProps | null)[], tableStyleNames: (TableStyleRef | null)[], cellFormulas: (CellFormulaExport | null)[], numberLoc: NumberLocale): void {
|
||
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
|
||
if (rows.length === 0) return;
|
||
const grid = tableGridJson(rows);
|
||
const margins = tablePropsOf(node, contentWidthCm);
|
||
tableMargins.push(margins);
|
||
// The named table style, if the registry still knows it: its name goes on the table's
|
||
// automatic style (applyTableStyleNames), its text formatting is baked per cell.
|
||
const styleName = typeof node.attrs?.tableStyle === 'string' ? node.attrs.tableStyle : null;
|
||
const tableStyle = styleName ? exportSheet.table?.[styleName] : undefined;
|
||
const look = parseTableLook(node.attrs?.tableLook);
|
||
tableStyleNames.push(tableStyle && styleName ? { name: styleName, look } : null);
|
||
const columnWidths = tableColumnWidthsCm(node, contentWidthCm - (margins ? margins.ml + margins.mr : 0));
|
||
// Cell margins ride on every cell as a TRBL shorthand (ODF has no table-level one);
|
||
// expandCellPadding splits it, since odf-kit only writes fo:padding and ODF only
|
||
// allows one length there. A cell's own attr overrides the table's.
|
||
const tablePad = parseCellPadding(node.attrs?.cellPadding) ?? DEFAULT_CELL_PADDING;
|
||
const padValue = (p: CellPadding) => p.map(n => `${n}cm`).join(' ');
|
||
doc.addTable((t: TableBuilder) => {
|
||
for (const row of rows) {
|
||
t.addRow((r: RowBuilder) => {
|
||
for (const cell of row.content ?? []) {
|
||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||
// Emit the cell's runs (SEG-separated) and record its descriptor; addCell is
|
||
// synchronous, so the push stays in document order, matching applyCellBlocks'
|
||
// walk. Spans make odf-kit emit covered cells, which its cell regex never matches.
|
||
const opts: CellOptions = { padding: padValue(parseCellPadding(cell.attrs?.cellPadding) ?? tablePad) };
|
||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
|
||
if (colspan > 1) opts.colSpan = colspan;
|
||
if (rowspan > 1) opts.rowSpan = rowspan;
|
||
// Cell shading → fo:background-color (odf-kit mints the cell style).
|
||
const bg = cell.attrs?.backgroundColor;
|
||
if (typeof bg === 'string') {
|
||
const c = normalizeColor(bg);
|
||
if (c) opts.backgroundColor = c;
|
||
}
|
||
// Content set against the middle/bottom of the box → style:vertical-align.
|
||
const va = cell.attrs?.verticalAlign;
|
||
if (va === 'middle' || va === 'bottom') opts.verticalAlign = va;
|
||
// Per-side borders → fo:border-* (null = the table default border below).
|
||
for (const side of BORDER_SIDES) {
|
||
const b = parseBorderAttr(cell.attrs?.[side] as string | null);
|
||
if (b === 'none') opts[side] = 'none';
|
||
else if (b) opts[side] = `${b.widthPt}pt solid ${normalizeColor(b.color) ?? b.color}`;
|
||
}
|
||
// Header cells and a table style's regions render bold/font via CSS
|
||
// (presentational), so bake that onto the runs for Word/LibreOffice.
|
||
const force = regionText(tableStyle, cell.attrs?.region);
|
||
if (bg === HEADER_SHADE) force.bold = true;
|
||
cellFormulas.push(cellFormulaOf(cell, grid, numberLoc));
|
||
r.addCell((c: CellBuilder) => {
|
||
cellBlocks.push(buildCellContent(cell, c, force));
|
||
}, opts);
|
||
}
|
||
});
|
||
}
|
||
}, columnWidths ? { border: TABLE_BORDER, columnWidths } : { border: TABLE_BORDER });
|
||
}
|
||
|
||
// odf-kit joins a multi-child element's children with "\n"; inside a <text:p>/<text:h>
|
||
// that newline is significant and collapses to a space, adding a spurious space at any
|
||
// mid-word run boundary. Strip the bare "\n" separators (real spaces sit adjacent, kept).
|
||
function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(
|
||
/<text:(p|h)\b(?:[^>]*[^/>])?>[\s\S]*?<\/text:\1>/g,
|
||
(block) => block.replace(/\n/g, ''),
|
||
);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Rewrite the inline sentinels planted before serialization into real ODF elements:
|
||
// LBR → <text:line-break/>, TAB → <text:tab/> (both valid as bare paragraph text and
|
||
// inside <text:span>), and complete the decimal stop odf-kit only half-writes.
|
||
const CHAR_TAB_STOP = 'style:type="char"';
|
||
|
||
// The ODF line kind that goes with a fill character.
|
||
const ODF_LEADER_STYLE: Record<string, string> = { '.': 'dotted', '·': 'dotted', '-': 'dash', '_': 'solid' };
|
||
|
||
// odf-kit has no leader option and mints one automatic style per distinct option set,
|
||
// so the leader rides style:type — where a bare map would give two stops that agree on
|
||
// position and alignment the same style, and with it the same leader.
|
||
function applyTabLeaders(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(LEAD)) return odtBytes;
|
||
content = content.replace(new RegExp(`style:type="([a-z]+)${LEAD}(.)"`, 'g'), (_m, type, ch) =>
|
||
`style:type="${type}" style:leader-style="${ODF_LEADER_STYLE[ch]}" style:leader-text="${ch}"`);
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(LBR) && !content.includes(TAB) && !content.includes(CHAR_TAB_STOP)) return odtBytes;
|
||
|
||
content = content.split(LBR).join('<text:line-break/>').split(TAB).join('<text:tab/>');
|
||
content = content.split(CHAR_TAB_STOP).join(`${CHAR_TAB_STOP} style:char="."`);
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// ODF draw:transform for a rotated frame/shape. ODF rotate() is CCW radians (ours is
|
||
// CW degrees) about the origin, so the translate re-centres it on the unrotated box.
|
||
function frameTransform(rotationDeg: number, widthCm: number, heightCm: number): string {
|
||
if (!rotationDeg || !widthCm || !heightCm) return '';
|
||
const a = (-rotationDeg * Math.PI) / 180;
|
||
const cw = widthCm / 2;
|
||
const ch = heightCm / 2;
|
||
const cos = Math.cos(a);
|
||
const sin = Math.sin(a);
|
||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const tx = r3(cos * cw - sin * ch - cw);
|
||
const ty = r3(sin * cw + cos * ch - ch);
|
||
return ` draw:transform="rotate (${a.toFixed(6)}) translate (${tx}cm ${ty}cm)"`;
|
||
}
|
||
|
||
function imageTransform(img: ImageExport): string {
|
||
return frameTransform(img.rotationDeg, img.widthCm, img.heightCm);
|
||
}
|
||
|
||
// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos
|
||
// places the frame on that side. topBottom ⇒ no wrap, placed at noneAlign — centred
|
||
// for an image, but a box with no align of its own sits at the left, as the editor
|
||
// draws it. An offset frame is placed by coordinate instead (svg:x on the frame).
|
||
function imageWrapProps(wrap: WrapMode, offset: number | null, align?: string | null, distCm?: number | null, noneAlign = 'center'): string {
|
||
const pos = offset != null ? 'from-left' : null;
|
||
// Only the text side is written; the frame's own offset covers the other one.
|
||
const gap = distCm ? ` fo:margin-${wrap === 'right' ? 'left' : 'right'}="${distCm}cm"` : '';
|
||
if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"${gap}`;
|
||
if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"${gap}`;
|
||
return `style:wrap="none" style:horizontal-pos="${align ?? pos ?? noneAlign}"`;
|
||
}
|
||
|
||
// The inverse of the importer's as-char alignment map (import/odt.ts): a frame with no
|
||
// entry stands on the baseline, which is what a style-less as-char frame does anyway.
|
||
const INLINE_VALIGN_ODF: Record<string, string> = {
|
||
middle: 'style:vertical-pos="middle" style:vertical-rel="baseline"',
|
||
below: 'style:vertical-pos="bottom" style:vertical-rel="baseline"',
|
||
'text-top': 'style:vertical-pos="top" style:vertical-rel="text"',
|
||
'text-middle': 'style:vertical-pos="middle" style:vertical-rel="text"',
|
||
'text-bottom': 'style:vertical-pos="bottom" style:vertical-rel="text"',
|
||
offset: 'style:vertical-pos="from-top" style:vertical-rel="text"',
|
||
};
|
||
|
||
// Graphic style for a floating frame (wrap + side, anchored to the paragraph top).
|
||
// Inline images need none. Injected into content.xml automatic-styles by applyImages.
|
||
function imageGraphicStyle(img: ImageExport, index: number): string {
|
||
if (img.anchorPage) {
|
||
return (
|
||
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
|
||
`<style:graphic-properties style:wrap="run-through" style:run-through="${img.inFront ? 'foreground' : 'background'}"` +
|
||
` style:horizontal-rel="page" style:horizontal-pos="from-left"` +
|
||
` style:vertical-rel="page" style:vertical-pos="from-top"/></style:style>`
|
||
);
|
||
}
|
||
if (img.wrap === 'inline') {
|
||
const v = INLINE_VALIGN_ODF[img.vAlign ?? ''];
|
||
return v
|
||
? `<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
|
||
`<style:graphic-properties style:wrap="none" ${v}/></style:style>`
|
||
: '';
|
||
}
|
||
return (
|
||
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
|
||
`<style:graphic-properties ${imageWrapProps(img.wrap, img.wrapOffsetCm, img.wrapAlign, img.wrapDistCm)}` +
|
||
` style:number-wrapped-paragraphs="no-limit"` +
|
||
` style:horizontal-rel="paragraph-content"` +
|
||
` style:vertical-pos="${img.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"/>` +
|
||
`</style:style>`
|
||
);
|
||
}
|
||
|
||
// One <draw:frame>. Inline = as-character (text-flow anchor). Floating = paragraph
|
||
// anchor + a graphic style (wrap + side). Size is the exact svg geometry and rotation
|
||
// the draw:transform, so all of it round-trips.
|
||
function imageFrameXml(img: ImageExport, index: number): string {
|
||
const dims =
|
||
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
|
||
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
|
||
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
|
||
const inner = `<draw:image xlink:href="${img.path}"/>${title}`;
|
||
const floats = img.anchorPage != null || img.wrap !== 'inline';
|
||
const anchor = img.anchorPage != null
|
||
? ` text:anchor-type="page" text:anchor-page-number="${img.anchorPage}"`
|
||
: ` text:anchor-type="${floats ? 'paragraph' : 'as-char'}"`;
|
||
const named = floats || (img.vAlign != null && img.vAlign in INLINE_VALIGN_ODF);
|
||
const styleName = named ? ` draw:style-name="ImgFr${index + 1}"` : '';
|
||
const x = img.wrapOffsetCm != null && floats && !img.wrapAlign ? ` svg:x="${img.wrapOffsetCm}cm"` : '';
|
||
// An as-char frame carries svg:y only for the offset alignment, which is what it means.
|
||
const y = img.wrapOffsetYCm != null && (floats || img.vAlign === 'offset')
|
||
? ` svg:y="${img.wrapOffsetYCm}cm"` : '';
|
||
return (
|
||
`<draw:frame draw:name="Image${index + 1}"${styleName}${anchor} draw:z-index="${index}"${dims}${x}${y}${imageTransform(img)}>` +
|
||
`${inner}</draw:frame>`
|
||
);
|
||
}
|
||
|
||
// <draw:frame> for a header/footer image. As-character by default; a positioned frame
|
||
// (a page-anchored background) instead rides the zone paragraph at its page corner
|
||
// offset, run through behind the text. Distinct draw:name per frame.
|
||
function hfImageFrameXml(img: ImageExport, index: number): string {
|
||
const dims =
|
||
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
|
||
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
|
||
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
|
||
const anchor =
|
||
img.wrap === 'inline'
|
||
? `text:anchor-type="as-char"`
|
||
: `text:anchor-type="paragraph" draw:style-name="${HF_BG_STYLE}${index + 1}"` +
|
||
` svg:x="${img.wrapOffsetCm ?? 0}cm" svg:y="${img.wrapOffsetYCm ?? 0}cm"`;
|
||
return (
|
||
`<draw:frame draw:name="HfImage${index + 1}" ${anchor} draw:z-index="${index}"${dims}>` +
|
||
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
|
||
);
|
||
}
|
||
|
||
const HF_BG_STYLE = 'HfBg';
|
||
|
||
// The graphic style a page-anchored zone frame points at: positioned from the page's
|
||
// top-left corner and run through behind the text, as LibreOffice writes a watermark.
|
||
function hfBackgroundStyleXml(index: number): string {
|
||
return (
|
||
`<style:style style:name="${HF_BG_STYLE}${index + 1}" style:family="graphic">` +
|
||
`<style:graphic-properties style:wrap="run-through" style:run-through="background"` +
|
||
` style:horizontal-rel="page" style:horizontal-pos="from-left"` +
|
||
` style:vertical-rel="page" style:vertical-pos="from-top"/></style:style>`
|
||
);
|
||
}
|
||
|
||
// Inject automatic styles into content.xml or styles.xml, tolerating an empty/self-closed
|
||
// or absent section — a document odf-kit gave none writes `<office:automatic-styles/>`, and
|
||
// a plain string replace on the closing tag would drop the styles there.
|
||
function injectAutomaticStyles(content: string, styles: string): string {
|
||
if (!styles) return content;
|
||
if (content.includes('</office:automatic-styles>')) {
|
||
return content.replace('</office:automatic-styles>', `${styles}</office:automatic-styles>`);
|
||
}
|
||
const section = `<office:automatic-styles>${styles}</office:automatic-styles>`;
|
||
if (content.includes('<office:automatic-styles/>')) {
|
||
return content.replace('<office:automatic-styles/>', section);
|
||
}
|
||
return content.replace(/<office:(body|master-styles)\b/, (m) => `${section}${m}`);
|
||
}
|
||
|
||
// Resolve image sentinels: swap each IMG{i}IMG for its <draw:frame>, add the binary
|
||
// picture files, and register each in META-INF/manifest.xml. content.xml already
|
||
// declares the draw/svg/xlink namespaces and rezipOdt handles binary entries.
|
||
function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
|
||
if (!images.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${IMG}(\\d+)${IMG}`, 'g'), (_m, idx: string) => {
|
||
const i = Number(idx);
|
||
const img = images[i];
|
||
return img ? imageFrameXml(img, i) : '';
|
||
});
|
||
// Graphic styles for the floating frames.
|
||
content = injectAutomaticStyles(content, images.map((img, i) => imageGraphicStyle(img, i)).join(''));
|
||
files['content.xml'] = strToU8(content);
|
||
|
||
for (const img of images) files[img.path] = img.bytes;
|
||
|
||
const manifestBytes = files['META-INF/manifest.xml'];
|
||
if (manifestBytes) {
|
||
const entries = images
|
||
.map(img => `<manifest:file-entry manifest:full-path="${img.path}" manifest:media-type="${img.mimeType}"/>`)
|
||
.join('');
|
||
const manifest = strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`);
|
||
files['META-INF/manifest.xml'] = strToU8(manifest);
|
||
}
|
||
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The formula sub-document: an ODF formula document's content.xml is the MathML root
|
||
// itself. The LaTeX rides in an annotation so our own files re-import exactly; other
|
||
// readers ignore it and typeset the presentation markup.
|
||
function formulaObjectXml(f: FormulaExport): string {
|
||
return `<?xml version="1.0" encoding="UTF-8"?>${mathmlDocument(parseLatex(f.latex), f.display, f.latex)}`;
|
||
}
|
||
|
||
// Resolve formula sentinels: swap each MTH{i}MTH for a <draw:frame> pointing at an
|
||
// embedded formula object, write that object's content.xml, and register both it and
|
||
// its directory in META-INF/manifest.xml — the same shape as applyImages.
|
||
function applyFormulas(odtBytes: Uint8Array, formulas: FormulaExport[]): Uint8Array {
|
||
if (!formulas.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${MTH}(\\d+)${MTH}`, 'g'), (_m, idx: string) => {
|
||
const i = Number(idx);
|
||
const f = formulas[i];
|
||
if (!f) return '';
|
||
// No svg:width/height on purpose: with a size the consumer scales the object to
|
||
// fit it (LibreOffice magnifies a too-wide frame); without one it typesets the
|
||
// formula at its natural size, matched to the surrounding text.
|
||
return (
|
||
`<draw:frame draw:name="Formula${i + 1}" draw:style-name="MthFr" text:anchor-type="as-char" draw:z-index="${i}">` +
|
||
`<draw:object xlink:href="./Formula${i + 1}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame>`
|
||
);
|
||
});
|
||
// One shared graphic style: an as-char formula sits on the text baseline, which is
|
||
// what LibreOffice writes for its own formula frames.
|
||
content = injectAutomaticStyles(content,
|
||
'<style:style style:name="MthFr" style:family="graphic">' +
|
||
'<style:graphic-properties style:vertical-pos="middle" style:vertical-rel="text"' +
|
||
' fo:padding="0cm" fo:border="none"/></style:style>');
|
||
files['content.xml'] = strToU8(content);
|
||
|
||
for (const [i, f] of formulas.entries()) {
|
||
files[`Formula${i + 1}/content.xml`] = strToU8(formulaObjectXml(f));
|
||
}
|
||
|
||
const manifestBytes = files['META-INF/manifest.xml'];
|
||
if (manifestBytes) {
|
||
const entries = formulas
|
||
.map((_f, i) =>
|
||
`<manifest:file-entry manifest:full-path="Formula${i + 1}/" manifest:media-type="application/vnd.oasis.opendocument.formula"/>` +
|
||
`<manifest:file-entry manifest:full-path="Formula${i + 1}/content.xml" manifest:media-type="text/xml"/>`)
|
||
.join('');
|
||
files['META-INF/manifest.xml'] = strToU8(
|
||
strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`));
|
||
}
|
||
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Ensure content.xml declares the number namespace (odf-kit may omit it); the minted
|
||
// <number:date-style>/<number:time-style> and their references need the prefix.
|
||
function ensureNumberNamespace(content: string): string {
|
||
if (content.includes('xmlns:number=')) return content;
|
||
return content.replace(
|
||
/<office:document-content\b/,
|
||
'<office:document-content xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"',
|
||
);
|
||
}
|
||
|
||
// Resolve date/time field sentinels: swap each DTF{i}DTF for a <text:date>/<text:time>
|
||
// carrying the displayed value, the ISO date/time-value, text:fixed, and a minted
|
||
// data style. Auto fields render (and store) the current moment; fixed ones use `value`.
|
||
function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[], lang: { language: string; country: string } | null): Uint8Array {
|
||
if (!fields.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
const tag = localeTag(lang ? `${lang.language}` : 'en');
|
||
// One data style per distinct format actually used; reference it by minted name.
|
||
const styleNames = new Map<string, string>();
|
||
const mintedStyles: string[] = [];
|
||
const styleFor = (fmt: DtFormat): string => {
|
||
const existing = styleNames.get(fmt.key);
|
||
if (existing) return existing;
|
||
const name = `Ndt${styleNames.size + 1}`;
|
||
styleNames.set(fmt.key, name);
|
||
mintedStyles.push(odfNumberStyle(fmt, name, lang));
|
||
return name;
|
||
};
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(new RegExp(`${DTF}(\\d+)${DTF}`, 'g'), (_m, idx: string) => {
|
||
const field = fields[Number(idx)];
|
||
if (!field) return '';
|
||
const fmt = findFormat(field.format)
|
||
?? findFormat(field.kind === 'time' ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT)!;
|
||
const parsed = field.fixed && field.value ? new Date(field.value) : new Date();
|
||
const when = isNaN(parsed.getTime()) ? new Date() : parsed;
|
||
const display = escapeXml(renderFormat(fmt, when, tag));
|
||
const styleName = styleFor(fmt);
|
||
const fixedAttr = ` text:fixed="${field.fixed ? 'true' : 'false'}"`;
|
||
if (fmt.kind === 'time') {
|
||
return `<text:time text:time-value="${toTimeValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
|
||
}
|
||
return `<text:date text:date-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:date>`;
|
||
});
|
||
|
||
content = ensureNumberNamespace(content);
|
||
content = injectAutomaticStyles(content, mintedStyles.join(''));
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Graphic style for a text box / shape: fill, stroke, text padding, auto-grow, and —
|
||
// for floating boxes — the same wrap/position props as floating images.
|
||
function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const fill = box.fill
|
||
? `draw:fill="solid" draw:fill-color="${normalizeColor(box.fill) ?? '#FFFFFF'}"`
|
||
: 'draw:fill="none"';
|
||
const stroke = box.stroke
|
||
? `draw:stroke="solid" svg:stroke-color="${normalizeColor(box.stroke) ?? '#000000'}" svg:stroke-width="${r3(box.strokeWidthPt)}pt"`
|
||
: 'draw:stroke="none"';
|
||
// An as-char frame keeps only its horizontal-pos: that is what centres a figure
|
||
// frame, and the anchor paragraph this export mints carries no alignment.
|
||
const wrap = box.wrap === 'inline'
|
||
? (box.wrapAlign ? ` style:horizontal-pos="${box.wrapAlign}" style:horizontal-rel="paragraph-content"` : '')
|
||
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"` +
|
||
` style:horizontal-rel="paragraph-content"` +
|
||
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
|
||
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
|
||
// false, or LibreOffice's shape autofit shrinks it to its text.
|
||
const grow = box.shapeKind === 'textbox' && !box.shapePath
|
||
? ' draw:auto-grow-height="true"'
|
||
: ' draw:auto-grow-height="false" draw:auto-grow-width="false"';
|
||
// An arrow head is a named marker in ODF, defined once in styles.xml.
|
||
const heads = SHAPES[box.shapeKind].line;
|
||
const marker = (side: 'start' | 'end') =>
|
||
` draw:marker-${side}="${ODF_ARROW}" draw:marker-${side}-width="${arrowHeadCm(box.strokeWidthPt)}cm"`;
|
||
const arrows = heads === 'end' ? marker('end')
|
||
: heads === 'both' ? marker('end') + marker('start') : '';
|
||
// Vertical text is a *paragraph* property of the frame's style — probed: in the
|
||
// graphic properties LibreOffice drops it, here it keeps it and lays the box out.
|
||
const vertical = box.textVertical
|
||
? '<style:paragraph-properties style:writing-mode="tb-rl"/>'
|
||
: '';
|
||
return (
|
||
`<style:style style:name="TbxFr${index + 1}" style:family="graphic">` +
|
||
`<style:graphic-properties ${fill} ${stroke}${arrows} fo:padding="${box.paddingCm}cm"` +
|
||
`${grow} draw:textarea-vertical-align="top"${wrap}/>${vertical}` +
|
||
`</style:style>`
|
||
);
|
||
}
|
||
|
||
// The anchor paragraph carries the spacing the box stands in for (import/odt.ts hands a
|
||
// lifted box its anchor's margins); nothing to mint when it has none.
|
||
function textBoxAnchorStyle(box: TextBoxExport, index: number): string {
|
||
if (!box.spaceBeforePt && !box.spaceAfterPt) return '';
|
||
return (
|
||
`<style:style style:name="TbxP${index + 1}" style:family="paragraph" style:parent-style-name="Standard">` +
|
||
`<style:paragraph-properties fo:margin-top="${box.spaceBeforePt}pt" fo:margin-bottom="${box.spaceAfterPt}pt"/>` +
|
||
`</style:style>`
|
||
);
|
||
}
|
||
|
||
// The drawing element wrapping a box's serialized blocks: a <draw:frame>/<draw:text-box>
|
||
// for plain text boxes (height = fo:min-height, so it grows with content like the
|
||
// editor), or a <draw:custom-shape> with the preset geometry of `utils/shapes.ts`.
|
||
function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
|
||
const n = index + 1;
|
||
const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph';
|
||
const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm);
|
||
const at = box.wrap === 'inline' ? ''
|
||
: (box.wrapOffsetCm != null ? ` svg:x="${box.wrapOffsetCm}cm"` : '') +
|
||
(box.wrapOffsetYCm != null ? ` svg:y="${box.wrapOffsetYCm}cm"` : '');
|
||
const common =
|
||
` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` +
|
||
` svg:width="${box.widthCm}cm"${at}`;
|
||
// A line is its two endpoints, so ODF gives it its own element rather than a frame
|
||
// with a width and a height. The blocks the schema keeps on the node are dropped:
|
||
// <draw:line> holds no text, and the editor draws none.
|
||
if (isLineKind(box.shapeKind)) {
|
||
const [y1, y2] = box.flipV ? [box.heightCm, 0] : [0, box.heightCm];
|
||
return (
|
||
`<draw:line draw:name="Line${n}"${common.replace(/ svg:width="[^"]*"/, '')}` +
|
||
` svg:x1="0cm" svg:y1="${y1}cm" svg:x2="${box.widthCm}cm" svg:y2="${y2}cm"/>`
|
||
);
|
||
}
|
||
if (box.shapeKind === 'textbox' && !box.shapePath) {
|
||
// svg:height for consumers without auto-grow; fo:min-height is the real semantic
|
||
// (height = minimum, content grows the box) and wins on our own re-import.
|
||
return (
|
||
`<draw:frame draw:name="TextBox${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
|
||
`<draw:text-box fo:min-height="${box.heightCm}cm">${inner}</draw:text-box></draw:frame>`
|
||
);
|
||
}
|
||
// A freeform is its own outline rather than a preset's: `non-primitive` plus the
|
||
// path, which is what LibreOffice writes back out unchanged (probed).
|
||
const geometry = box.shapePath
|
||
? `<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="non-primitive"`
|
||
+ ` draw:enhanced-path="${odfEnhancedPath(box.shapePath)}"/>`
|
||
: odfEnhancedGeometry(box.shapeKind) ?? '';
|
||
return (
|
||
`<draw:custom-shape draw:name="Shape${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
|
||
`${inner}${geometry}</draw:custom-shape>`
|
||
);
|
||
}
|
||
|
||
// LibreOffice's own standard arrow head, name and path alike, so a file we write and
|
||
// one it writes carry the same marker rather than two that look the same.
|
||
const ODF_ARROW = 'Arrow';
|
||
const ODF_ARROW_MARKER = `<draw:marker draw:name="${ODF_ARROW}" svg:viewBox="0 0 20 30" svg:d="m10 0-10 30h20z"/>`;
|
||
|
||
// Resolve the text-box marker paragraphs: wrap each S{i}…E{i} region (the box's
|
||
// hoisted, fully serialized blocks) into its drawing element inside a fresh anchor
|
||
// paragraph, and inject the minted graphic styles.
|
||
function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Array {
|
||
if (!boxes.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(
|
||
new RegExp(`<text:p\\b[^>]*>${TBX}S(\\d+)${TBX}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${TBX}E\\1${TBX}</text:p>`, 'g'),
|
||
(_m, idx: string, inner: string) => {
|
||
const i = Number(idx);
|
||
const box = boxes[i];
|
||
if (!box) return '';
|
||
const style = box.spaceBeforePt || box.spaceAfterPt ? `TbxP${i + 1}` : 'Standard';
|
||
return `<text:p text:style-name="${style}">${textBoxXml(box, inner, i)}</text:p>`;
|
||
},
|
||
);
|
||
content = injectAutomaticStyles(
|
||
content,
|
||
boxes.map((b, i) => textBoxGraphicStyle(b, i) + textBoxAnchorStyle(b, i)).join(''),
|
||
);
|
||
files['content.xml'] = strToU8(content);
|
||
// An arrow head is referenced by name, so its one definition goes where LibreOffice
|
||
// keeps its own — office:styles in styles.xml — and only when a line asks for it.
|
||
const stylesBytes = files['styles.xml'];
|
||
if (stylesBytes && boxes.some((b) => SHAPES[b.shapeKind].line && SHAPES[b.shapeKind].line !== 'none')) {
|
||
const styles = ensureDrawNamespaces(strFromU8(stylesBytes));
|
||
if (!styles.includes(`draw:name="${ODF_ARROW}"`)) {
|
||
files['styles.xml'] = strToU8(styles.replace(/<office:styles(\s[^>]*)?>/, (m) => `${m}${ODF_ARROW_MARKER}`));
|
||
}
|
||
}
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Section style for a multi-column region: balanced columns with a uniform gap.
|
||
// text:dont-balance-text-columns sits on <style:columns> (not section-properties).
|
||
function columnsSectionStyle(cols: ColumnsExport, index: number): string {
|
||
return (
|
||
`<style:style style:name="ColSec${index + 1}" style:family="section">` +
|
||
`<style:section-properties style:editable="false">` +
|
||
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"` +
|
||
` text:dont-balance-text-columns="false"/>` +
|
||
`</style:section-properties></style:style>`
|
||
);
|
||
}
|
||
|
||
// Resolve the columns marker paragraphs: wrap each S{i}…E{i} region (the section's
|
||
// hoisted, fully serialized blocks) into a <text:section> — a block-level element,
|
||
// so no anchor paragraph — and inject the minted section styles.
|
||
function applyColumns(odtBytes: Uint8Array, cols: ColumnsExport[]): Uint8Array {
|
||
if (!cols.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
content = content.replace(
|
||
new RegExp(`<text:p\\b[^>]*>${COL}S(\\d+)${COL}</text:p>([\\s\\S]*?)<text:p\\b[^>]*>${COL}E\\1${COL}</text:p>`, 'g'),
|
||
(_m, idx: string, inner: string) => {
|
||
const i = Number(idx);
|
||
if (!cols[i]) return '';
|
||
return `<text:section text:style-name="ColSec${i + 1}" text:name="ColumnsSection${i + 1}">${inner}</text:section>`;
|
||
},
|
||
);
|
||
content = injectAutomaticStyles(content, cols.map((c, i) => columnsSectionStyle(c, i)).join(''));
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Minted automatic paragraph style for a TOC entry level: per-level left indent and a
|
||
// right tab stop at the text width with a dotted leader (so the page number right-aligns
|
||
// with dots, matching the on-screen TOC and Word/LibreOffice).
|
||
// The note styles LibreOffice ships, written verbatim so a re-save changes nothing:
|
||
// Footnote/Endnote at 10pt with a 0.6cm hanging indent, and the two character styles
|
||
// its notes-configuration points at (the anchor raised, the symbol plain).
|
||
function notesStyles(): string {
|
||
const para = (name: string) =>
|
||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard" style:class="extra">`
|
||
+ `<style:paragraph-properties fo:margin-left="${NOTE_INDENT_CM}cm" fo:text-indent="-${NOTE_INDENT_CM}cm" style:auto-text-indent="false"/>`
|
||
+ `<style:text-properties fo:font-size="${NOTE_FONT_SIZE_PT}pt" style:font-size-asian="${NOTE_FONT_SIZE_PT}pt" style:font-size-complex="${NOTE_FONT_SIZE_PT}pt"/>`
|
||
+ '</style:style>';
|
||
const anchor = (name: string, display: string) =>
|
||
`<style:style style:name="${name}" style:display-name="${display}" style:family="text">`
|
||
+ '<style:text-properties style:text-position="super 58%"/></style:style>';
|
||
return para('Footnote') + para('Endnote')
|
||
+ `<style:style style:name="Footnote_20_Symbol" style:display-name="Footnote Symbol" style:family="text"/>`
|
||
+ `<style:style style:name="Endnote_20_Symbol" style:display-name="Endnote Symbol" style:family="text"/>`
|
||
+ anchor('Footnote_20_anchor', 'Footnote anchor')
|
||
+ anchor('Endnote_20_anchor', 'Endnote anchor');
|
||
}
|
||
|
||
// <text:notes-configuration> per class. ODF counts start-value from 0, so the first
|
||
// note of a class carries startAt - 1.
|
||
function notesConfiguration(settings: NoteSettings): string {
|
||
const one = (kind: NoteKind) => {
|
||
const s = settings[kind];
|
||
const foot = kind === 'footnote';
|
||
const prefix = s.prefix ? ` style:num-prefix="${escapeXml(s.prefix)}"` : '';
|
||
const suffix = s.suffix ? ` style:num-suffix="${escapeXml(s.suffix)}"` : '';
|
||
return `<text:notes-configuration text:note-class="${kind}"`
|
||
+ ` text:citation-style-name="${odfStyleName(s.citationStyle)}"`
|
||
+ ` text:citation-body-style-name="${foot ? 'Footnote_20_anchor' : 'Endnote_20_anchor'}"`
|
||
+ ` text:default-style-name="${odfStyleName(s.bodyStyle)}"`
|
||
+ ` style:num-format="${s.numFormat}"${prefix}${suffix}`
|
||
+ ` text:start-value="${Math.max(0, s.startAt - 1)}"`
|
||
+ (foot ? ` text:footnotes-position="${s.position}" text:start-numbering-at="${s.restart}"` : '')
|
||
+ '/>';
|
||
};
|
||
return one('footnote') + one('endnote');
|
||
}
|
||
|
||
// <style:footnote-sep> lives inside the page layout's own properties, not the styles.
|
||
function footnoteSepXml(sep: NoteSettings['separator']): string {
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
return `<style:footnote-sep style:width="${round3((sep.weightPt / 72) * 2.54)}cm"`
|
||
+ ` style:distance-before-sep="${round3(sep.spaceAboveCm)}cm"`
|
||
+ ` style:distance-after-sep="${round3(sep.spaceBelowCm)}cm"`
|
||
+ ` style:line-style="solid" style:adjustment="${sep.align}"`
|
||
+ ` style:rel-width="${round3(sep.relWidthPercent)}%" style:color="${sep.color}"/>`;
|
||
}
|
||
|
||
function contentsEntryStyle(name: string, level: number, tabPosCm: number): string {
|
||
const indentCm = level === 2 ? 0.6 : level === 3 ? 1.2 : 0;
|
||
const margin = indentCm > 0 ? ` fo:margin-left="${indentCm}cm"` : '';
|
||
return (
|
||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard">` +
|
||
`<style:paragraph-properties${margin}>` +
|
||
`<style:tab-stops><style:tab-stop style:position="${tabPosCm}cm" style:type="right" style:leader-style="dotted" style:leader-text="."/></style:tab-stops>` +
|
||
`</style:paragraph-properties></style:style>`
|
||
);
|
||
}
|
||
|
||
// The index title style (bold, 16pt) — mirrors the .toc-title on screen.
|
||
function contentsHeadingStyle(name: string): string {
|
||
return (
|
||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard">` +
|
||
`<style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.3cm"/>` +
|
||
`<style:text-properties fo:font-size="16pt" fo:font-weight="bold"/></style:style>`
|
||
);
|
||
}
|
||
|
||
// A level's entry style: the file's own named one where the index came with it, else the
|
||
// automatic Contents_20_N this export mints.
|
||
function tocLevelStyle(toc: TocExport, level: number): string {
|
||
const own = toc.levelStyles?.[level - 1];
|
||
return own ? odfStyleName(own) : `${ODF_INDEX[toc.kind].entryStyle}${level}`;
|
||
}
|
||
|
||
// ODF has one element family per index. A caption index is single-level and finds its
|
||
// entries by the counter's name, so its template carries no text:outline-level.
|
||
const ODF_INDEX: Record<IndexKind, { el: string; heading: string; entryStyle: string; docName: string; seq?: string }> = {
|
||
toc: { el: 'table-of-content', heading: 'Contents_20_Heading', entryStyle: 'Contents_20_', docName: 'Table of Contents' },
|
||
figures: { el: 'illustration-index', heading: 'Illustration_20_Index_20_Heading', entryStyle: 'Illustration_20_Index_20_', docName: 'Illustration Index', seq: 'Illustration' },
|
||
tables: { el: 'table-index', heading: 'Table_20_Index_20_Heading', entryStyle: 'Table_20_Index_20_', docName: 'Table Index', seq: 'Table' },
|
||
alphabetical: { el: 'alphabetical-index', heading: 'Index_20_Heading', entryStyle: 'Index_20_', docName: 'Alphabetical Index' },
|
||
bibliography: { el: 'bibliography', heading: 'Bibliography_20_Heading', entryStyle: 'Bibliography_20_', docName: 'Bibliography' },
|
||
};
|
||
|
||
// A bibliography's entry template is per source type, and LibreOffice regenerates the
|
||
// rows from it — so it spells out the citation style's own row, token for token (which
|
||
// is what an ODF template is). Only the types actually cited get one; the rest
|
||
// LibreOffice fills in itself.
|
||
function bibEntryTemplates(style: string, types: string[], cite: CitationStyle): string {
|
||
const body = rowTemplate(cite).map(t => ('field' in t
|
||
? `<text:index-entry-bibliography text:bibliography-data-field="${t.field}"/>`
|
||
: `<text:index-entry-span>${escapeXml(t.text)}</text:index-entry-span>`)).join('');
|
||
return (types.length ? types : ['book'])
|
||
.map(t => `<text:bibliography-entry-template text:bibliography-type="${t}" text:style-name="${style}">${body}</text:bibliography-entry-template>`)
|
||
.join('');
|
||
}
|
||
|
||
function tocXml(toc: TocExport, index: number, bibTypes: string[]): string {
|
||
const spec = ODF_INDEX[toc.kind];
|
||
const title = toc.title ?? INDEX_TITLES[toc.kind];
|
||
const name = `${spec.docName}${index + 1}`;
|
||
const stop =
|
||
`<text:index-entry-tab-stop style:type="right"` +
|
||
`${toc.tabPosCm ? ` style:position="${toc.tabPosCm}cm"` : ''}` +
|
||
`${toc.leader ? ` style:leader-char="${escapeXml(toc.leader)}"` : ''}/>`;
|
||
const entry = (levelAttr: string, style: string) =>
|
||
`<text:${spec.el}-entry-template${levelAttr} text:style-name="${style}">` +
|
||
`<text:index-entry-link-start/><text:index-entry-text/>${stop}` +
|
||
`<text:index-entry-page-number/><text:index-entry-link-end/>` +
|
||
`</text:${spec.el}-entry-template>`;
|
||
// An alphabetical index is fed by its marks, is single-level, and merges the pages of
|
||
// a term the reader marked more than once — LibreOffice's text:combine-entries.
|
||
const sourceAttrs = toc.kind === 'bibliography'
|
||
// LibreOffice's own two flags for a numbered bibliography: [1] before each row, and
|
||
// the rows in the order the document cites them rather than sorted.
|
||
? (toc.citationStyle === 'numbered' ? ' text:numbered-entries="true" text:sort-by-position="true"' : '')
|
||
: toc.kind === 'alphabetical'
|
||
? ' text:combine-entries="true" text:ignore-case="true"'
|
||
: spec.seq
|
||
? ` text:use-caption="true" text:caption-sequence-name="${spec.seq}" text:caption-sequence-format="category-and-value"`
|
||
: ` text:outline-level="${toc.maxLevel}" text:use-index-marks="false" text:use-index-source-styles="false"`;
|
||
const templates = toc.kind === 'bibliography'
|
||
? bibEntryTemplates(tocLevelStyle(toc, 1), bibTypes, toc.citationStyle)
|
||
: spec.seq
|
||
? entry('', tocLevelStyle(toc, 1))
|
||
: toc.kind === 'alphabetical'
|
||
? entry(' text:outline-level="1"', tocLevelStyle(toc, 1))
|
||
: HEADING_LEVELS.filter(l => l <= toc.maxLevel)
|
||
.map(l => entry(` text:outline-level="${l}"`, tocLevelStyle(toc, l)))
|
||
.join('');
|
||
const source =
|
||
`<text:${spec.el}-source${sourceAttrs}>` +
|
||
(title ? `<text:index-title-template text:style-name="${spec.heading}">${escapeXml(title)}</text:index-title-template>` : '') +
|
||
templates +
|
||
`</text:${spec.el}-source>`;
|
||
const body =
|
||
`<text:index-body>` +
|
||
(title
|
||
? `<text:index-title text:name="${escapeXml(name)}_Head">` +
|
||
`<text:p text:style-name="${spec.heading}">${escapeXml(title)}</text:p>` +
|
||
`</text:index-title>`
|
||
: '') +
|
||
toc.entries
|
||
.map(e => `<text:p text:style-name="${tocLevelStyle(toc, e.level)}">${escapeXml(e.text).replace(/\n/g, '<text:line-break/>')}`
|
||
// A bibliography row is the source, nothing else: no tab, no page number.
|
||
+ (toc.kind === 'bibliography' ? '' : `<text:tab/>${e.pages?.join(', ') ?? e.page}`) + '</text:p>')
|
||
.join('') +
|
||
`</text:index-body>`;
|
||
return `<text:${spec.el} text:name="${escapeXml(name)}" text:protected="true">${source}${body}</text:${spec.el}>`;
|
||
}
|
||
|
||
// BMS/BME/XRF sentinels → <text:bookmark-start/>, <text:bookmark-end/> and
|
||
// <text:bookmark-ref>. All three are legal anywhere in paragraph content, so a plain
|
||
// replace works wherever odf-kit put the run — inside a <text:span> included.
|
||
function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(BMS) && !content.includes(XRF)) return odtBytes;
|
||
|
||
content = content
|
||
.replace(new RegExp(`${BMS}([^${BMS}]*)${BMS}`, 'g'), (_m, name: string) => `<text:bookmark-start text:name="${name}"/>`)
|
||
.replace(new RegExp(`${BME}([^${BME}]*)${BME}`, 'g'), (_m, name: string) => `<text:bookmark-end text:name="${name}"/>`)
|
||
.replace(new RegExp(`${XRF}(\\d+)${XRF}([^${XRF}]*)${XRF}`, 'g'), (_m, idx: string, shown: string) => {
|
||
const ref = refs[Number(idx)];
|
||
if (!ref) return shown;
|
||
return `<text:bookmark-ref text:reference-format="${ref.format}" text:ref-name="${ref.name}">${shown}</text:bookmark-ref>`;
|
||
});
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// CMS/CME sentinels → <office:annotation> (author, date and body) and
|
||
// <office:annotation-end/>. content.xml declares neither dc: nor loext:, so the two
|
||
// namespaces are added on the root when a document actually has comments.
|
||
function applyComments(odtBytes: Uint8Array, list: CommentExport[]): Uint8Array {
|
||
if (!list.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(CMS)) return odtBytes;
|
||
|
||
content = content
|
||
.replace(new RegExp(`${CMS}(\\d+)${CMS}`, 'g'), (_m, idx: string) => {
|
||
const c = list[Number(idx)];
|
||
if (!c) return '';
|
||
// LibreOffice reads its own resolved flag off loext: and writes it either way;
|
||
// its comment bodies carry the pool's Comment paragraph style.
|
||
const body = c.text.split('\n').map(line => `<text:p text:style-name="Comment">${escapeXml(line)}</text:p>`).join('');
|
||
return `<office:annotation office:name="${escapeXml(c.name)}" loext:resolved="${c.resolved}">`
|
||
+ (c.author ? `<dc:creator>${escapeXml(c.author)}</dc:creator>` : '')
|
||
+ (c.date ? `<dc:date>${escapeXml(c.date)}</dc:date>` : '')
|
||
+ (body || '<text:p text:style-name="Comment"/>')
|
||
+ '</office:annotation>';
|
||
})
|
||
.replace(new RegExp(`${CME}(\\d+)${CME}`, 'g'), (_m, idx: string) => {
|
||
const c = list[Number(idx)];
|
||
return c ? `<office:annotation-end office:name="${escapeXml(c.name)}"/>` : '';
|
||
});
|
||
|
||
// applyRevisions may already have declared dc:; declaring either twice is invalid XML.
|
||
content = ensureDcNamespace(content);
|
||
if (!content.includes('xmlns:loext=')) {
|
||
content = content.replace('<office:document-content ',
|
||
'<office:document-content xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" ');
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Cut each hoisted note paragraph out of content.xml and splice its runs into a
|
||
// <text:note> at the anchor sentinel. Runs last of the content.xml passes, so the note
|
||
// text carries everything the earlier ones resolved inside it.
|
||
function applyNotes(odtBytes: Uint8Array, notes: NoteExport[]): Uint8Array {
|
||
if (!notes.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(FNT)) return odtBytes;
|
||
|
||
// The note's runs, taken out of the paragraph the hoist gave it. A wrapper span may
|
||
// hold the sentinel, so it is cut by string and the empty remains are swept after.
|
||
const bodies: string[] = notes.map((_note, i) => {
|
||
const open = `${FNT}B${i}${FNT}`;
|
||
const at = content.indexOf(open);
|
||
if (at < 0) return '';
|
||
const start = content.lastIndexOf('<text:p', at);
|
||
const end = content.indexOf('</text:p>', at);
|
||
if (start < 0 || end < 0) return '';
|
||
const block = content.slice(start, end + '</text:p>'.length);
|
||
content = content.slice(0, start) + content.slice(end + '</text:p>'.length);
|
||
return block
|
||
.replace(/^<text:p\b[^>]*>/, '')
|
||
.replace(/<\/text:p>$/, '')
|
||
.replace(open, '')
|
||
.replace(/<text:span\b[^>]*\/>|<text:span\b[^>]*><\/text:span>/g, '');
|
||
});
|
||
|
||
content = content.replace(new RegExp(`${FNT}A(\\d+)${FNT}`, 'g'), (_m, idx: string) => {
|
||
const i = Number(idx);
|
||
const note = notes[i];
|
||
if (!note) return '';
|
||
const endnote = note.kind === 'endnote';
|
||
const label = note.label ? ` text:label="${escapeXml(note.label)}"` : '';
|
||
return `<text:note text:id="${endnote ? 'edn' : 'ftn'}${i + 1}" text:note-class="${note.kind}">`
|
||
+ `<text:note-citation${label}>${escapeXml(note.citation)}</text:note-citation>`
|
||
+ `<text:note-body><text:p text:style-name="${odfStyleName(note.styleName ?? (endnote ? 'Endnote' : 'Footnote'))}">${bodies[i]}</text:p></text:note-body>`
|
||
+ '</text:note>';
|
||
});
|
||
// Defensive: strip any sentinel the passes above left behind.
|
||
content = content.split(FNT).join('');
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number, bibTypes: string[]): Uint8Array {
|
||
if (!tocs.length) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(TOC_SENT)) return odtBytes;
|
||
|
||
content = content.replace(
|
||
new RegExp(`<text:p\\b[^>]*>${TOC_SENT}(\\d+)${TOC_SENT}</text:p>`, 'g'),
|
||
(_m, idx: string) => {
|
||
const toc = tocs[Number(idx)];
|
||
return toc ? tocXml(toc, Number(idx), bibTypes) : '';
|
||
},
|
||
);
|
||
// Defensive: strip any sentinels not consumed above.
|
||
content = content.split(TOC_SENT).join('');
|
||
|
||
const tabPosCm = Math.max(1, Math.round(contentWidthCm * 1000) / 1000);
|
||
// One set of entry styles per index family actually present; a caption index has the
|
||
// single level its ODF template declares.
|
||
const styles = [...new Set(tocs.map(t => t.kind))]
|
||
.map((kind) => {
|
||
const spec = ODF_INDEX[kind];
|
||
const levels = kind === 'toc' ? HEADING_LEVELS : [1];
|
||
return contentsHeadingStyle(spec.heading)
|
||
+ levels.map(l => contentsEntryStyle(`${spec.entryStyle}${l}`, l, tabPosCm)).join('');
|
||
})
|
||
.join('');
|
||
content = injectAutomaticStyles(content, styles);
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// Header/footer input for the export: one single-paragraph doc per zone (or null),
|
||
// the page count snapshot for the stored <text:page-count> value, and the page-edge
|
||
// distances (cm; default HF_DISTANCE_CM) for header (from top) / footer (from bottom).
|
||
export type HfExport = {
|
||
header: HfDoc;
|
||
footer: HfDoc;
|
||
// First-page overrides (Word titlePg / ODF header-first); only used when
|
||
// differentFirstPage is set. null = the first page's zone is blank.
|
||
headerFirst?: HfDoc;
|
||
footerFirst?: HfDoc;
|
||
differentFirstPage?: boolean;
|
||
// Even-page overrides (Word evenAndOddHeaders / ODF header-left); only used when
|
||
// differentOddEven is set. null = the even pages' zone is blank.
|
||
headerEven?: HfDoc;
|
||
footerEven?: HfDoc;
|
||
differentOddEven?: boolean;
|
||
// One set per section, in body order; [0] repeats the fields above. Absent = the
|
||
// document has a single section.
|
||
sections?: HfSet[];
|
||
pageCount: number;
|
||
headerDistanceCm?: number;
|
||
footerDistanceCm?: number;
|
||
};
|
||
|
||
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
|
||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false): Promise<Uint8Array> {
|
||
// Images become IMG sentinels before serialization; applyImages resolves them and writes
|
||
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
|
||
// (so PGB misses their blocks) and before the inline passes (which then cover them).
|
||
exportSheet = styles;
|
||
exportSpacingModel = spacingModel;
|
||
const images: ImageExport[] = [];
|
||
const tocs: TocExport[] = [];
|
||
const textBoxes: TextBoxExport[] = [];
|
||
const columns: ColumnsExport[] = [];
|
||
const dateFields: DateTimeFieldExport[] = [];
|
||
const formulas: FormulaExport[] = [];
|
||
const crossRefs: CrossRefExport[] = [];
|
||
const notes: NoteExport[] = [];
|
||
const commentList: CommentExport[] = [];
|
||
const seqFields: SequenceExport[] = [];
|
||
const revisionList = new Map<string, RevisionExport>();
|
||
const indexMarks: IndexEntryExport[] = [];
|
||
const bibMarks: BibExport[] = [];
|
||
const rubies: RubyExport[] = [];
|
||
const sentinels = replaceRuby(replaceBibEntries(replaceIndexEntries(replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList), indexMarks), bibMarks), rubies);
|
||
const raw = markTextEffects(bakeListCharStyles(sentinels, styles), DEFAULT_FONT_SIZE_PT, styles);
|
||
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||
// Different first page (ODF header-first): page 1 gets its own zone content.
|
||
const differentFirstPage = !!hf?.differentFirstPage;
|
||
let firstHeaderPara = differentFirstPage && hf && !hfIsEmpty(hf.headerFirst ?? null) ? (hf.headerFirst!.content![0] as TiptapNode) : null;
|
||
let firstFooterPara = differentFirstPage && hf && !hfIsEmpty(hf.footerFirst ?? null) ? (hf.footerFirst!.content![0] as TiptapNode) : null;
|
||
// Different odd & even pages (ODF header-left): even pages get their own zone.
|
||
const differentOddEven = !!hf?.differentOddEven;
|
||
let evenHeaderPara = differentOddEven && hf && !hfIsEmpty(hf.headerEven ?? null) ? (hf.headerEven!.content![0] as TiptapNode) : null;
|
||
let evenFooterPara = differentOddEven && hf && !hfIsEmpty(hf.footerEven ?? null) ? (hf.footerEven!.content![0] as TiptapNode) : null;
|
||
// Hoist header/footer inline images out to HFIMG sentinels before odf-kit serializes
|
||
// the zones; applyHfPostProcess rewrites them to <draw:frame> in styles.xml.
|
||
const hfImages: ImageExport[] = [];
|
||
headerPara = headerPara && markTextEffects(headerPara);
|
||
footerPara = footerPara && markTextEffects(footerPara);
|
||
headerPara = replaceHfImages(headerPara, hfImages);
|
||
footerPara = replaceHfImages(footerPara, hfImages);
|
||
firstHeaderPara = replaceHfImages(firstHeaderPara, hfImages);
|
||
firstFooterPara = replaceHfImages(firstFooterPara, hfImages);
|
||
evenHeaderPara = replaceHfImages(evenHeaderPara, hfImages);
|
||
evenFooterPara = replaceHfImages(evenFooterPara, hfImages);
|
||
// With the flag on, page 1 is independent: whenever a side has a zone on either
|
||
// variant, emit both — an empty one blanks its side, as the editor shows it.
|
||
if (differentFirstPage) {
|
||
const empty = (): TiptapNode => ({ type: 'paragraph', content: [] });
|
||
if (headerPara || firstHeaderPara) { headerPara ??= empty(); firstHeaderPara ??= empty(); }
|
||
if (footerPara || firstFooterPara) { footerPara ??= empty(); firstFooterPara ??= empty(); }
|
||
}
|
||
// Same for even pages: an empty even zone blanks it while the default fills odd pages.
|
||
if (differentOddEven) {
|
||
const empty = (): TiptapNode => ({ type: 'paragraph', content: [] });
|
||
if (headerPara || evenHeaderPara) { headerPara ??= empty(); evenHeaderPara ??= empty(); }
|
||
if (footerPara || evenFooterPara) { footerPara ??= empty(); evenFooterPara ??= empty(); }
|
||
}
|
||
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
|
||
// ODF page margin; clamped below the body margin so the body still starts at it.
|
||
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
|
||
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
|
||
let json = injectCustomTypes(raw);
|
||
if (headerPara || footerPara) {
|
||
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
|
||
}
|
||
|
||
// Text width = the format's page width (orientation-swapped) minus the L/R
|
||
// margins. Table column widths are scaled to fill exactly this width.
|
||
const pageWidthCm = pageDimsCm(pageFormat, orientation).w;
|
||
const contentWidthCm = pageWidthCm - margins.left - margins.right;
|
||
|
||
// Filled by exportTable, in document order, one CellBlock[] per table cell.
|
||
// applyCellBlocks consumes it to rebuild real <text:h>/<text:p>/<text:list>.
|
||
const cellBlocks: CellBlock[][] = [];
|
||
// One entry per table, same order — consumed by applyTableProps.
|
||
const tableMargins: (TableProps | null)[] = [];
|
||
// The named table style + its options per table, same order — applyTableStyleNames.
|
||
const tableStyleNames: (TableStyleRef | null)[] = [];
|
||
// One entry per cell, same order as cellBlocks — applyCellFormulas. A cell's number
|
||
// is read in the document's language, as LibreOffice reads it.
|
||
const cellFormulas: (CellFormulaExport | null)[] = [];
|
||
const langTag = language ? `${language.language}-${language.country}` : 'en';
|
||
const numberLoc = numberLocale(langTag);
|
||
|
||
const odt = await tiptapToOdt(json, {
|
||
// Orientation sets style:print-orientation; rewriteStylesXml overrides the exact
|
||
// fo:page-width/height for the chosen format (odf-kit only knows 5 presets).
|
||
orientation,
|
||
// Margins (cm) from the Layout panel, matching the editor's padding so line
|
||
// wrapping / page flow is identical. With a header/footer the vertical margin is
|
||
// the edge→zone distance; applyHfPostProcess keeps the body starting at the margin.
|
||
marginTop: `${headerPara ? headerDist : margins.top}cm`,
|
||
marginBottom: `${footerPara ? footerDist : margins.bottom}cm`,
|
||
marginLeft: `${margins.left}cm`,
|
||
marginRight: `${margins.right}cm`,
|
||
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
|
||
if (node.type === CUST_HF) {
|
||
if (headerPara) doc.setHeader((b: HeaderFooterBuilder) => applyHfRuns(b, headerPara, hf!.pageCount));
|
||
if (footerPara) doc.setFooter((b: HeaderFooterBuilder) => applyHfRuns(b, footerPara, hf!.pageCount));
|
||
return;
|
||
}
|
||
if (node.type === CUST_TABLE) {
|
||
exportTable(node, doc, contentWidthCm, cellBlocks, tableMargins, tableStyleNames, cellFormulas, numberLoc);
|
||
return;
|
||
}
|
||
const opts: {
|
||
lineHeight?: number | string;
|
||
align?: AlignValue;
|
||
spaceBefore?: string;
|
||
spaceAfter?: string;
|
||
indentLeft?: string;
|
||
indentFirst?: string;
|
||
tabStops?: { position: string; type: 'left' | 'center' | 'right' }[];
|
||
} = {};
|
||
if (node.attrs?.lineHeight != null) {
|
||
const lhRaw = String(node.attrs.lineHeight);
|
||
const lhNum = parseFloat(lhRaw);
|
||
opts.lineHeight = isNaN(lhNum) ? lhRaw : lhNum;
|
||
} else {
|
||
opts.lineHeight = DEFAULT_LINE_HEIGHT;
|
||
}
|
||
const ta = node.attrs?.textAlign;
|
||
if (ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify') {
|
||
opts.align = ta;
|
||
}
|
||
if (node.attrs?.spaceBefore != null) opts.spaceBefore = `${node.attrs.spaceBefore}pt`;
|
||
if (node.attrs?.spaceAfter != null) opts.spaceAfter = `${node.attrs.spaceAfter}pt`;
|
||
// Left indent → fo:margin-left (odf-kit emits it natively from indentLeft).
|
||
if (typeof node.attrs?.indent === 'number' && node.attrs.indent > 0) {
|
||
opts.indentLeft = `${node.attrs.indent}cm`;
|
||
}
|
||
// First-line indent → fo:text-indent; negative is a hanging indent.
|
||
if (typeof node.attrs?.indentFirst === 'number' && node.attrs.indentFirst !== 0) {
|
||
opts.indentFirst = `${node.attrs.indentFirst}cm`;
|
||
}
|
||
// Tab stops → <style:tab-stops>. odf-kit's type has no decimal stop, so it goes
|
||
// out as 'char' and applyInlineSentinels adds the style:char ODF requires.
|
||
const stops = parseTabStops(node.attrs?.tabStops);
|
||
if (stops.length) {
|
||
opts.tabStops = stops.map((s) => ({
|
||
position: `${s.pos}cm`,
|
||
// odf-kit writes style:type verbatim but types it without ODF's 'char'; a
|
||
// LEAD suffix rides along so the leader reaches applyTabLeaders.
|
||
type: ((s.align === 'decimal' ? 'char' : s.align)
|
||
+ (normalizeLeader(s.leader) ? `${LEAD}${s.leader}` : '')) as 'left' | 'center' | 'right',
|
||
}));
|
||
}
|
||
const content = node.content ?? [];
|
||
// The paragraph-mark font size rides as an FSZ sentinel that
|
||
// applyEmptyLineFontSizes turns into a paragraph-style fo:font-size — it is the
|
||
// block's line-height floor, so it matters on a filled block as much as an empty one.
|
||
const fszPayload = markFontPayload(node.attrs);
|
||
const fsz = fszPayload ? `${FSZ}${fszPayload}${FSZ}` : '';
|
||
// Paragraph background/borders ride as a leading PBX sentinel (odf-kit has no such
|
||
// options); applyParagraphBoxes mints the style. FSZ stays first so its own pass,
|
||
// which runs earlier, still matches it right after the opening tag.
|
||
const spec = paraBoxSpec(node.attrs);
|
||
const pbx = spec ? `${PBX}${spec}${PBX}` : '';
|
||
// Named style, unless it is the one odf-kit puts on this node type anyway.
|
||
const styleName = styleOf(node);
|
||
const sty = styleName !== odfDefaultStyleOf(node) ? `${STY}${styleName}${STY}` : '';
|
||
const marks = fsz + sty + pbx;
|
||
const withPbx = (p: ParagraphBuilder) => { if (marks) p.addText(marks); applyRuns(p, content); };
|
||
|
||
if (node.type === CUST_P) {
|
||
if (content.length === 0) doc.addParagraph(marks, opts);
|
||
else doc.addParagraph(withPbx, opts);
|
||
} else if (node.type === CUST_H) {
|
||
const level = (node.attrs?.level as number) ?? 1;
|
||
if (content.length === 0) doc.addHeading(marks, level, opts);
|
||
else doc.addHeading(withPbx, level, opts);
|
||
}
|
||
},
|
||
});
|
||
|
||
// Rewrite odf-kit's default numbering (1.) into per-level formats (depth cycle,
|
||
// explicit types, multilevel chains). Runs before applyListItemStyles, which only
|
||
// touches <text:p> styles, not the <text:list-style> definitions.
|
||
const olFormats: (OlStyleFix | null)[] = [];
|
||
collectOrderedListFormats(raw, olFormats);
|
||
let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
|
||
|
||
// A Word list continued across an intervening paragraph splits into separate nodes with
|
||
// a `start` attr; odf-kit drops it, so emit text:start-value on each such list's first item.
|
||
const listStarts: (number | null)[] = [];
|
||
collectListStartValues(raw, listStarts);
|
||
numberedOdt = applyListStartValues(numberedOdt, listStarts);
|
||
|
||
// Rewrite odf-kit's default bullet chars into the per-level bulletChar attrs.
|
||
const blChars: (string[] | null)[] = [];
|
||
collectBulletListChars(raw, blChars);
|
||
numberedOdt = applyBulletListChars(numberedOdt, blChars);
|
||
|
||
const nestedFixes: (ListDef | null)[] = [];
|
||
collectNestedListFixes(raw, nestedFixes);
|
||
numberedOdt = applyNestedListTypes(numberedOdt, nestedFixes);
|
||
|
||
const listStyles: ParaStyle[] = [];
|
||
collectListItemStyles(raw, listStyles);
|
||
const styledLists = applyListItemStyles(numberedOdt, listStyles);
|
||
|
||
// Whole-list indent → added to each L# list-style's level margins.
|
||
const listLevels: ListLevelProps[][] = [];
|
||
collectListLevelProps(raw, listLevels);
|
||
let indentedLists = applyListLevelProps(styledLists, listLevels);
|
||
|
||
// Marker formatting → a minted character style on each L# level definition.
|
||
const markerFormats: (MarkerFormat | null)[][] = [];
|
||
collectListMarkerFormats(raw, markerFormats);
|
||
indentedLists = applyListMarkerFormats(indentedLists, markerFormats);
|
||
|
||
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
|
||
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
|
||
const styledCells = applyCellFormulas(applyCellBlocks(indentedLists, cellBlocks), cellFormulas, langTag);
|
||
|
||
const rowHeights: (string | null)[] = [];
|
||
collectTableRowHeights(raw, rowHeights);
|
||
const styledRows = applyTableRowHeights(expandCellPadding(styledCells), rowHeights);
|
||
const withTableMargins = applyTableStyleNames(
|
||
applyTableHeaderRows(applyTableProps(styledRows, tableMargins, contentWidthCm), tableMargins), tableStyleNames);
|
||
|
||
const cleaned = collapseRunWhitespace(withTableMargins);
|
||
// Before applyInlineSentinels: it matches the bare style:type="char" a leader would hide.
|
||
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
|
||
const withImages = applyImages(withBreaks, images);
|
||
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
|
||
const withSequences = applyRuby(applyBibEntries(applyIndexEntries(applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList, recordChanges), indexMarks), bibMarks), rubies);
|
||
const withFormulas = applyFormulas(withSequences, formulas);
|
||
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
|
||
const withColumns = applyColumns(withTextBoxes, columns);
|
||
const withBookmarks = applyComments(applyBookmarks(withColumns, crossRefs), commentList);
|
||
// After every other content.xml pass: the note's own runs are hoisted into the body,
|
||
// so they must be fully resolved before they are cut out and moved into <text:note>.
|
||
const withNotes = applyNotes(withBookmarks, notes);
|
||
const withToc = applyToc(withNotes, tocs, contentWidthCm, [...new Set(bibMarks.map(m => m.type))]);
|
||
const withPageBreaks = applyPageBreaks(withToc);
|
||
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
|
||
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
|
||
// Effects first: applyCharacterStyles then clones the style that already carries them.
|
||
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
|
||
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
|
||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering);
|
||
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||
const withWatermark = applyWatermarkOdf(withHf, decor.watermark);
|
||
// Sections past the first get their own master page, which is where ODF keeps a
|
||
// section's header/footer; the SEC-marked block points at it.
|
||
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation);
|
||
return zipFinal(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props));
|
||
}
|
||
|
||
// The document's first page number. ODF has no document-level start: LibreOffice puts
|
||
// `style:page-number` on the first paragraph's own style (probed), beside the master page
|
||
// it names.
|
||
function applyPageNumberStart(odtBytes: Uint8Array, start: number): Uint8Array {
|
||
if (start <= 1) return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
if (!contentBytes) return odtBytes;
|
||
const content = strFromU8(contentBytes);
|
||
const body = /<office:text\b[^>]*>/.exec(content);
|
||
if (!body) return odtBytes;
|
||
const first = /<text:(p|h)\b([^>]*)>/.exec(content.slice(body.index + body[0].length));
|
||
if (!first) return odtBytes;
|
||
const at = body.index + body[0].length + first.index;
|
||
const srcM = /text:style-name="([^"]*)"/.exec(first[2]);
|
||
const source = srcM ? srcM[1] : 'Standard';
|
||
const name = 'PgNumStart';
|
||
const def = findAutoStyle(content, source);
|
||
const minted = def
|
||
? cloneStyleWithParaProps(def, name, `style:page-number="${start}"`)
|
||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source}"><style:paragraph-properties style:page-number="${start}"/></style:style>`;
|
||
const tag = srcM
|
||
? first[0].replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
|
||
: `<text:${first[1]} text:style-name="${name}"${first[2]}>`;
|
||
files['content.xml'] = strToU8(
|
||
injectAutomaticStyles(content.slice(0, at) + tag + content.slice(at + first[0].length), minted),
|
||
);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// The descriptive metadata → meta.xml, which odf-kit already writes (generator +
|
||
// creation date). LibreOffice's File ▸ Properties and Word's File ▸ Info read it.
|
||
function applyDocProperties(odtBytes: Uint8Array, props: DocProperties): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const metaBytes = files['meta.xml'];
|
||
if (!metaBytes) return odtBytes;
|
||
const el = (tag: string, value: string) => (value.trim() ? `<${tag}>${escapeXml(value.trim())}</${tag}>` : '');
|
||
const author = props.author.trim();
|
||
const fields = el('dc:title', props.title) + el('dc:subject', props.subject)
|
||
+ el('dc:description', props.description)
|
||
+ keywordList(props.keywords).map(k => `<meta:keyword>${escapeXml(k)}</meta:keyword>`).join('')
|
||
+ (author ? el('meta:initial-creator', author) + el('dc:creator', author) : '')
|
||
+ `<dc:date>${new Date().toISOString()}</dc:date>`;
|
||
files['meta.xml'] = strToU8(strFromU8(metaBytes)
|
||
.replace('<meta:generator>odf-kit</meta:generator>', `<meta:generator>${GENERATOR}</meta:generator>`)
|
||
.replace('</office:meta>', `${fields}</office:meta>`));
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// A document that takes the larger of two adjoining spacings needs LibreOffice's
|
||
// AddParaTableSpacing=false — its own default adds them, so without this the space
|
||
// between every pair of blocks would grow when the file is reopened.
|
||
function applySpacingModel(odtBytes: Uint8Array, model: SpacingModel): Uint8Array {
|
||
if (model !== 'max') return odtBytes;
|
||
const files = unzipSync(odtBytes);
|
||
const item =
|
||
'<config:config-item-set config:name="ooo:configuration-settings">' +
|
||
'<config:config-item config:name="AddParaTableSpacing" config:type="boolean">false</config:config-item>' +
|
||
'</config:config-item-set>';
|
||
const existing = files['settings.xml'];
|
||
if (existing) {
|
||
files['settings.xml'] = strToU8(strFromU8(existing).replace('</office:settings>', `${item}</office:settings>`));
|
||
return rezipOdt(files);
|
||
}
|
||
files['settings.xml'] = strToU8(
|
||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||
'<office:document-settings xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"' +
|
||
' xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0" office:version="1.3">' +
|
||
`<office:settings>${item}</office:settings></office:document-settings>`,
|
||
);
|
||
const manifestBytes = files['META-INF/manifest.xml'];
|
||
if (manifestBytes) {
|
||
const entry = '<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>';
|
||
const manifest = strFromU8(manifestBytes);
|
||
if (!manifest.includes('"settings.xml"')) {
|
||
files['META-INF/manifest.xml'] = strToU8(manifest.replace('</manifest:manifest>', `${entry}</manifest:manifest>`));
|
||
}
|
||
}
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
function hfAlign(para: TiptapNode): AlignValue | null {
|
||
const ta = para.attrs?.textAlign;
|
||
return ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null;
|
||
}
|
||
|
||
// <style:tab-stops> for a zone paragraph: odf-kit builds the body's, but a header/footer
|
||
// style is written by hand here.
|
||
function tabStopsXml(attrs: TiptapNode['attrs']): string {
|
||
const stops = parseTabStops(attrs?.tabStops);
|
||
if (!stops.length) return '';
|
||
const inner = stops.map((st) => {
|
||
const leader = normalizeLeader(st.leader);
|
||
return `<style:tab-stop style:position="${st.pos}cm" style:type="${st.align === 'decimal' ? 'char' : st.align}"`
|
||
+ (st.align === 'decimal' ? ' style:char="."' : '')
|
||
+ (leader ? ` style:leader-style="${ODF_LEADER_STYLE[leader]}" style:leader-text="${leader}"` : '')
|
||
+ '/>';
|
||
}).join('');
|
||
return `<style:tab-stops>${inner}</style:tab-stops>`;
|
||
}
|
||
|
||
// The whole <style:paragraph-properties> of a zone paragraph, or '' when it needs none:
|
||
// alignment plus the paragraph background ("colored field") and per-side borders.
|
||
function hfParaPropsXml(para: TiptapNode): string {
|
||
const props: string[] = [];
|
||
const align = hfAlign(para);
|
||
if (align) props.push(`fo:text-align="${align}"`);
|
||
const s = paraStyleFromAttrs(para.attrs);
|
||
if (s.background) props.push(`fo:background-color="${s.background}"`);
|
||
// The zone's own margins, which grow the band on the way back in (import/CLAUDE.md).
|
||
for (const [attr, side] of [['spaceBefore', 'top'], ['spaceAfter', 'bottom']] as const) {
|
||
const v = para.attrs?.[attr];
|
||
if (typeof v === 'number' && v) props.push(`fo:margin-${side}="${v}pt"`);
|
||
}
|
||
for (const [attr, side] of [
|
||
['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'],
|
||
] as const) {
|
||
if (s[attr]) props.push(`fo:border-${side}="${s[attr]}"`);
|
||
}
|
||
const tabs = tabStopsXml(para.attrs);
|
||
if (!props.length && !tabs) return '';
|
||
const open = `<style:paragraph-properties${props.length ? ` ${props.join(' ')}` : ''}`;
|
||
return tabs ? `${open}>${tabs}</style:paragraph-properties>` : `${open}/>`;
|
||
}
|
||
|
||
// Header/footer post-processing on styles.xml: resolve LBR/PGC sentinels, apply the
|
||
// paragraph alignment to the Header/Footer styles, and rewrite the geometry to the
|
||
// ODF's own mapping (page margin = HF distance, min-height fills up to the body margin).
|
||
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number, firstHeaderPara: TiptapNode | null = null, firstFooterPara: TiptapNode | null = null, pageCount = 1, hfImages: ImageExport[] = [], evenHeaderPara: TiptapNode | null = null, evenFooterPara: TiptapNode | null = null): Uint8Array {
|
||
if (!headerPara && !footerPara && !firstHeaderPara && !firstFooterPara && !evenHeaderPara && !evenFooterPara) return odtBytes;
|
||
|
||
const files = unzipSync(odtBytes);
|
||
const stylesBytes = files['styles.xml'];
|
||
if (!stylesBytes) return odtBytes;
|
||
let styles = strFromU8(stylesBytes);
|
||
|
||
// Same fix as collapseRunWhitespace: odf-kit joins runs with "\n", which would
|
||
// collapse into spurious spaces. styles.xml only has text:p inside header/footer.
|
||
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, ''));
|
||
styles = styles.split(LBR).join('<text:line-break/>');
|
||
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
|
||
styles = styles.replace(new RegExp(`${CHP}(\\d)${CHP}([\\s\\S]*?)${CHP}`, 'g'),
|
||
(_m, level: string, name: string) => `<text:chapter text:display="name" text:outline-level="${level}">${name}</text:chapter>`);
|
||
|
||
const mintedStyles: string[] = [];
|
||
const mint = (n: string) => { mintedStyles.push(n); };
|
||
styles = resolveTextEffects(styles, mint, 'HFTE');
|
||
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number, distCm: number) => {
|
||
// min-height fills the gap between the edge→zone distance and the body margin,
|
||
// so the body still starts at bodyMarginCm (page margin was set to distCm).
|
||
const minH = round3(Math.max(0.2, bodyMarginCm - distCm));
|
||
// The spacing attr sits on the body-facing side: below the header, above the footer.
|
||
const spacingAttr = kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top';
|
||
styles = styles.replace(
|
||
new RegExp(`<style:${kind}-style>[\\s\\S]*?</style:${kind}-style>`),
|
||
`<style:${kind}-style><style:header-footer-properties fo:min-height="${minH}cm" ${spacingAttr}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`,
|
||
);
|
||
const props = hfParaPropsXml(para);
|
||
if (props) {
|
||
const styleName = kind === 'header' ? 'Header' : 'Footer';
|
||
styles = styles.replace(
|
||
new RegExp(`(<style:style style:name="${styleName}"[^>]*?)/>`),
|
||
`$1>${props}</style:style>`,
|
||
);
|
||
}
|
||
};
|
||
if (headerPara) zone('header', headerPara, margins.top, headerDist);
|
||
if (footerPara) zone('footer', footerPara, margins.bottom, footerDist);
|
||
|
||
// Different first page / odd-even: inject <style:{header,footer}-{first,left}> content
|
||
// into the master page (ODF 1.3; LibreOffice reads them). The page-layout header/footer
|
||
// geometry above is shared across variants, so only the content differs here.
|
||
if (firstHeaderPara || firstFooterPara || evenHeaderPara || evenFooterPara) {
|
||
const injectVariant = (kind: 'header' | 'footer', suffix: 'first' | 'left', para: TiptapNode | null) => {
|
||
if (!para) return;
|
||
const xml = hfVariantZoneXml(kind, suffix, para, pageCount, mint);
|
||
// Insert after the matching default zone (always present when a variant exists,
|
||
// since buildOdt emits an empty default alongside it), else at the master-page bounds.
|
||
const closeTag = `</style:${kind}>`;
|
||
if (styles.includes(closeTag)) styles = styles.replace(closeTag, `${closeTag}${xml}`);
|
||
else if (kind === 'header') styles = styles.replace(/(<style:master-page\b[^>]*>)/, `$1${xml}`);
|
||
else styles = styles.replace('</style:master-page>', `${xml}</style:master-page>`);
|
||
};
|
||
injectVariant('header', 'first', firstHeaderPara);
|
||
injectVariant('header', 'left', evenHeaderPara);
|
||
injectVariant('footer', 'first', firstFooterPara);
|
||
injectVariant('footer', 'left', evenFooterPara);
|
||
}
|
||
if (mintedStyles.length) {
|
||
const defs = mintedStyles.join('');
|
||
styles = injectAutomaticStyles(styles, defs);
|
||
}
|
||
|
||
// Resolve HFIMG sentinels (default + first-page zones) to as-char <draw:frame>s, then
|
||
// add the picture binaries and their manifest entries (mirrors the body applyImages).
|
||
if (hfImages.length) {
|
||
styles = ensureDrawNamespaces(styles);
|
||
styles = styles.replace(new RegExp(`${HFIMG}(\\d+)${HFIMG}`, 'g'), (_m, idx: string) => {
|
||
const img = hfImages[Number(idx)];
|
||
return img ? hfImageFrameXml(img, Number(idx)) : '';
|
||
});
|
||
const bgStyles = hfImages.map((img, i) => (img.wrap === 'inline' ? '' : hfBackgroundStyleXml(i))).join('');
|
||
if (bgStyles) {
|
||
styles = injectAutomaticStyles(styles, bgStyles);
|
||
}
|
||
for (const img of hfImages) files[img.path] = img.bytes;
|
||
const manifestBytes = files['META-INF/manifest.xml'];
|
||
if (manifestBytes) {
|
||
const entries = hfImages
|
||
.map((img) => `<manifest:file-entry manifest:full-path="${img.path}" manifest:media-type="${img.mimeType}"/>`)
|
||
.join('');
|
||
files['META-INF/manifest.xml'] = strToU8(strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`));
|
||
}
|
||
}
|
||
|
||
files['styles.xml'] = strToU8(styles);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// styles.xml needs draw/svg/xlink namespaces for an image frame; odf-kit may not declare
|
||
// all three (svg is used by page geometry, but draw/xlink can be absent).
|
||
function ensureDrawNamespaces(styles: string): string {
|
||
const ns: [string, string][] = [
|
||
['draw', 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0'],
|
||
['svg', 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0'],
|
||
['xlink', 'http://www.w3.org/1999/xlink'],
|
||
];
|
||
const missing = ns.filter(([p]) => !styles.includes(`xmlns:${p}=`)).map(([p, uri]) => `xmlns:${p}="${uri}"`);
|
||
return missing.length ? styles.replace(/<office:document-styles\b/, `<office:document-styles ${missing.join(' ')}`) : styles;
|
||
}
|
||
|
||
// A variant zone paragraph → <style:{header,footer}-{first,left}> XML (suffix null = the
|
||
// section's own zone). Runs and page fields become <text:span>s referencing minted
|
||
// automatic text styles (pushed via `mint`); hardBreak → line-break.
|
||
function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' | null, para: TiptapNode, pageCount: number, mint: (styleXml: string) => void, prefix = ''): string {
|
||
let styleSeq = 0;
|
||
// Distinct minted-style prefix per variant so first + even styles never collide.
|
||
const pfx = `${prefix}HF${suffix === 'first' ? 'F' : suffix === 'left' ? 'L' : 'D'}${kind[0].toUpperCase()}`;
|
||
// Wrap inline XML in a minted text style span when the run carries formatting.
|
||
const styled = (innerXml: string, marks: TiptapNode['marks']): string => {
|
||
const props = odfTextPropsFromMarks(marks);
|
||
if (!props) return innerXml;
|
||
const name = `${pfx}T${++styleSeq}`;
|
||
mint(`<style:style style:name="${name}" style:family="text"><style:text-properties ${props}/></style:style>`);
|
||
return `<text:span text:style-name="${name}">${innerXml}</text:span>`;
|
||
};
|
||
|
||
let inner = '';
|
||
for (const node of para.content ?? []) {
|
||
if (node.type === 'text' && node.text) inner += styled(odfEncodeInline(node.text), node.marks);
|
||
else if (node.type === 'hardBreak') inner += '<text:line-break/>';
|
||
else if (node.type === 'pageNumber') inner += styled('<text:page-number text:select-page="current">1</text:page-number>', node.marks);
|
||
else if (node.type === 'pageCount') inner += styled(`<text:page-count>${pageCount}</text:page-count>`, node.marks);
|
||
else if (node.type === 'chapterField') inner += styled(
|
||
`<text:chapter text:display="name" text:outline-level="${Number(node.attrs?.level) || 1}">${odfEncodeInline(String(node.attrs?.text ?? ''))}</text:chapter>`, node.marks);
|
||
}
|
||
|
||
const parent = kind === 'header' ? 'Header' : 'Footer';
|
||
const props = hfParaPropsXml(para);
|
||
let paraStyle = parent;
|
||
if (props) {
|
||
paraStyle = `${pfx}P`;
|
||
mint(`<style:style style:name="${paraStyle}" style:family="paragraph" style:parent-style-name="${parent}">${props}</style:style>`);
|
||
}
|
||
const tag = suffix ? `${kind}-${suffix}` : kind;
|
||
return `<style:${tag}><text:p text:style-name="${paraStyle}">${inner}</text:p></style:${tag}>`;
|
||
}
|
||
|
||
// A section's own master page: the Standard one cloned under its own name, carrying that
|
||
// section's zones. ODF has no per-section header/footer other than this.
|
||
function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount: number, mint: (styleXml: string) => void, pfx: string): string {
|
||
const zone = (kind: 'header' | 'footer', suffix: 'first' | 'left' | null, doc: HfDoc): string => {
|
||
if (hfIsEmpty(doc)) return '';
|
||
return hfVariantZoneXml(kind, suffix, doc!.content![0] as TiptapNode, pageCount, mint, pfx);
|
||
};
|
||
// An empty default beside a variant blanks its side, as the editor renders it.
|
||
const need = (a: HfDoc, b: HfDoc) => (hfIsEmpty(a) && !hfIsEmpty(b) ? { type: 'doc', content: [{ type: 'paragraph', content: [] }] } as HfDoc : a);
|
||
const hFirst = set.differentFirstPage ? set.headerFirst : null;
|
||
const fFirst = set.differentFirstPage ? set.footerFirst : null;
|
||
const hEven = set.differentOddEven ? set.headerEven : null;
|
||
const fEven = set.differentOddEven ? set.footerEven : null;
|
||
const body = zone('header', null, need(need(set.header, hFirst), hEven))
|
||
+ zone('header', 'first', hFirst) + zone('header', 'left', hEven)
|
||
+ zone('footer', null, need(need(set.footer, fFirst), fEven))
|
||
+ zone('footer', 'first', fFirst) + zone('footer', 'left', fEven);
|
||
return `<style:master-page style:name="${name}" style:page-layout-name="${layoutName}">${body}</style:master-page>`;
|
||
}
|
||
|
||
// Point each SEC-marked block at its section's master page (ODF's only per-section
|
||
// header/footer), minting the master pages beside the Standard one odf-kit wrote.
|
||
function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number, margins: PageMargins, format: PageFormat, orientation: Orientation): Uint8Array {
|
||
const files = unzipSync(odtBytes);
|
||
const contentBytes = files['content.xml'];
|
||
const stylesBytes = files['styles.xml'];
|
||
if (!contentBytes || !stylesBytes) return odtBytes;
|
||
let content = strFromU8(contentBytes);
|
||
if (!content.includes(SEC)) return odtBytes;
|
||
let styles = strFromU8(stylesBytes);
|
||
|
||
const minted: string[] = [];
|
||
const used = new Set<number>();
|
||
let counter = 0;
|
||
content = content.replace(
|
||
new RegExp(`<text:(p|h)\\b([^>]*)>${SEC}(\\d+)${SEC}`, 'g'),
|
||
(_m, tag: string, attrs: string, idx: string) => {
|
||
const index = Number(idx);
|
||
used.add(index);
|
||
const sm = /text:style-name="([^"]*)"/.exec(attrs);
|
||
const source = sm?.[1] ?? '';
|
||
const def = source ? findAutoStyle(content, source) : null;
|
||
const name = `MP${++counter}`;
|
||
const master = ` style:master-page-name="Section${index + 1}"`;
|
||
let style = def
|
||
? def.replace(/style:name="[^"]*"/, `style:name="${name}"`).replace(/(<style:style\b[^>]*?)(\/?>)/, `$1${master}$2`)
|
||
: `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${source || 'Standard'}"${master}/>`;
|
||
// A section restarting the numbering says so on this same paragraph — ODF has no
|
||
// other place for it (applyPageNumberStart writes the document's own that way).
|
||
const restart = sets[index]?.pageNumberStart;
|
||
if (restart != null) style = upsertProps(style, 'paragraph', { 'style:page-number': String(restart) });
|
||
minted.push(style);
|
||
const rest = attrs.replace(/\s*text:style-name="[^"]*"/, '');
|
||
return `<text:${tag}${rest} text:style-name="${name}">`;
|
||
},
|
||
);
|
||
if (minted.length) {
|
||
content = injectAutomaticStyles(content, minted.join(''));
|
||
}
|
||
|
||
// The page layout odf-kit gave the Standard master page: reused as is by a section
|
||
// whose margins are the document's, cloned with them shifted by a section that has
|
||
// its own — a shift, so whatever the header/footer pass folded into it survives.
|
||
const layout = /<style:master-page\b[^>]*style:page-layout-name="([^"]*)"/.exec(styles)?.[1] ?? 'pm1';
|
||
const layoutXml = new RegExp(`<style:page-layout\\b[^>]*style:name="${layout}"[\\s\\S]*?</style:page-layout>`).exec(styles)?.[0] ?? null;
|
||
const layouts: string[] = [];
|
||
const layoutFor = (index: number, set: HfSet): string => {
|
||
const m = set.margins;
|
||
const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null;
|
||
if ((!m && !paper) || !layoutXml) return layout;
|
||
const name = `${layout}Sec${index + 1}`;
|
||
layouts.push(layoutXml
|
||
.replace(`style:name="${layout}"`, `style:name="${name}"`)
|
||
.replace(/<style:page-layout-properties\b[^>]*>/, (props) => {
|
||
// The margins are a shift, so whatever the header/footer pass folded into them
|
||
// survives; the paper is set outright.
|
||
let p = m
|
||
? (['top', 'bottom', 'left', 'right'] as const).reduce((q, side) =>
|
||
q.replace(new RegExp(`fo:margin-${side}="([\\d.]+)cm"`), (_x, cm: string) =>
|
||
`fo:margin-${side}="${Math.max(0, Math.round((parseFloat(cm) + m[side] - margins[side]) * 1000) / 1000)}cm"`), props)
|
||
: props;
|
||
if (paper) {
|
||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||
p = p
|
||
.replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(paper.w)}cm"`)
|
||
.replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`)
|
||
.replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`);
|
||
}
|
||
return p;
|
||
}));
|
||
return name;
|
||
};
|
||
const hfStyles: string[] = [];
|
||
const pages: string[] = [];
|
||
for (const index of [...used].sort((a, b) => a - b)) {
|
||
const set = sets[index];
|
||
if (!set) continue;
|
||
pages.push(masterPageXml(`Section${index + 1}`, layoutFor(index, set), set, pageCount, (x) => hfStyles.push(x), `MS${index}`));
|
||
}
|
||
if (layouts.length) styles = styles.replace('</office:automatic-styles>', `${layouts.join('')}</office:automatic-styles>`);
|
||
if (pages.length) styles = styles.replace('</office:master-styles>', `${pages.join('')}</office:master-styles>`);
|
||
if (hfStyles.length) {
|
||
const defs = hfStyles.join('');
|
||
styles = injectAutomaticStyles(styles, defs);
|
||
}
|
||
|
||
files['content.xml'] = strToU8(content);
|
||
files['styles.xml'] = strToU8(styles);
|
||
return rezipOdt(files);
|
||
}
|
||
|
||
// A text run's ODF <style:text-properties> attribute string from TipTap marks (mirrors
|
||
// formattingFromMarks). Empty string → no styling needed (returned as null).
|
||
function odfTextPropsFromMarks(marks: TiptapNode['marks']): string | null {
|
||
const fmt = formattingFromMarks(marks);
|
||
const a: string[] = [];
|
||
// The effects below name the underline's own shape and colour where the run has them.
|
||
const extra = odfExtraTextProps(marks);
|
||
if (fmt.fontWeight != null) a.push(`fo:font-weight="${fmt.fontWeight}"`);
|
||
else if (fmt.bold) a.push('fo:font-weight="bold"');
|
||
if (fmt.italic) a.push('fo:font-style="italic"');
|
||
if (fmt.underline) {
|
||
a.push('style:text-underline-width="auto"');
|
||
if (!/style:text-underline-style=/.test(extra)) a.push('style:text-underline-style="solid"');
|
||
if (!/style:text-underline-color=/.test(extra)) a.push('style:text-underline-color="font-color"');
|
||
}
|
||
if (fmt.strikethrough) a.push('style:text-line-through-style="solid"');
|
||
if (extra) a.push(extra);
|
||
if (fmt.superscript) a.push('style:text-position="super 58%"');
|
||
else if (fmt.subscript) a.push('style:text-position="sub 58%"');
|
||
if (fmt.fontFamily) {
|
||
const q = /\s/.test(fmt.fontFamily) ? `'${fmt.fontFamily}'` : fmt.fontFamily;
|
||
a.push(`style:font-name="${fmt.fontFamily}" fo:font-family="${q}"`);
|
||
}
|
||
if (fmt.fontSize) a.push(`fo:font-size="${fmt.fontSize}"`);
|
||
if (fmt.color) a.push(`fo:color="${fmt.color}"`);
|
||
if (fmt.highlightColor) a.push(`fo:background-color="${fmt.highlightColor}"`);
|
||
return a.length ? a.join(' ') : null;
|
||
}
|
||
|
||
// Encode inline text as ODF: runs of ≥2 spaces → <text:s text:c>, tabs → <text:tab/>,
|
||
// the rest XML-escaped (so LibreOffice preserves significant whitespace).
|
||
function odfEncodeInline(s: string): string {
|
||
let out = '';
|
||
for (let i = 0; i < s.length; ) {
|
||
const ch = s[i];
|
||
if (ch === '\t') { out += '<text:tab/>'; i++; continue; }
|
||
if (ch === ' ') {
|
||
let n = 1;
|
||
while (s[i + n] === ' ') n++;
|
||
out += n === 1 ? ' ' : `<text:s text:c="${n}"/>`;
|
||
i += n;
|
||
continue;
|
||
}
|
||
out += ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch;
|
||
i++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Document filename derived from the first non-empty heading (sanitized, max 50
|
||
// chars), falling back to document.odt.
|
||
export function deriveFilename(json: TiptapNode): string {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const content = (json as any).content as any[] | undefined;
|
||
const heading = content?.find(
|
||
(node: any) => node.type === 'heading' && node.content?.length
|
||
);
|
||
const firstText: string | undefined = heading?.content?.[0]?.text;
|
||
if (firstText) {
|
||
const name = firstText
|
||
.slice(0, 50)
|
||
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
||
.trim()
|
||
.replace(/\s+/g, '-');
|
||
if (name) return `${name}.odt`;
|
||
}
|
||
return 'document.odt';
|
||
}
|