/**
* Paste-from-Office cleanup. Word and Google Docs clipboard HTML carries
* heavy proprietary markup — conditional comments, o:/w:/v: namespaced tags,
* mso-* styles, and (in Word's case) lists faked as styled paragraphs. This
* pre-pass normalizes that into plain HTML *before* the sanitizer runs, so
* the schema receives real
/ structures instead of paragraph soup.
*/
/** Does this clipboard HTML come from Word, Outlook or Google Docs? */
export function isOfficeHtml(html: string): boolean {
return (
/class="?Mso|style=["'][^"']*mso-|urn:schemas-microsoft-com||]*Excel\.Sheet|('td, th'))) {
const bg = cell.getAttribute('bgcolor');
if (bg && !/background/i.test(cell.getAttribute('style') ?? '')) {
cell.style.backgroundColor = bg;
}
const align = cell.getAttribute('align') || cell.style.textAlign;
const hasBlock = cell.querySelector('p, div, ul, ol, table, h1, h2, h3, h4, h5, h6');
if (!hasBlock && cell.childNodes.length > 0) {
const p = doc.createElement('p');
if (align) p.style.textAlign = align;
while (cell.firstChild) p.appendChild(cell.firstChild);
cell.appendChild(p);
}
}
}
interface WordListItem {
level: number;
ordered: boolean;
paragraph: HTMLElement;
}
/** Word marks list paragraphs with an `mso-list: lX levelY` style. */
function wordListInfo(el: HTMLElement): { level: number } | null {
const style = el.getAttribute('style') ?? '';
const match = /mso-list:[^;"]*\blevel(\d+)/i.exec(style);
if (match) return { level: Number(match[1]) };
// Some Word variants only set the class.
if (/\bMsoListParagraph/i.test(el.className) && /mso-list/i.test(style)) {
return { level: 1 };
}
return null;
}
/**
* The bullet/number marker Word renders as a literal span. Ordered markers
* look like "1." / "a)" / "iv."; bullets are symbol characters (·, o, §).
*/
function extractWordMarker(paragraph: HTMLElement): { ordered: boolean } {
let ordered = false;
// The marker lives in a span with mso-list:Ignore (or inside the
// conditional comment we already stripped, leaving the bare text).
const spans = paragraph.querySelectorAll('span');
for (const span of Array.from(spans)) {
if (/mso-list:\s*ignore/i.test(span.getAttribute('style') ?? '')) {
ordered = /^\s*(\d+|[a-z]{1,4}|[ivxlc]+)[.)]/i.test(span.textContent ?? '');
span.remove();
return { ordered };
}
}
// Fallback: leading "1." / "a)" text in the paragraph itself.
const text = paragraph.textContent ?? '';
const lead = /^\s*(\d+|[a-z])[.)]\s+/i.exec(text);
if (lead) {
ordered = true;
const first = paragraph.firstChild;
if (first?.nodeType === Node.TEXT_NODE) {
first.textContent = (first.textContent ?? '').replace(/^\s*\S+[.)]\s+/, '');
}
}
return { ordered };
}
/** Converts consecutive Word list paragraphs into real nested ul/ol lists. */
function convertWordLists(body: HTMLElement): void {
const doc = body.ownerDocument;
const paragraphs = Array.from(body.querySelectorAll('p'));
let run: WordListItem[] = [];
const flush = (): void => {
if (run.length === 0) return;
const anchor = run[0].paragraph;
// Build nested lists by level (Word levels start at 1).
const roots: HTMLElement[] = [];
const stack: { level: number; list: HTMLElement }[] = [];
for (const item of run) {
while (stack.length > 0 && stack[stack.length - 1].level > item.level) {
stack.pop();
}
if (stack.length === 0 || stack[stack.length - 1].level < item.level) {
const list = doc.createElement(item.ordered ? 'ol' : 'ul');
if (stack.length === 0) {
roots.push(list);
} else {
const parentItems = stack[stack.length - 1].list.children;
const parentLi = parentItems[parentItems.length - 1] ?? null;
(parentLi ?? stack[stack.length - 1].list).appendChild(list);
}
stack.push({ level: item.level, list });
}
const li = doc.createElement('li');
const p = doc.createElement('p');
p.innerHTML = item.paragraph.innerHTML;
li.appendChild(p);
stack[stack.length - 1].list.appendChild(li);
}
anchor.before(...roots);
for (const item of run) item.paragraph.remove();
run = [];
};
for (const paragraph of paragraphs) {
const info = wordListInfo(paragraph);
if (info) {
const { ordered } = extractWordMarker(paragraph);
run.push({ level: info.level, ordered, paragraph });
} else {
flush();
}
}
flush();
}
/** Normalizes Word/Google Docs clipboard HTML into plain semantic HTML. */
export function cleanOfficeHtml(html: string): string {
// Conditional comments carry list markers and VML — drop them first, while
// this is still a string ("…" variants too).
let cleaned = html
.replace(//gi, '')
.replace(//gi, '')
.replace(//g, '');
const doc = new DOMParser().parseFromString(cleaned, 'text/html');
const body = doc.body;
// Namespaced Office elements (, , ): unwrap, keeping text.
for (const el of Array.from(body.querySelectorAll('*'))) {
if (el.tagName.includes(':')) {
el.replaceWith(...Array.from(el.childNodes));
}
}
// Google Docs wraps the payload in — unwrap so nothing reads it as bold.
for (const el of Array.from(body.querySelectorAll('b[id^="docs-internal-guid"]'))) {
el.replaceWith(...Array.from(el.childNodes));
}
convertWordLists(body);
// Excel: preserve cell background and per-cell text alignment.
if (isExcelHtml(html)) convertExcelCells(body);
// Strip Mso classes and mso-* style declarations; the sanitizer would drop
// most of this anyway, but clearing it here keeps parsing predictable.
for (const el of Array.from(body.querySelectorAll('*'))) {
if (/\bMso/i.test(el.className)) el.removeAttribute('class');
const style = el.getAttribute('style');
if (style && /mso-/i.test(style)) {
const kept = style
.split(';')
.filter(decl => decl.trim() && !/^\s*mso-/i.test(decl))
.join(';');
if (kept.trim()) el.setAttribute('style', kept);
else el.removeAttribute('style');
}
}
// Empty paragraphs Word uses for spacing (only inside).
for (const p of Array.from(body.querySelectorAll('p'))) {
if ((p.textContent ?? '').replace(/ /g, '').trim() === '' && !p.querySelector('img')) {
p.remove();
}
}
return body.innerHTML;
}