import { Schema, NodeSpec, MarkSpec, Attrs, DOMOutputSpec, ParseRule } from 'prosemirror-model'; import { addListNodes } from 'prosemirror-schema-list'; import { tableNodes } from 'prosemirror-tables'; import OrderedMap from 'orderedmap'; import { EMBED_SANDBOX, EMBED_REFERRER_POLICY } from './plugins/embeds'; import { sanitizeExtraStyleForWrite } from './email-css'; import { sanitizeTableTextStyleForWrite } from './sanitize'; export type Alignment = 'left' | 'center' | 'right' | 'justify'; const ALIGN_VALUES: Alignment[] = ['left', 'center', 'right', 'justify']; /** Keywords a cell's `text-align` attr may hold (email-fidelity). */ const CELL_ALIGN_KEYWORDS = new Set(['left', 'center', 'right', 'justify', 'start', 'end']); function getAlignFromDom(dom: HTMLElement): Alignment | null { const value = dom.style.textAlign || dom.getAttribute('align') || ''; return (ALIGN_VALUES as string[]).includes(value) ? (value as Alignment) : null; } function alignStyle(align: Alignment | null): string | null { return align ? `text-align: ${align}` : null; } /** Image/figure width: a number is px, a `%` string a percentage. */ const IMAGE_PERCENT = /^(\d{1,3}(?:\.\d{1,2})?)%$/; export function imageWidthCss(width: number | string | null): string | null { if (typeof width === 'number') return width > 0 ? `${width}px` : null; if (typeof width === 'string') { const match = IMAGE_PERCENT.exec(width.trim()); if (match) { const n = parseFloat(match[1]); if (n > 0 && n <= 100) return width.trim(); } } return null; } /** Reads an img/figure width: a valid `%` keeps the string, else px rounds. */ function imageWidthFromDom(styleWidth: string, attrWidth?: string): number | string | null { const trimmed = styleWidth.trim(); // A percentage style width is kept only in range; an out-of-range/invalid one // (e.g. '150%') is dropped rather than silently reinterpreted as px. if (trimmed.endsWith('%')) { const match = IMAGE_PERCENT.exec(trimmed); if (match) { const n = parseFloat(match[1]); if (n > 0 && n <= 100) return trimmed; } return null; } const sw = parseFloat(styleWidth); const aw = parseFloat(attrWidth ?? ''); const px = Number.isFinite(sw) ? Math.round(sw) : Number.isFinite(aw) ? Math.round(aw) : null; return px && px > 0 ? px : null; } /** Indent depth in 40px steps, like CKEditor/Google Docs. */ export const INDENT_STEP_PX = 40; export const MAX_INDENT = 8; function getIndentFromDom(dom: HTMLElement): number { const raw = dom.style.marginInlineStart || dom.style.marginLeft || ''; const px = parseFloat(raw); if (!Number.isFinite(px) || px <= 0) return 0; return Math.min(MAX_INDENT, Math.round(px / INDENT_STEP_PX)); } /** * Line height as a unitless multiplier string ('1.5'). Percentages are * converted; px values pass through as-is so pasted content keeps its look. */ function getLineHeightFromDom(dom: HTMLElement): string | null { const raw = dom.style.lineHeight.trim(); if (!raw || raw === 'normal') return null; const num = parseFloat(raw); if (!Number.isFinite(num) || num <= 0) return null; if (raw.endsWith('%')) return String(num / 100); return raw; } /** Fidelity block margin, stored as a ready-to-emit declaration string. */ function getMarginFromDom(dom: HTMLElement): string | null { const s = dom.style; if (s.margin) return `margin: ${s.margin}`; const parts: string[] = []; if (s.marginTop) parts.push(`margin-top: ${s.marginTop}`); if (s.marginBottom) parts.push(`margin-bottom: ${s.marginBottom}`); return parts.length ? parts.join('; ') : null; } /** Combined style string for margin + align + indent + line-height textblock attrs. */ function blockStyle(attrs: { align?: Alignment | null; indent?: number; lineHeight?: string | null; margin?: string | null; extraStyle?: string | null; }): string | null { const parts: string[] = []; if (attrs.margin) parts.push(attrs.margin); const align = alignStyle(attrs.align ?? null); if (align) parts.push(align); if (attrs.indent) parts.push(`margin-inline-start: ${attrs.indent * INDENT_STEP_PX}px`); if (attrs.lineHeight) parts.push(`line-height: ${attrs.lineHeight}`); // Per-app extension declarations, re-validated against the permanent denylist // + value grammar (the singleton schema can't see the per-app allowlist). if (attrs.extraStyle) { const safe = sanitizeExtraStyleForWrite(attrs.extraStyle); if (safe) parts.push(safe); } return parts.length ? parts.join('; ') : null; } /* ------------------------------------------------------------------------- * Author class / id passthrough * * Content elements keep the `class` and `id` an author gave them, so class/id * CSS (e.g. a scoped author