import { mapToNearestPresetColor } from '../../utils/color-mapping'; import { normalizeInlineMarkupIn } from '../../utils/inline-normalization'; import { isDefaultDarkBackground as isDefaultDarkBackgroundShared, isDefaultWhiteBackground as isDefaultWhiteBackgroundShared, isInvisibleBackground, } from '../../utils/default-page-colors'; import { COLUMNS_CANDIDATE_ATTR } from './constants'; import { parseUntrustedHtml } from '../../utils/inert-html'; import { trimTrailingBreaks } from '../../utils/trailing-breaks'; import { isSpacerParagraph } from '../../utils/spacer-paragraph'; /** * Pre-process Google Docs clipboard HTML before sanitization. * * Google Docs wraps content in `` and * encodes formatting as inline styles on `` elements rather than * semantic tags. The sanitizer strips `` (not in the allowed * config), destroying formatting. This function converts style-based * spans to ``/``/`` BEFORE the sanitizer runs. * * @param html - raw clipboard HTML string * @returns preprocessed HTML string */ export function preprocessGoogleDocsHtml(html: string): string { const wrapper = parseUntrustedHtml(html); const isGoogleDocs = unwrapGoogleDocsContent(wrapper); convertGoogleDocsStyles(wrapper, isGoogleDocs); if (isGoogleDocs) { unwrapLayoutSingleColumnTables(wrapper); } /** * Unwrapping `

` line-boundaries inside table cells is a property of the * cell HTML, not of the source app: Word, Notion exports, and generic web * tables all wrap each cell line in its own `

` exactly like Google Docs. * The sanitizer strips `

` (not in the allowed config), so if this only ran * for Google-Docs-flagged HTML every other source lost its in-cell line * breaks. Run it for all pasted HTML. */ convertTableCellParagraphs(wrapper); if (isGoogleDocs) { stampColumnsCandidateTables(wrapper); promoteImages(wrapper); } /** * The conversion above is per-``, because that is the only unit the * source gives us — and Google Docs writes a separate span for every text * run, including runs holding just a `
` or a single space. Collapse the * resulting identical neighbours here, at the point that creates them, so * every consumer of this function (paste and the CLI converter alike) gets * the collapsed form. */ normalizeInlineMarkupIn(wrapper); return wrapper.innerHTML; } /** * Promote every `` under the wrapper to a top-level sibling. * * Google Docs pastes images wrapped inside a `

` (often further nested * under ``s). The paste pipeline splits top-level siblings into * separate blocks, so an `` buried inside a `

` never gets a chance * to become its own image block. This splits each enclosing ancestor at * the image boundary so the `` ends up as a direct child of the * wrapper, with any before/after content preserved in clones of the * original ancestors. */ function findTopLevelAncestor(node: Element, wrapper: HTMLElement): Element | null { const parent = node.parentElement; if (parent === null) { return null; } return parent === wrapper ? node : findTopLevelAncestor(parent, wrapper); } /** * Shallow-clone `parent` and move every sibling before `pivot` into the clone. * Returns the clone (or null if it would be empty and `carry` is null). */ function buildBeforeHalf(parent: Element, pivot: Node, carry: Node | null): Element | null { const clone = parent.cloneNode(false) as Element; const siblings: Node[] = []; for (const child of Array.from(parent.childNodes)) { if (child === pivot) break; siblings.push(child); } siblings.forEach((sib) => clone.appendChild(sib)); if (carry !== null) { clone.appendChild(carry); } return clone.childNodes.length > 0 ? clone : null; } /** * Shallow-clone `parent` and move every sibling after `pivot` into the clone, * preceded by an inner `carry` node if present. */ function buildAfterHalf(parent: Element, pivot: Node, carry: Node | null): Element | null { const clone = parent.cloneNode(false) as Element; const allChildren = Array.from(parent.childNodes); const pivotIndex = allChildren.findIndex((child) => child === pivot); const siblings = allChildren.slice(pivotIndex + 1); if (carry !== null) { clone.appendChild(carry); } siblings.forEach((sib) => clone.appendChild(sib)); return clone.childNodes.length > 0 ? clone : null; } /** * Walk up from `img` to `topLevel`, splitting each ancestor at the image boundary. * Returns the before/after halves (clones of original ancestors, minus the image). */ function splitAncestorsAroundImage(img: Element, topLevel: Element): { before: Node | null; after: Node | null } { const reduce = (current: Element, before: Node | null, after: Node | null): { before: Node | null; after: Node | null } => { if (current === topLevel) return { before, after }; const parent = current.parentElement; if (parent === null) return { before, after }; const nextBefore = buildBeforeHalf(parent, current, before); const nextAfter = buildAfterHalf(parent, current, after); return reduce(parent, nextBefore, nextAfter); }; return reduce(img, null, null); } function hasTableAncestorWithin(node: Element, wrapper: HTMLElement): boolean { const parent = node.parentElement; if (parent === null || parent === wrapper) { return false; } if (parent.tagName === 'TABLE') { return true; } return hasTableAncestorWithin(parent, wrapper); } function promoteImages(wrapper: HTMLElement): void { const imgs = Array.from(wrapper.querySelectorAll('img')); for (const img of imgs) { /** * Images inside a must stay in their cell. Promoting them would * split the table around the image boundary, destroying the original * row/column layout and scattering the cell contents across half-tables. */ if (hasTableAncestorWithin(img, wrapper)) continue; const topLevel = findTopLevelAncestor(img, wrapper); if (!topLevel) continue; const { before, after } = splitAncestorsAroundImage(img, topLevel); const frag = document.createDocumentFragment(); if (before) frag.appendChild(before); frag.appendChild(img); if (after) frag.appendChild(after); topLevel.replaceWith(frag); } } /** * Strip Google Docs wrapper elements to expose underlying content. * Google Docs wraps clipboard HTML in ``. * Content may be split across multiple child `
` elements (e.g. one * per table), so all children are moved out of the wrapper. * * @returns true if Google Docs content was detected */ function unwrapGoogleDocsContent(wrapper: HTMLElement): boolean { const googleDocsWrapper = wrapper.querySelector('b[id^="docs-internal-guid-"]'); if (!googleDocsWrapper) { return false; } const fragment = document.createDocumentFragment(); while (googleDocsWrapper.firstChild) { fragment.appendChild(googleDocsWrapper.firstChild); } googleDocsWrapper.replaceWith(fragment); return true; } /** * Determine the background-color style declaration for a Google Docs element. * * When a background color is present, it is mapped to the nearest preset. * When only a foreground color is present, an explicit `transparent` background * is returned so the mark element doesn't inherit an unwanted background. */ function resolveBackgroundStyle(hasBgColor: boolean, hasColor: boolean, mappedBg: string): string { if (hasBgColor) { return `background-color: ${mappedBg}`; } if (hasColor) { return 'background-color: transparent'; } return ''; } /** * Check whether a CSS color value is the default black text color. * Google Docs uses different formats: `rgb(0, 0, 0)`, `rgb(0,0,0)`, or `#000000`. * Spans with only this color should not be converted to ``. */ function isDefaultBlack(color: string): boolean { const normalized = color.replace(/\s/g, ''); return normalized === 'rgb(0,0,0)' || normalized === '#000000'; } /** * Whether an element's text is entirely a link's text. * * Editors color link text with their own link color (Google Docs `#1155cc`, * Word `#0563c1`, …). Pasted links should always render with Blok's default * link color, so any color sitting on link text is dropped. This returns true * when the node is inside an ``, or wraps an `` that covers all of its * text — the two shapes clipboards emit for a colored link. A span that only * partially overlaps a link (surrounding text + link) is intentional text * formatting and returns false so its color survives. */ function isLinkContent(node: Element): boolean { if (node.closest('a') !== null) { return true; } const anchor = node.querySelector('a'); return anchor !== null && anchor.textContent?.trim() === node.textContent?.trim(); } /** * Compute the relative luminance of a CSS color value. * Supports rgb(), rgba(), hsl(), hsla(), and hex (#rrggbb / #rgb) formats. * Alpha components are ignored — only the base RGB channels are used. * Returns a value in [0, 1], or -1 if the format is unrecognized. * Uses simplified linear luminance (no gamma correction), adequate for * threshold comparisons at this scale. */ function computeRelativeLuminance(color: string): number { const normalized = color.replace(/\s/g, '').toLowerCase(); /* rgb() and rgba() — alpha component is optional and ignored */ const rgbMatch = /^rgba?\((\d+),(\d+),(\d+)(?:,[\d.]+)?\)$/.exec(normalized); if (rgbMatch) { const r = parseInt(rgbMatch[1], 10) / 255; const g = parseInt(rgbMatch[2], 10) / 255; const b = parseInt(rgbMatch[3], 10) / 255; return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /* hsl() and hsla() — alpha component is optional and ignored */ const hslMatch = /^hsla?\(([\d.]+),([\d.]+)%,([\d.]+)%(?:,[\d.]+)?\)$/.exec(normalized); if (hslMatch) { const h = parseFloat(hslMatch[1]) / 360; const s = parseFloat(hslMatch[2]) / 100; const l = parseFloat(hslMatch[3]) / 100; if (s === 0) { return 0.2126 * l + 0.7152 * l + 0.0722 * l; // achromatic: r = g = b = l } const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const hueToChannel = (t: number): number => { const wrapped = (() => { if (t < 0) return t + 1; if (t > 1) return t - 1; return t; })(); if (wrapped < 1 / 6) { return p + (q - p) * 6 * wrapped; } if (wrapped < 1 / 2) { return q; } if (wrapped < 2 / 3) { return p + (q - p) * (2 / 3 - wrapped) * 6; } return p; }; const r = hueToChannel(h + 1 / 3); const g = hueToChannel(h); const b = hueToChannel(h - 1 / 3); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } const hexMatch = /^#([0-9a-f]{6}|[0-9a-f]{3})$/.exec(normalized); if (hexMatch) { const hex = hexMatch[1]; const expand = hex.length === 3 ? [hex[0] + hex[0], hex[1] + hex[1], hex[2] + hex[2]] : [hex.substring(0, 2), hex.substring(2, 4), hex.substring(4, 6)]; const r = parseInt(expand[0], 16) / 255; const g = parseInt(expand[1], 16) / 255; const b = parseInt(expand[2], 16) / 255; return 0.2126 * r + 0.7152 * g + 0.0722 * b; } return -1; } /** * Re-exports for backward compatibility with table-cell-clipboard and other * callers that still import these helpers from this module. The canonical * implementations live in `src/components/utils/default-page-colors.ts`. */ export const isDefaultWhiteBackground = isDefaultWhiteBackgroundShared; export const isDefaultDarkBackground = isDefaultDarkBackgroundShared; /** * Check whether a CSS color value is a near-white (dark mode default text) color. * When the browser natively copies from a contenteditable in dark mode, it includes * the resolved light page text color (e.g. rgb(226, 224, 220) for Blok's #e2e0dc * default text). These should not be treated as intentional marker formatting for * non-Google-Docs content. * * Uses relative luminance > 0.75, which is above all Blok text presets while * catching typical dark mode default text colors. * * Returns false for unrecognized color formats (luminance === -1) so unknown * formats are treated conservatively: they are not filtered out here, but any * color that cannot be parsed also cannot be mapped to a preset, so the * sanitizer will strip it regardless. */ function isDefaultLightText(color: string): boolean { const luminance = computeRelativeLuminance(color); return luminance >= 0 && luminance > 0.75; } /** * Optionally wrap innerHTML in a `` with mapped color styles. * Returns the original content unchanged when no color formatting is needed. */ function buildMarkWrapper( innerHTML: string, hasColor: boolean, hasBgColor: boolean, color: string | undefined, bgColor: string | undefined ): string { if (!hasColor && !hasBgColor) { return innerHTML; } const mappedColor = hasColor && color !== undefined ? mapToNearestPresetColor(color, 'text') : ''; const mappedBg = hasBgColor && bgColor !== undefined ? mapToNearestPresetColor(bgColor, 'bg') : ''; const colorStyles = [ hasColor ? `color: ${mappedColor}` : '', resolveBackgroundStyle(hasBgColor, hasColor, mappedBg), ].filter(Boolean).join('; '); return colorStyles ? `${innerHTML}` : innerHTML; } /** * Convert a single style `` to semantic HTML. * * For Google Docs content, all non-transparent backgrounds are treated as * intentional formatting. For browser-native clipboard content, default * page values (black text, white background) are filtered out so computed * styles on plain text don't produce spurious `` elements. * * @returns replacement HTML string, or `null` if the span should be left as-is */ const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']); function hasHeadingAncestor(node: Element): boolean { const parent = node.parentElement; if (parent === null) { return false; } if (HEADING_TAGS.has(parent.tagName)) { return true; } return hasHeadingAncestor(parent); } function convertSpanToSemanticHtml(span: Element, isGoogleDocs: boolean): string | null { const style = span.getAttribute('style') ?? ''; /** * Headings are already rendered bold. A span with font-weight:700 inside a * heading is the default weight Google Docs writes — treating it as bold * would wrap the text in ``, producing `` in saved data and * visibly doubling the weight. */ const isBold = /font-weight\s*:\s*(700|bold)/i.test(style) && !hasHeadingAncestor(span); const isItalic = /font-style\s*:\s*italic/i.test(style); const colorMatch = /(? — pasted links always use the default * link color regardless of the source's color. */ const isLinkColor = color !== undefined && isLinkContent(span); const hasColor = !isLinkColor && (isGoogleDocs ? color !== undefined && !isDefaultBlack(color) : color !== undefined && !isDefaultBlack(color) && !isDefaultLightText(color)); /** * Invisible backgrounds (transparent, near-white light-page bg, near-black * dark-page bg) are filtered for both branches. Google Docs writes the page * background-color onto every in the clipboard payload — without this * filter, the nearest-preset color mapping would attach a default-bg styled * to every paragraph. */ const hasBgColor = bgColor !== undefined && !isInvisibleBackground(bgColor); if (!isBold && !isItalic && !hasColor && !hasBgColor) { return null; } const inner = buildMarkWrapper(span.innerHTML, hasColor, hasBgColor, color, bgColor); const italic = isItalic ? `${inner}` : inner; return isBold ? `${italic}` : italic; } /** * Convert Google Docs style-based `` elements to semantic HTML tags. * * - `` or `font-weight:bold` → `` * - `` → `` * - `` → `` * - `` → `` * * Color and bold/italic can combine: a bold red span becomes `text`. */ function convertGoogleDocsStyles(wrapper: HTMLElement, isGoogleDocs: boolean): void { for (const span of Array.from(wrapper.querySelectorAll('span[style]'))) { const replacement = convertSpanToSemanticHtml(span, isGoogleDocs); if (replacement !== null) { // Range anchored in span's own (inert) document: a live range parses the // fragment with a browsing context, so its would load right here. span.replaceWith(span.ownerDocument.createRange().createContextualFragment(replacement)); } } if (isGoogleDocs) { convertAnchorColorStyles(wrapper); } } /** * Move background-color styles on `` elements into `` wrappers. * * Google Docs sometimes puts background-color directly on the `` element. * The sanitizer only allows `href`/`target`/`rel` on ``, so inline styles * are stripped — losing the background. This moves the background into a * `` wrapping the link content before sanitization runs. * * A `color` on the `` is the link's own color and is dropped: pasted links * always render with Blok's default link color. */ function convertAnchorColorStyles(wrapper: HTMLElement): void { for (const anchor of Array.from(wrapper.querySelectorAll('a[style]'))) { const style = anchor.getAttribute('style') ?? ''; const bgMatch = /background-color\s*:\s*([^;]+)/i.exec(style); const bgColor = bgMatch?.[1]?.trim(); const hasBgColor = bgColor !== undefined && bgColor !== 'transparent' && bgColor !== 'inherit'; const el = anchor as HTMLElement; el.style.removeProperty('color'); if (!hasBgColor) { continue; } const mappedBg = mapToNearestPresetColor(bgColor, 'bg'); el.innerHTML = `${el.innerHTML}`; el.style.removeProperty('background-color'); } } /** * The table's own rows (nested tables' rows excluded) as arrays of TD/TH * cells, in document order. */ function ownRowCells(table: HTMLTableElement): HTMLElement[][] { return Array.from(table.querySelectorAll('tr')) .filter((row) => row.closest('table') === table) .map((row) => Array.from(row.children) .filter((child): child is HTMLElement => child.tagName === 'TD' || child.tagName === 'TH')); } /** * Unwrap single-column LAYOUT tables into plain top-level content. * * A one-column table can never become a `column_list` (the editor dissolves * single-column lists), so tables the editorial rules classify as layout — * a lone-cell "callout box" (any content) or a multi-row single-column stack * holding a photo — are unwrapped instead: the `
` is replaced by each * cell's children in row order. Text-only multi-row single-column tables are * genuinely tabular and stay tables. Sheets pastes and nested tables are * never touched. * * MUST run before `convertTableCellParagraphs` (so freed `

`s become * top-level paragraphs that split into separate blocks, not `
` runs) and * before `promoteImages` (so freed images get promoted like any other). */ function unwrapLayoutSingleColumnTables(wrapper: HTMLElement): void { if (wrapper.querySelector('google-sheets-html-origin') !== null) { return; } for (const table of Array.from(wrapper.querySelectorAll('table'))) { if (table.parentElement?.closest('table') !== null) { continue; } const rows = ownRowCells(table); if (rows.length === 0 || rows.some((cells) => cells.length !== 1)) { continue; } const isLayout = rows.length === 1 || table.querySelector('img') !== null; if (!isLayout) { continue; } table.replaceWith(...rows.flatMap(([cell]) => Array.from(cell.childNodes))); } } /** * Stamp Google Docs tables that are columns LAYOUTS (not tabular data) as * columns candidates. Docs has no native column layout, so writers fake * columns with a table; the HTML paste handler expands stamped tables into * `column_list`/`column` blocks instead of a table block (each table column's * cells stack top-to-bottom inside one Blok column). * * The editorial rules for what counts as layout: * - a single-row table with 2 or 3 cells, whatever the content; * - a multi-row uniform 2-column table containing at least one image * (photo+text side-by-side). Text-only multi-row tables (e.g. the * what/where/for-whom "summary" table) are genuinely tabular, and so are * multi-row 3-column tables even with photos (a 3-across photo grid). * * Never stamped: ragged rows or merged cells (tabular data), Google Sheets * pastes (a selection there is tabular data, marked by * ``), and tables nested inside another table * (columns cannot live in a table cell). */ function stampColumnsCandidateTables(wrapper: HTMLElement): void { if (wrapper.querySelector('google-sheets-html-origin') !== null) { return; } for (const table of Array.from(wrapper.querySelectorAll('table'))) { if (table.parentElement?.closest('table') !== null) { continue; } const rows = ownRowCells(table); if (rows.length === 0) { continue; } const columnCount = rows[0].length; const isUniform = rows.every((cells) => cells.length === columnCount); if (!isUniform) { continue; } const isSingleRowLayout = rows.length === 1 && (columnCount === 2 || columnCount === 3); const isPhotoTextLayout = rows.length > 1 && columnCount === 2 && table.querySelector('img') !== null; if (isSingleRowLayout || isPhotoTextLayout) { table.setAttribute(COLUMNS_CANDIDATE_ATTR, ''); } } } /** * Convert `

` boundaries to `
` line breaks inside table cells. * * Editors wrap each line in a cell as a separate `

`. The sanitizer strips * `

` (not in the allowed config), losing line breaks. Converting to `
` * preserves them since `
` IS in the config (`{ br: {} }`). * * Empty / nbsp-only paragraphs are visual spacers, not content lines — they are * dropped rather than turned into a stray blank `
` line in the cell. * * Only targets `

` and `` elements — top-level `

` tags are left * intact so the paste pipeline can split them into separate blocks. */ /** Replace a cell `

` with its content + a `
`, or drop it if it is a spacer. */ function unwrapCellParagraph(p: Element): void { if (isSpacerParagraph(p)) { p.remove(); return; } const fragment = p.ownerDocument.createRange().createContextualFragment(p.innerHTML + '
'); p.replaceWith(fragment); } function convertTableCellParagraphs(wrapper: HTMLElement): void { for (const cell of Array.from(wrapper.querySelectorAll('td, th'))) { const paragraphs = cell.querySelectorAll('p'); if (paragraphs.length === 0) { continue; } Array.from(paragraphs).forEach(unwrapCellParagraph); // Remove trailing
from the cell cell.innerHTML = trimTrailingBreaks(cell.innerHTML); } }