suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
857 lines (711 loc) • 24.4 kB
JavaScript
/**
* @description Converts MS Word/Excel/OneNote clipboard HTML into clean.
*/
/** Matches MS conditional comments: <!--[if ...]>...<![endif]--> */
const _RE_CONDITIONAL_COMMENTS = /<!--\[if[^>]*>[\s\S]*?<!\[endif\]-->/gi;
/** Matches Office namespace tags: <o:p>, </o:p>, <w:Sdt>, etc. */
const _RE_OFFICE_TAGS = /<\/?(?:\w+:)\w+[^>]*>/gi;
/** Matches <xml>...</xml> blocks */
const _RE_XML_BLOCKS = /<xml>[\s\S]*?<\/xml>/gi;
/** Matches <style>...</style> blocks */
const _RE_STYLE_BLOCKS = /<style[^>]*>[\s\S]*?<\/style>/gi;
/** Matches individual mso-* CSS declarations */
const _RE_MSO_STYLE = /\s*mso-[^:]+:[^;"]+;?/gi;
/** Matches mso-list value: l{id} level{n} lfo{order} */
const _RE_MSO_LIST = /l(\d+)\s+level(\d+)\s+lfo(\d+)/i;
/** Matches mso-list:Ignore markers */
const _RE_MSO_LIST_IGNORE = /mso-list:\s*Ignore/i;
/** Matches Mso* class names */
const _RE_MSO_CLASS = /\bMso\w+/g;
/** Matches Excel xl* class names */
const _RE_XL_CLASS = /\bxl\d+/g;
/** Matches tab-stops CSS declarations */
const _RE_TAB_STOPS = /\s*tab-stops:[^;"]+;?/gi;
/** Matches bullet-like characters at the start of text */
const _RE_BULLET_CHARS = /^[\s\u00a0]*[\u2022\u00b7\u00a7\u25cf\u25cb\u25aa\u25a0·o§]\s*/;
/** Matches ordered list marker patterns */
const _RE_ORDERED_MARKERS = [/^\s*[0-9]+[.)]\s/, /^\s*[a-z][.)]\s/i, /^\s*[ivxlmcd]+[.)]\s/i];
/** Matches mso-spacerun spans */
const _RE_SPACERUN = /<span\s+style\s*=\s*["']?\s*mso-spacerun:\s*yes\s*;?\s*["']?\s*>([\s\u00a0]*)<\/span>/gi;
/** Matches mso-tab-count spans */
const _RE_TAB_COUNT = /<span\s+style\s*=\s*["'][^"']*mso-tab-count:\s*\d+[^"']*["']\s*>[^<]*<\/span>/gi;
/** Matches mso-outline-level value */
const _RE_OUTLINE_LEVEL = /mso-outline-level:\s*(\d+)/i;
/** Matches mso-highlight value */
const _RE_MSO_HIGHLIGHT = /mso-highlight:\s*([^;"]+)/i;
/** Matches mso-level-number-format value */
const _RE_LEVEL_NUMBER_FORMAT = /mso-level-number-format:\s*([^;\s]+)/i;
/** Matches mso-level-start-at value */
const _RE_LEVEL_START_AT = /mso-level-start-at:\s*(\d+)/i;
/** Matches mso-level-text value */
const _RE_LEVEL_TEXT = /mso-level-text:\s*([^;\s]+)/i;
/** Word number format → CSS list-style-type */
const _LEVEL_STYLE_MAP = {
decimal: 'decimal',
'alpha-upper': 'upper-alpha',
'alpha-lower': 'lower-alpha',
'roman-upper': 'upper-roman',
'roman-lower': 'lower-roman',
'arabic-leading-zero': 'decimal-leading-zero',
};
/** Matches zero-value margin shorthand (3 values) */
const _RE_ZERO_MARGIN = /\s*margin:\s*0[a-z]*\s+0[a-z]*\s+0[a-z]*\s*;?/gi;
/** Matches text-indent CSS declarations */
const _RE_TEXT_INDENT = /\s*text-indent:\s*-?[\d.]+[a-z]*\s*;?/gi;
/** Matches line-height: normal */
const _RE_LINE_HEIGHT_NORMAL = /\s*line-height:\s*normal\s*;?/gi;
/** Matches MsoHeading class names */
const _RE_MSO_HEADING = /MsoHeading(\d)/i;
/** Matches file:/// protocol */
const _RE_FILE_PROTOCOL = /^file:\/\/\//i;
/** Matches Word bookmark anchor names (_Toc*, _Ref*, _Hlt*, _Hlk*) */
const _RE_BOOKMARK_NAME = /^_(Toc|Ref|Hlt|Hlk)\d+$/i;
/** Matches Word bookmark href targets (#_Toc*, etc.) */
const _RE_BOOKMARK_HREF = /^#_(Toc|Ref|Hlt|Hlk)\d+$/i;
/** Matches page-break-before: always */
const _RE_PAGE_BREAK = /page-break-before\s*:\s*always/i;
/** Matches mso-break-type: section-break */
const _RE_SECTION_BREAK = /mso-break-type\s*:\s*section-break/i;
/** Matches mso-column-break-before: always */
const _RE_COLUMN_BREAK = /mso-column-break-before\s*:\s*always/i;
/** Matches Word/WordSection div class names */
const _RE_SECTION_CLASS = /^(?:Word)?Section\d+$/i;
// ---------- internal helpers ----------
/**
* @description Extracts `@list` rules from Word's <style> block for list type detection.
* @param {string} html Raw Word HTML
* @returns {Map<string, Object>} Map of "l{id}:level{n}" -> { type: 'ol'|'ul', listStyleType: string }
*/
function _extractListStyles(html) {
const map = new Map();
const styleMatch = html.match(/<style[^>]*>([\s\S]*?)<\/style>/i);
if (!styleMatch) return map;
const css = styleMatch[1];
const listRuleRe = /@list\s+l(\d+):level(\d+)\s*\{([^}]*)\}/gi;
let m;
while ((m = listRuleRe.exec(css)) !== null) {
const id = m[1];
const level = m[2];
const body = m[3];
const key = `l${id}:level${level}`;
const fmt = _RE_LEVEL_NUMBER_FORMAT.exec(body);
const startAt = _RE_LEVEL_START_AT.exec(body);
const text = _RE_LEVEL_TEXT.exec(body);
let type = 'ol';
let listStyleType = '';
if (fmt) {
const fmtVal = fmt[1].toLowerCase();
if (fmtVal === 'bullet' || fmtVal === 'image') {
type = 'ul';
if (text) {
const t = text[1].replace(/["%\\]/g, '');
if (t === '\u00b7' || t === '·') listStyleType = 'disc';
else if (t === 'o') listStyleType = 'circle';
else if (t === '\u00a7' || t === '§') listStyleType = 'square';
}
} else {
listStyleType = _LEVEL_STYLE_MAP[fmtVal] || '';
}
}
map.set(key, {
type,
listStyleType,
startAt: startAt ? parseInt(startAt[1], 10) : 1,
});
}
return map;
}
/**
* @description Detects whether a list item is ordered or unordered by inspecting its marker text content.
* @param {string} text The marker text (from mso-list:Ignore span)
* @returns {'ol'|'ul'}
*/
function _detectListTypeFromText(text) {
const trimmed = text.replace(/[\s\u00a0]+/g, ' ').trim();
if (_RE_BULLET_CHARS.test(trimmed)) return 'ul';
for (const re of _RE_ORDERED_MARKERS) {
if (re.test(trimmed)) return 'ol';
}
return 'ul';
}
/**
* @description Removes bullet/number marker spans (mso-list:Ignore) and conditional comments from a list item element.
* @param {Element} el
*/
function _removeListMarkers(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_COMMENT, null);
const commentsToRemove = [];
while (walker.nextNode()) {
commentsToRemove.push(walker.currentNode);
}
for (const c of commentsToRemove) {
/** @type {Element} */ (c)?.remove();
}
const spans = el.querySelectorAll('span');
for (const span of spans) {
const style = span.getAttribute('style') || '';
if (_RE_MSO_LIST_IGNORE.test(style)) {
span.remove();
}
}
if (el.firstChild && el.firstChild.nodeType === 3) {
el.firstChild.nodeValue = el.firstChild.nodeValue.replace(/^[\s\u00a0]+/, '');
}
}
/**
* @description Cleans inline style attributes: strips mso-*, tab-stops, converts mso-highlight to background-color.
* @param {Element} el
*/
function _cleanStyles(el) {
const style = el.getAttribute('style');
if (!style) return;
let cleaned = style;
// Convert mso-highlight to background-color before stripping mso-*
const highlightMatch = _RE_MSO_HIGHLIGHT.exec(cleaned);
if (highlightMatch && !/background-color/i.test(cleaned) && !/background\s*:/i.test(cleaned)) {
cleaned += ';background-color:' + highlightMatch[1].trim();
}
cleaned = cleaned
.replace(_RE_MSO_STYLE, '')
.replace(_RE_TAB_STOPS, '')
.replace(_RE_ZERO_MARGIN, '')
.replace(_RE_TEXT_INDENT, '')
.replace(_RE_LINE_HEIGHT_NORMAL, '')
.trim();
// Remove trailing/leading semicolons
cleaned = cleaned
.replace(/^;+|;+$/g, '')
.replace(/;{2,}/g, ';')
.trim();
if (cleaned) {
el.setAttribute('style', cleaned);
} else {
el.removeAttribute('style');
}
}
/**
* @description Removes Mso- and xl- class names from an element. Removes class attr if empty.
* @param {Element} el
*/
function _cleanClasses(el) {
const cls = el.getAttribute('class');
if (!cls) return;
const cleaned = cls.replace(_RE_MSO_CLASS, '').replace(_RE_XL_CLASS, '').trim();
if (cleaned) {
el.setAttribute('class', cleaned);
} else {
el.removeAttribute('class');
}
}
/**
* @description Unwraps spans that have no attributes left after cleanup.
* @param {Element} container
*/
function _unwrapEmptySpans(container) {
const spans = container.querySelectorAll('span');
for (let i = spans.length - 1; i >= 0; i--) {
const span = spans[i];
if (!span.attributes.length || (!span.getAttribute('style') && !span.getAttribute('class'))) {
const parent = span.parentNode;
if (parent) {
while (span.firstChild) {
parent.insertBefore(span.firstChild, span);
}
parent.removeChild(span);
}
}
}
}
// ---------- list conversion ----------
/**
* @description Transforms flat MsoListParagraph paragraphs into nested ol/ul/li structure.
* @param {Document} doc Parsed DOM document
* @param {Map<string, Object>} listStyles Extracted @list CSS rules
*/
function _convertLists(doc, listStyles) {
const body = doc.body;
const items = [];
const allElements = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6');
for (const el of allElements) {
const style = el.getAttribute('style') || '';
const cls = el.getAttribute('class') || '';
const msoListMatch = _RE_MSO_LIST.exec(style);
if (msoListMatch || /MsoListParagraph/i.test(cls)) {
items.push({
el,
listId: msoListMatch ? msoListMatch[1] : '0',
level: msoListMatch ? parseInt(msoListMatch[2], 10) : 1,
lfo: msoListMatch ? msoListMatch[3] : '1',
});
}
}
if (items.length === 0) return;
const groups = [];
let currentGroup = null;
for (const item of items) {
const prevSibling = _getPrevSiblingElement(item.el);
const isConsecutive =
currentGroup && currentGroup.length > 0 && prevSibling === currentGroup[currentGroup.length - 1].el;
if (isConsecutive) {
currentGroup.push(item);
} else {
currentGroup = [item];
groups.push(currentGroup);
}
}
for (const group of groups) {
_buildListFromGroup(group, listStyles);
}
}
/**
* @description Gets the previous element sibling, skipping text nodes.
* @param {Element} el
* @returns {Element|null}
*/
function _getPrevSiblingElement(el) {
let prev = el.previousSibling;
while (prev) {
if (prev.nodeType === 1) return /** @type {Element} */ (prev);
if (prev.nodeType === 3 && prev.nodeValue.trim()) return null;
prev = prev.previousSibling;
}
return null;
}
/**
* @description Builds a proper list (ol/ul with li) from a consecutive group of Word list paragraphs.
* @param {Array<Object>} group Array of { el, listId, level, lfo }
* @param {Map<string, Object>} listStyles
*/
function _buildListFromGroup(group, listStyles) {
for (const item of group) {
const styleKey = `l${item.listId}:level${item.level}`;
const styleDef = listStyles.get(styleKey);
if (styleDef) {
item.type = styleDef.type;
item.listStyleType = styleDef.listStyleType;
item.startAt = styleDef.startAt;
} else {
const markerText = _extractMarkerText(item.el);
item.type = _detectListTypeFromText(markerText);
item.listStyleType = '';
item.startAt = 1;
}
}
const parent = group[0].el.parentNode;
// Save anchor before the loop removes elements from the DOM
const anchor = group[group.length - 1].el.nextSibling;
const stack = [];
let rootList = null;
for (const item of group) {
const { el, level, type, listStyleType, startAt } = item;
_removeListMarkers(el);
_cleanStyles(el);
_cleanClasses(el);
const li = el.ownerDocument.createElement('li');
while (el.firstChild) {
li.appendChild(el.firstChild);
}
if (el.getAttribute('style')) {
li.setAttribute('style', el.getAttribute('style'));
}
if (stack.length === 0) {
rootList = _createListElement(el.ownerDocument, type, listStyleType, startAt);
rootList.appendChild(li);
stack.push({ listEl: rootList, level });
} else {
const currentLevel = stack[stack.length - 1].level;
if (level > currentLevel) {
const prevLi = stack[stack.length - 1].listEl.lastElementChild;
const childList = _createListElement(el.ownerDocument, type, listStyleType, startAt);
childList.appendChild(li);
if (prevLi) {
prevLi.appendChild(childList);
} else {
stack[stack.length - 1].listEl.appendChild(childList);
}
stack.push({ listEl: childList, level });
} else if (level < currentLevel) {
while (stack.length > 1 && stack[stack.length - 1].level > level) {
stack.pop();
}
stack[stack.length - 1].listEl.appendChild(li);
} else {
stack[stack.length - 1].listEl.appendChild(li);
}
}
el.remove();
}
if (rootList) {
parent.insertBefore(rootList, anchor);
}
}
/**
* @description Creates an <ol> or <ul> element with optional list-style-type and start attributes.
* @param {Document} doc
* @param {'ol'|'ul'} type
* @param {string} listStyleType
* @param {number} startAt
* @returns {Element}
*/
function _createListElement(doc, type, listStyleType, startAt) {
const list = doc.createElement(type);
if (listStyleType) {
list.style.listStyleType = listStyleType;
}
if (type === 'ol' && startAt > 1) {
list.setAttribute('start', String(startAt));
}
return list;
}
/**
* @description Extracts the marker text from a Word list paragraph (the mso-list:Ignore span content).
* @param {Element} el
* @returns {string}
*/
function _extractMarkerText(el) {
const spans = el.querySelectorAll('span');
for (const span of spans) {
const style = span.getAttribute('style') || '';
if (_RE_MSO_LIST_IGNORE.test(style)) {
return span.textContent || '';
}
}
return el.textContent?.substring(0, 10) || '';
}
// ---------- heading conversion ----------
/**
* @description Converts `<p>` elements with `mso-outline-level` to semantic `<h1>`-`<h6>` headings.
* Also normalizes existing heading elements that have MsoHeading* classes.
* @param {Document} doc
*/
function _convertHeadings(doc) {
const paragraphs = doc.body.querySelectorAll('p');
for (const p of paragraphs) {
const style = p.getAttribute('style') || '';
const cls = p.getAttribute('class') || '';
let level = 0;
// Check mso-outline-level
const outlineMatch = _RE_OUTLINE_LEVEL.exec(style);
if (outlineMatch) {
level = parseInt(outlineMatch[1], 10);
}
// Check MsoHeading* class
if (!level) {
const headingClassMatch = _RE_MSO_HEADING.exec(cls);
if (headingClassMatch) {
level = parseInt(headingClassMatch[1], 10);
}
}
if (level >= 1 && level <= 6) {
const heading = doc.createElement('h' + level);
while (p.firstChild) {
heading.appendChild(p.firstChild);
}
if (style) heading.setAttribute('style', style);
p.replaceWith(heading);
}
}
}
// ---------- table cleanup ----------
/**
* @description Cleans Word/Excel table markup.
* - Removes mso-yfti-*, mso-border-*, mso-padding-alt, mso-cellspacing, mso-table-layout-alt.
* - Strips MsoTableGrid/MsoNormalTable classes.
* - Removes Excel-specific <col> elements.
* - Preserves border, colspan, rowspan, and basic styling.
* @param {Document} doc
*/
function _cleanTables(doc) {
const tables = doc.body.querySelectorAll('table');
for (const table of tables) {
_cleanClasses(table);
// Remove cellspacing/cellpadding attributes (handle via CSS)
table.removeAttribute('cellspacing');
table.removeAttribute('cellpadding');
// Remove Excel <col> elements
const cols = table.querySelectorAll('col');
for (const col of cols) {
col.remove();
}
// Remove <colgroup> if empty after col removal
const colgroups = table.querySelectorAll('colgroup');
for (const cg of colgroups) {
if (!cg.children.length) cg.remove();
}
}
// Clean rows
const rows = doc.body.querySelectorAll('tr');
for (const tr of rows) {
// Remove height attribute (keep style-based height if meaningful)
tr.removeAttribute('height');
}
// Clean cells
const cells = doc.body.querySelectorAll('td, th');
for (const cell of cells) {
// Remove valign attribute (use CSS vertical-align instead if present)
const valign = cell.getAttribute('valign');
if (valign) {
cell.removeAttribute('valign');
const existingStyle = cell.getAttribute('style') || '';
if (!/vertical-align/i.test(existingStyle)) {
cell.setAttribute('style', (existingStyle ? existingStyle + ';' : '') + 'vertical-align:' + valign);
}
}
// Unwrap single MsoNormal <p> inside cells → inline content
const children = cell.children;
if (children.length === 1 && children[0].tagName === 'P') {
const p = children[0];
const cls = p.getAttribute('class') || '';
if (/MsoNormal/i.test(cls) || !cls) {
while (p.firstChild) {
cell.insertBefore(p.firstChild, p);
}
p.remove();
}
}
}
}
// ---------- track changes & comments ----------
/**
* @description Removes track changes and comment markup.
* - `<del>` / `msoDel`: remove entirely (deleted content should not appear)
* - `<ins>` / `msoIns`: unwrap (keep the inserted content)
* - `MsoCommentReference` / `MsoCommentText`: remove
* - `mso-element:comment` divs: remove
* @param {Document} doc
*/
function _cleanTrackChanges(doc) {
// Remove deletions
const dels = doc.body.querySelectorAll('del, .msoDel');
for (const del of dels) {
del.remove();
}
// Unwrap insertions (keep content)
const inses = doc.body.querySelectorAll('ins, .msoIns');
for (let i = inses.length - 1; i >= 0; i--) {
const ins = inses[i];
const parent = ins.parentNode;
if (parent) {
while (ins.firstChild) {
parent.insertBefore(ins.firstChild, ins);
}
ins.remove();
}
}
// Remove comment references
const commentRefs = doc.body.querySelectorAll('.MsoCommentReference, [style*="mso-comment-reference"]');
for (const ref of commentRefs) {
ref.remove();
}
// Remove comment text blocks
const commentTexts = doc.body.querySelectorAll('.MsoCommentText');
for (const ct of commentTexts) {
ct.remove();
}
// Remove mso-element:comment divs
const commentDivs = doc.body.querySelectorAll('div[style*="mso-element:comment"]');
for (const div of commentDivs) {
div.remove();
}
// Remove comment anchor names
const commentAnchors = doc.body.querySelectorAll('a[name^="_msocom"], a[name^="_msoanchor"]');
for (const anchor of commentAnchors) {
anchor.remove();
}
}
// ---------- link cleanup ----------
/**
* @description Cleans up Word-specific link patterns.
* - Removes `file:///` protocol links (converts to plain text)
* - Removes internal bookmark anchors (_Toc, _Ref, _Hlt targets)
* - Keeps mailto: and http(s): links intact
* @param {Document} doc
*/
function _cleanLinks(doc) {
const anchors = doc.body.querySelectorAll('a');
for (let i = anchors.length - 1; i >= 0; i--) {
const a = anchors[i];
const href = a.getAttribute('href') || '';
const name = a.getAttribute('name') || '';
// Remove file:/// protocol links (meaningless outside source machine)
if (_RE_FILE_PROTOCOL.test(href)) {
const parent = a.parentNode;
if (parent) {
while (a.firstChild) {
parent.insertBefore(a.firstChild, a);
}
a.remove();
}
continue;
}
// Remove bookmark anchor targets (_Toc, _Ref, _Hlt, _Hlk)
if (_RE_BOOKMARK_NAME.test(name)) {
// If it has content, unwrap; if empty anchor, remove
const parent = a.parentNode;
if (parent) {
if (a.childNodes.length) {
while (a.firstChild) {
parent.insertBefore(a.firstChild, a);
}
}
a.remove();
}
continue;
}
// Remove bookmark links pointing to internal anchors
if (_RE_BOOKMARK_HREF.test(href)) {
const parent = a.parentNode;
if (parent) {
while (a.firstChild) {
parent.insertBefore(a.firstChild, a);
}
a.remove();
}
continue;
}
// Clean v:shapes attribute from regular links
a.removeAttribute('v:shapes');
}
}
// ---------- image cleanup ----------
/**
* @description Cleans Word-specific image patterns.
* - Removes `v:shapes` attribute
* - Removes images with `file:///` src (broken temp file paths)
* @param {Document} doc
*/
function _cleanImages(doc) {
const images = doc.body.querySelectorAll('img');
for (let i = images.length - 1; i >= 0; i--) {
const img = images[i];
const src = img.getAttribute('src') || '';
// Remove broken file:/// protocol images
if (_RE_FILE_PROTOCOL.test(src)) {
img.remove();
continue;
}
// Remove v:shapes attribute
img.removeAttribute('v:shapes');
}
}
// ---------- break handling ----------
/**
* @description Handles Word page breaks, section breaks, and manual line breaks.
* - Page breaks (`mso-special-character:line-break` + `page-break-before:always`): remove
* - Section break `<br>` + `<div class=SectionN>`: unwrap section divs, remove break
* - Column breaks: remove
* - Manual line breaks (Shift+Enter): keep as `<br>`
* @param {Document} doc
*/
function _cleanBreaks(doc) {
const brs = doc.body.querySelectorAll('br');
for (let i = brs.length - 1; i >= 0; i--) {
const br = brs[i];
const style = br.getAttribute('style') || '';
// Page break or section break: remove
if (_RE_PAGE_BREAK.test(style) || _RE_SECTION_BREAK.test(style)) {
br.remove();
continue;
}
// Column break: remove
if (_RE_COLUMN_BREAK.test(style)) {
br.remove();
continue;
}
// Clean clear attribute and style from regular line breaks
br.removeAttribute('style');
br.removeAttribute('clear');
}
// Unwrap Section divs (Section1, Section2, etc.)
const sectionDivs = doc.body.querySelectorAll('div[class*="Section"]');
for (let i = sectionDivs.length - 1; i >= 0; i--) {
const div = sectionDivs[i];
const cls = div.getAttribute('class') || '';
if (_RE_SECTION_CLASS.test(cls.trim())) {
const parent = div.parentNode;
if (parent) {
while (div.firstChild) {
parent.insertBefore(div.firstChild, div);
}
div.remove();
}
}
}
}
// ---------- public API ----------
/**
* @description Converts MS Word/Excel/OneNote HTML clipboard data to clean, standards-compliant HTML.
* @param {string} html Raw HTML string from MS Office clipboard
* @returns {string} Cleaned HTML string
*/
export function cleanHTML(html) {
if (!html) return '';
// 1. Extract list style definitions from <style> blocks before removing them
const listStyles = _extractListStyles(html);
// 2. String-level cleanup (before DOM parsing)
let cleaned = html;
// Remove <style> blocks
cleaned = cleaned.replace(_RE_STYLE_BLOCKS, '');
// Remove <xml>...</xml> blocks
cleaned = cleaned.replace(_RE_XML_BLOCKS, '');
// Remove conditional comments
cleaned = cleaned.replace(_RE_CONDITIONAL_COMMENTS, '');
// Remove Office namespace tags (<o:p>, <w:Sdt>, etc.)
cleaned = cleaned.replace(_RE_OFFICE_TAGS, '');
// Remove <!--StartFragment--> / <!--EndFragment-->
cleaned = cleaned.replace(/<!--(?:Start|End)Fragment-->/gi, '');
// Remove <meta> and <link> tags
cleaned = cleaned.replace(/<(?:meta|link)[^>]*>/gi, '');
// Normalize mso-spacerun spans → single space
cleaned = cleaned.replace(_RE_SPACERUN, ' ');
// Normalize mso-tab-count spans → single space
cleaned = cleaned.replace(_RE_TAB_COUNT, ' ');
// Normalize sequences (Word uses excessive )
cleaned = cleaned.replace(/( ){2,}/g, ' ');
// Remove soft hyphens
cleaned = cleaned.replace(/\u00AD/g, '');
// 3. DOM-level cleanup
const doc = new DOMParser().parseFromString(cleaned, 'text/html');
// 3a. Remove track changes & comments first (before structural changes)
_cleanTrackChanges(doc);
// 3b. Convert Word fake lists to proper <ol>/<ul>/<li>
_convertLists(doc, listStyles);
// 3c. Convert outline-level paragraphs to semantic headings
_convertHeadings(doc);
// 3d. Clean tables
_cleanTables(doc);
// 3e. Clean links
_cleanLinks(doc);
// 3f. Clean images
_cleanImages(doc);
// 3g. Clean page/section/column breaks
_cleanBreaks(doc);
// 3h. Clean all elements (styles, classes, attributes)
const allElements = doc.body.querySelectorAll('*');
for (const el of allElements) {
_cleanStyles(el);
_cleanClasses(el);
// Remove Word-specific attributes
if (el.getAttribute('lang')) el.removeAttribute('lang');
el.removeAttribute('v:shapes');
// Remove width/height from non-media, non-table elements
const tag = el.tagName.toLowerCase();
if (tag !== 'img' && tag !== 'video' && tag !== 'iframe' && tag !== 'table' && tag !== 'td' && tag !== 'th') {
el.removeAttribute('width');
el.removeAttribute('height');
}
}
// 3i. Unwrap empty spans
_unwrapEmptySpans(doc.body);
// 3j. Remove completely empty paragraphs
const paragraphs = doc.body.querySelectorAll('p');
for (const p of paragraphs) {
if (!p.textContent.trim() && !p.querySelector('img, video, iframe, br, table')) {
p.remove();
}
}
return doc.body.innerHTML;
}
export default {
cleanHTML,
};