/**
* 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 +
'