// Clipboard HTML is parsed in an inert document, never inserted into the live DOM.
export function isWordHtml(html) {
return /\bmso-|class=["']?Mso|urn:schemas-microsoft-com:office|Microsoft Word/i.test(html);
}
export function cleanWordHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script,style,meta,link,iframe,object,embed,svg,math,form,input,button').forEach(node => node.remove());
const comments = doc.createTreeWalker(doc.body, NodeFilter.SHOW_COMMENT);
const remove = [];
while (comments.nextNode()) { remove.push(comments.currentNode); }
remove.forEach(node => node.remove());
doc.querySelectorAll('p').forEach(node => {
const level = (node.className || '').match(/\bMsoHeading([1-6])\b/i) || (node.getAttribute('style') || '').match(/mso-outline-level:\s*([1-6])\b/i);
if (!level) { return; }
const heading = doc.createElement('h' + level[1]);
while (node.firstChild) { heading.appendChild(node.firstChild); }
node.replaceWith(heading);
});
// Desktop Word exports list items as paragraphs with mso-list and marker spans.
const lists = new Map();
doc.querySelectorAll('p').forEach(node => {
const style = node.getAttribute('style') || '';
const match = style.match(/mso-list:\s*(l\d+)\s+level(\d+)\s+(lfo\d+)/i);
if (!match) { return; }
const marker = Array.from(node.querySelectorAll('span')).find(span => /mso-list:\s*ignore/i.test(span.getAttribute('style') || ''));
const label = marker ? marker.textContent.trim() : '';
const ordered = /^(?:\d+|[a-z]+)[.)]/i.test(label);
lists.set(node, { key: match[1] + match[3], level: Number(match[2]), type: ordered ? 'ol' : 'ul', start: /^\d+/.test(label) ? parseInt(label, 10) : 1 });
if (marker) { marker.remove(); }
});
const parents = new Set(Array.from(lists.keys()).map(node => node.parentNode));
parents.forEach(parent => {
let stack = [];
let key = '';
Array.from(parent.childNodes).forEach(node => {
if (node.nodeType === 3 && !node.textContent.trim()) { return; }
const item = lists.get(node);
if (!item) { stack = []; key = ''; return; }
if (key !== item.key) { stack = []; key = item.key; }
while (stack.length && stack[stack.length - 1].level > item.level) { stack.pop(); }
if (stack.length && stack[stack.length - 1].level === item.level && stack[stack.length - 1].type !== item.type) { stack.pop(); }
if (!stack.length || stack[stack.length - 1].level < item.level) {
const list = doc.createElement(item.type);
if (item.type === 'ol' && item.start > 1) { list.setAttribute('start', item.start); }
if (stack.length) { stack[stack.length - 1].last.appendChild(list); }
else { parent.insertBefore(list, node); }
stack.push({ level: item.level, type: item.type, list: list, last: null });
}
const li = doc.createElement('li');
const entry = stack[stack.length - 1];
entry.list.appendChild(li);
li.appendChild(node);
entry.last = li;
});
});
const allowed = new Set('p div br h1 h2 h3 h4 h5 h6 strong b em i u s strike del blockquote pre code ul ol li a table thead tbody tfoot tr td th hr'.split(' '));
Array.from(doc.body.querySelectorAll('*')).reverse().forEach(node => {
if (!node.parentNode) { return; }
const tag = node.tagName.toLowerCase();
// Convert meaningful inline formatting before stripping presentation attributes.
const marks = [];
const weight = node.style.fontWeight;
if (weight === 'bold' || Number(weight) >= 600) { marks.push('strong'); }
if (node.style.fontStyle === 'italic') { marks.push('em'); }
const decoration = node.style.textDecoration;
if (/underline/.test(decoration)) { marks.push('u'); }
if (/line-through/.test(decoration)) { marks.push('s'); }
marks.forEach(mark => {
const wrapper = doc.createElement(mark);
while (node.firstChild) { wrapper.appendChild(node.firstChild); }
node.appendChild(wrapper);
});
Array.from(node.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
const safeLink = tag === 'a' && name === 'href' && /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(value) && !/[\u0000-\u0020]/.test(value);
const span = /^(td|th)$/.test(tag) && /^(colspan|rowspan)$/.test(name) && /^[1-9]\d{0,2}$/.test(value);
const start = tag === 'ol' && name === 'start' && /^[1-9]\d{0,5}$/.test(value);
if (!safeLink && !span && !start) { node.removeAttribute(attr.name); }
});
if (!allowed.has(tag)) {
node.replaceWith(...Array.from(node.childNodes));
}
});
return doc.body.innerHTML;
}