/** * Document export helpers: inline-styled HTML (email-safe), Word (.doc) and * print-to-PDF. All three start from the *live, rendered* content DOM so the * output reflects exactly what the user sees — chosen fonts, colours, * alignment and table borders — rather than a re-derived approximation. * * These functions are dependency-free. PDF export relies on the browser's own * print-to-PDF pipeline (see buildPrintDocument); Word export emits an HTML * document Word opens natively. */ /** Inherited text properties: emitted only when they differ from the parent. */ const INHERITED_PROPS = [ 'color', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'text-align', 'text-transform', ] as const; /** * Box/decoration properties: emitted only when they differ from their initial * value (listed here). Longhands are used because getComputedStyle does not * reliably return the `margin`/`padding`/`border` shorthands. */ const BOX_DEFAULTS: Record = { 'background-color': ['rgba(0, 0, 0, 0)', 'transparent'], 'text-decoration-line': ['none'], 'margin-top': ['0px'], 'margin-right': ['0px'], 'margin-bottom': ['0px'], 'margin-left': ['0px'], 'padding-top': ['0px'], 'padding-right': ['0px'], 'padding-bottom': ['0px'], 'padding-left': ['0px'], 'vertical-align': ['baseline'], }; /** Table-ish elements get their borders folded in for email/Word fidelity. */ const BORDER_TAGS = new Set(['TABLE', 'TD', 'TH', 'THEAD', 'TBODY', 'TR']); /** Attributes worth preserving; everything else (class, contenteditable, * spellcheck, data-*, pm-*) is dropped from the exported markup. */ const KEEP_ATTRS = new Set([ 'href', 'src', 'alt', 'title', 'colspan', 'rowspan', 'width', 'height', 'align', 'target', 'rel', ]); function emitBorders(source: Element, style: CSSStyleDeclaration, clone: HTMLElement): void { if (!BORDER_TAGS.has(source.tagName)) return; const cs = getComputedStyle(source); const collapse = cs.getPropertyValue('border-collapse'); if (collapse === 'collapse') clone.style.setProperty('border-collapse', 'collapse'); for (const side of ['top', 'right', 'bottom', 'left']) { const width = cs.getPropertyValue(`border-${side}-width`); if (width && width !== '0px') { const st = cs.getPropertyValue(`border-${side}-style`) || 'solid'; const color = cs.getPropertyValue(`border-${side}-color`); clone.style.setProperty(`border-${side}`, `${width} ${st} ${color}`); } } void style; } /** Folds computed styles from a live source element into a detached clone. */ function styleElement(source: Element, clone: HTMLElement, parent: Element | null): void { const cs = getComputedStyle(source); const parentCs = parent ? getComputedStyle(parent) : null; for (const prop of INHERITED_PROPS) { const value = cs.getPropertyValue(prop); if (!value) continue; if (!parentCs || parentCs.getPropertyValue(prop) !== value) { clone.style.setProperty(prop, value); } } for (const [prop, defaults] of Object.entries(BOX_DEFAULTS)) { const value = cs.getPropertyValue(prop); if (value && !defaults.includes(value)) clone.style.setProperty(prop, value); } // List markers: emit the resolved bullet/number style on the list container. if (source.tagName === 'UL' || source.tagName === 'OL') { const marker = cs.getPropertyValue('list-style-type'); if (marker && marker !== 'none') clone.style.setProperty('list-style-type', marker); } // Images and embeds should never overflow the exported page/email column. if (source.tagName === 'IMG' || source.tagName === 'IFRAME') { clone.style.setProperty('max-width', '100%'); } emitBorders(source, cs, clone); // Prune attributes down to the structural allow-list, keeping only style. for (const attr of Array.from(clone.attributes)) { if (attr.name !== 'style' && !KEEP_ATTRS.has(attr.name)) clone.removeAttribute(attr.name); } } /** Recursively clones `source` into `target`'s document, inlining styles. */ function cloneWithStyles( source: Node, doc: Document, parent: Element | null ): Node | null { if (source.nodeType === Node.TEXT_NODE) { return doc.createTextNode(source.textContent ?? ''); } if (source.nodeType !== Node.ELEMENT_NODE) return null; const el = source as Element; // ProseMirror decorations (find-highlights, gap/drop cursors, trailing // break hacks) carry these classes — never part of the real content. if (el.classList.contains('ProseMirror-gapcursor')) return null; if (el.classList.contains('ProseMirror-separator')) return null; const clone = doc.createElement(el.tagName.toLowerCase()); for (const attr of Array.from(el.attributes)) clone.setAttribute(attr.name, attr.value); styleElement(el, clone as HTMLElement, parent); for (const child of Array.from(el.childNodes)) { const cloned = cloneWithStyles(child, doc, el); if (cloned) clone.appendChild(cloned); } return clone; } /** * Serializes the live content root to an HTML string with all styling folded * into inline `style` attributes. Suitable for pasting into email clients that * strip `' + '' + bodyHtml + '' ); } /** Options for the print-to-PDF document. */ export interface PrintOptions { title?: string; /** Optional running header text (top of every printed page). */ header?: string; /** Optional running footer text (bottom of every printed page). */ footer?: string; /** Base font family for the printed page. */ fontFamily?: string; } /** * Builds a standalone HTML document tuned for print-to-PDF. Page breaks, * embedded images and a chosen base font are honoured by the browser's print * engine; headers/footers render via a fixed running band on each page. */ export function buildPrintDocument(bodyHtml: string, options: PrintOptions = {}): string { const { title = 'Document', header = '', footer = '', fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', } = options; const runningBands = (header ? `
${escapeHtml(header)}
` : '') + (footer ? `` : ''); return ( '\n' + `${escapeHtml(title)}' + runningBands + `
${bodyHtml}
` + '' ); }