@ckeditor/ckeditor5-paste-from-office
Version:
Paste from Office feature for CKEditor 5.
1,522 lines • 63.1 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin } from "@ckeditor/ckeditor5-core";
import { insertToPriorityArray, priorities } from "@ckeditor/ckeditor5-utils";
import { ClipboardPipeline } from "@ckeditor/ckeditor5-clipboard";
import { Matcher, ViewDocument, ViewDomConverter, ViewUpcastWriter } from "@ckeditor/ckeditor5-engine";
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Transforms `<a>` elements which are bookmarks by moving their children after the element.
*
* @internal
*/
function transformBookmarks(documentFragment, writer) {
const elementsToChange = [];
for (const value of writer.createRangeIn(documentFragment)) {
const element = value.item;
if (element.is("element", "a") && !element.hasAttribute("href") && (element.hasAttribute("id") || element.hasAttribute("name"))) elementsToChange.push(element);
}
for (const element of elementsToChange) {
const index = element.parent.getChildIndex(element) + 1;
const children = element.getChildren();
writer.insertChild(index, children, element.parent);
if (isHiddenBookmarkAnchor(element)) writer.remove(element);
}
}
/**
* Checks whether the given element is a hidden or auto-generated bookmark anchor.
*
* Editors like MS Word and Google Docs use the `name` attribute (rather than `id`)
* for bookmarks. Furthermore, they reserve `_`-prefixed bookmark names for
* auto-generated anchors (e.g., Table of Contents or internal hyperlinks) and
* do not allow users to manually create custom bookmarks starting with an underscore.
*
* @param element The element to check.
* @returns True if the element is a hidden bookmark anchor, false otherwise.
*/
function isHiddenBookmarkAnchor(element) {
const name = element.getAttribute("name");
return !!name && name.startsWith("_");
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/utils
*/
/**
* Normalizes CSS length value to 'px'.
*
* @internal
*/
function convertCssLengthToPx(value) {
const numericValue = parseFloat(value);
if (value.endsWith("pt")) return toPx(numericValue * 96 / 72);
else if (value.endsWith("pc")) return toPx(numericValue * 12 * 96 / 72);
else if (value.endsWith("in")) return toPx(numericValue * 96);
else if (value.endsWith("cm")) return toPx(numericValue * 96 / 2.54);
else if (value.endsWith("mm")) return toPx(numericValue / 10 * 96 / 2.54);
return value;
}
/**
* Returns true for value with 'px' unit.
*
* @internal
*/
function isPx(value) {
return value !== void 0 && value.endsWith("px");
}
/**
* Returns a rounded 'px' value.
*
* @internal
*/
function toPx(value) {
return Math.round(value) + "px";
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/list
*/
/**
* Transforms Word specific list-like elements to the semantic HTML lists.
*
* Lists in Word are represented by block elements with special attributes like:
*
* ```xml
* <p class=MsoListParagraphCxSpFirst style='mso-list:l1 level1 lfo1'>...</p> // Paragraph based list.
* <h1 style='mso-list:l0 level1 lfo1'>...</h1> // Heading 1 based list.
* ```
*
* @param documentFragment The view structure to be transformed.
* @param stylesString Styles from which list-like elements styling will be extracted.
* @param hasMultiLevelListPlugin Whether the editor has the multi-level list plugin enabled.
* @param enableSkipLevelLists Whether to enable skip-level lists.
* @internal
*/
function transformListItemLikeElementsIntoLists(documentFragment, stylesString, hasMultiLevelListPlugin, enableSkipLevelLists = false) {
if (!documentFragment.childCount) return;
const writer = new ViewUpcastWriter(documentFragment.document);
const itemLikeElements = findAllItemLikeElements(documentFragment, writer);
if (!itemLikeElements.length) return;
const encounteredLists = [];
const stack = [];
let topLevelListInfo = createTopLevelListInfo();
for (const itemLikeElement of itemLikeElements) if (itemLikeElement.indent !== void 0) {
if (!isListContinuation(itemLikeElement)) {
applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
topLevelListInfo = createTopLevelListInfo();
encounteredLists.length = 1;
stack.length = 0;
}
const originalListId = `${itemLikeElement.id}:${itemLikeElement.indent}`;
const indent = enableSkipLevelLists ? itemLikeElement.indent - 1 : Math.min(itemLikeElement.indent - 1, stack.length);
if (indent < stack.length && stack[indent].id !== itemLikeElement.id) {
if (indent == 0 && topLevelListInfo.canApplyMarginOnList && topLevelListInfo.topLevelListItemElements.length > 0) {
applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
topLevelListInfo = createTopLevelListInfo();
}
encounteredLists.length = indent;
stack.length = indent;
}
if (indent < stack.length - 1) {
encounteredLists.length = indent + 1;
stack.length = indent + 1;
}
const listStyle = detectListStyle(itemLikeElement, stylesString);
while (stack.length < indent) {
const intermediateList = writer.createElement(listStyle.type);
const intermediateListItem = writer.createElement("li");
writer.setStyle("list-style-type", "none", intermediateListItem);
if (stack.length == 0) {
const parent = itemLikeElement.element.parent;
const index = parent.getChildIndex(itemLikeElement.element) + 1;
writer.insertChild(index, intermediateList, parent);
} else {
const parentListItems = stack[stack.length - 1].listItemElements;
writer.appendChild(intermediateList, parentListItems[parentListItems.length - 1]);
}
writer.appendChild(intermediateListItem, intermediateList);
stack.push({
...itemLikeElement,
listElement: intermediateList,
listItemElements: [intermediateListItem],
isIntermediate: true,
marginLeft: void 0
});
}
if (indent > stack.length - 1 || stack[indent].listElement.name != listStyle.type) {
if (listStyle.type == "ol" && itemLikeElement.id !== void 0 && encounteredLists[indent] && encounteredLists[indent][originalListId]) listStyle.startIndex = encounteredLists[indent][originalListId];
const listElement = createNewEmptyList(listStyle, writer, hasMultiLevelListPlugin);
if (stack.length == 0) {
const parent = itemLikeElement.element.parent;
const index = parent.getChildIndex(itemLikeElement.element) + 1;
writer.insertChild(index, listElement, parent);
} else if (indent == 0) {
const existingList = stack[0].listElement;
const listParent = existingList.parent;
const insertIndex = listParent.getChildIndex(existingList) + 1;
writer.insertChild(insertIndex, listElement, listParent);
} else {
const parentListItems = stack[indent - 1].listItemElements;
writer.appendChild(listElement, parentListItems[parentListItems.length - 1]);
}
stack[indent] = {
...itemLikeElement,
listElement,
listItemElements: []
};
if (itemLikeElement.id !== void 0) {
if (!encounteredLists[indent]) encounteredLists[indent] = {};
encounteredLists[indent][originalListId] = listStyle.startIndex || 1;
}
} else if (stack[indent].isIntermediate) {
applyListStyleToElement(stack[indent].listElement, listStyle, writer, hasMultiLevelListPlugin);
stack[indent] = {
...itemLikeElement,
listElement: stack[indent].listElement,
listItemElements: stack[indent].listItemElements
};
/* v8 ignore else -- @preserve */
if (itemLikeElement.id !== void 0) {
/* v8 ignore else -- @preserve */
if (!encounteredLists[indent]) encounteredLists[indent] = {};
encounteredLists[indent][originalListId] = listStyle.startIndex || 1;
}
}
const listItem = itemLikeElement.element.name == "li" ? itemLikeElement.element : writer.createElement("li");
applyListItemMarginLeftAndUpdateTopLevelInfo(writer, stack, topLevelListInfo, itemLikeElement, listItem, indent);
writer.appendChild(listItem, stack[indent].listElement);
stack[indent].listItemElements.push(listItem);
if (itemLikeElement.id !== void 0 && encounteredLists[indent]) encounteredLists[indent][originalListId]++;
if (itemLikeElement.element != listItem) writer.appendChild(itemLikeElement.element, listItem);
removeBulletElement(itemLikeElement.element, writer);
writer.removeStyle("text-indent", itemLikeElement.element);
writer.removeStyle("margin-left", itemLikeElement.element);
} else {
const stackItem = stack.find((stackItem) => stackItem.marginLeft == itemLikeElement.marginLeft);
if (stackItem) {
const listItems = stackItem.listItemElements;
writer.appendChild(itemLikeElement.element, listItems[listItems.length - 1]);
writer.removeStyle("margin-left", itemLikeElement.element);
stack.length = stack.indexOf(stackItem) + 1;
encounteredLists.length = stack.length + 1;
} else {
applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
topLevelListInfo = createTopLevelListInfo();
stack.length = 0;
}
}
applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
}
function applyListItemMarginLeftAndUpdateTopLevelInfo(writer, stack, topLevelListInfo, itemLikeElement, listItem, indent) {
if (itemLikeElement.marginLeft === void 0) {
if (indent == 0) topLevelListInfo.canApplyMarginOnList = false;
return;
}
const listItemBlockMarginLeft = parseFloat(itemLikeElement.marginLeft);
let currentListBlockIndent = 0;
for (let ancestorIndex = 0; ancestorIndex < stack.length - 1; ancestorIndex++) {
const ancestorListItems = stack[ancestorIndex].listItemElements;
const ancestorMargin = ancestorListItems[ancestorListItems.length - 1].getStyle("margin-left");
if (ancestorMargin !== void 0) currentListBlockIndent += parseFloat(ancestorMargin);
}
currentListBlockIndent += stack.length * 40;
const adjustedListItemIndent = listItemBlockMarginLeft - currentListBlockIndent;
const listItemBlockMarginLeftPx = adjustedListItemIndent !== 0 ? toPx(adjustedListItemIndent) : void 0;
if (listItemBlockMarginLeftPx) {
writer.setStyle("margin-left", listItemBlockMarginLeftPx, listItem);
if (indent == 0 && topLevelListInfo.canApplyMarginOnList) {
if (topLevelListInfo.marginLeft === void 0) topLevelListInfo.marginLeft = listItemBlockMarginLeftPx;
if (listItemBlockMarginLeftPx !== topLevelListInfo.marginLeft) topLevelListInfo.canApplyMarginOnList = false;
topLevelListInfo.topLevelListItemElements.push(listItem);
}
}
}
function createTopLevelListInfo() {
return {
marginLeft: void 0,
canApplyMarginOnList: true,
topLevelListItemElements: []
};
}
/**
* Sets margin-left style to the top-level list if all its items have the same margin-left.
* If margin-left is set on the list, it is removed from all its items to avoid doubling of margins.
*/
function applyIndentationToTopLevelList(writer, stack, topLevelListInfo) {
if (topLevelListInfo.canApplyMarginOnList && topLevelListInfo.marginLeft && topLevelListInfo.topLevelListItemElements.length > 0) {
writer.setStyle("margin-left", topLevelListInfo.marginLeft, stack[0].listElement);
for (const topLevelListItem of topLevelListInfo.topLevelListItemElements) writer.removeStyle("margin-left", topLevelListItem);
}
}
/**
* Removes paragraph wrapping content inside a list item.
*
* @internal
*/
function unwrapParagraphInListItem(documentFragment, writer) {
for (const value of writer.createRangeIn(documentFragment)) {
const element = value.item;
if (element.is("element", "li")) {
const firstChild = element.getChild(0);
if (firstChild && firstChild.is("element", "p")) writer.unwrapElement(firstChild);
}
}
}
/**
* Finds all list-like elements in a given document fragment.
*
* @param documentFragment Document fragment in which to look for list-like nodes.
* @returns Array of found list-like items. Each item is an object containing
* @internal
*/
function findAllItemLikeElements(documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const itemLikeElements = [];
const foundMargins = /* @__PURE__ */ new Set();
for (const item of range.getItems()) {
if (!item.is("element") || !item.name.match(/^(p|h\d+|li|div)$/)) continue;
let marginLeft = getMarginLeftNormalized(item);
if (marginLeft !== void 0 && parseFloat(marginLeft) == 0 && !Array.from(item.getClassNames()).find((className) => className.startsWith("MsoList"))) marginLeft = void 0;
if (item.hasStyle("mso-list") && item.getStyle("mso-list") !== "none" || marginLeft !== void 0 && foundMargins.has(marginLeft)) {
const itemData = getListItemData(item);
itemLikeElements.push({
element: item,
id: itemData.id,
order: itemData.order,
indent: itemData.indent,
marginLeft
});
if (marginLeft !== void 0) foundMargins.add(marginLeft);
} else foundMargins.clear();
}
return itemLikeElements;
}
/**
* Whether the given element is possibly a list continuation. Previous element was wrapped into a list
* or the current element already is inside a list.
*/
function isListContinuation(currentItem) {
let previousSibling = currentItem.element.previousSibling;
while (previousSibling && isStrayInlineMarker(previousSibling)) previousSibling = previousSibling.previousSibling;
if (!previousSibling) {
const parent = currentItem.element.parent;
return isList(parent) && (!parent.previousSibling || isList(parent.previousSibling));
}
return isList(previousSibling);
}
/**
* True for empty inline elements Word emits as residue between paragraphs (`<span>`, `<a>`, `<o:p>`).
* Used by `isListContinuation` to look past these when checking whether the prior block is a list —
* they're layout artefacts, not real content.
*/
function isStrayInlineMarker(node) {
return node.is("element") && node.childCount === 0 && /^(?:span|a|o:p)$/.test(node.name);
}
function isList(element) {
return element.is("element", "ol") || element.is("element", "ul");
}
/**
* Extracts list item style from the provided CSS.
*
* List item style is extracted from the CSS stylesheet. Each list with its specific style attribute
* value (`mso-list:l1 level1 lfo1`) has its dedicated properties in a CSS stylesheet defined with a selector like:
*
* ```css
* @list l1:level1 { ... }
* ```
*
* It contains `mso-level-number-format` property which defines list numbering/bullet style. If this property
* is not defined it means default `decimal` numbering.
*
* Here CSS string representation is used as `mso-level-number-format` property is an invalid CSS property
* and will be removed during CSS parsing.
*
* @param listLikeItem List-like item for which list style will be searched for. Usually
* a result of `findAllItemLikeElements()` function.
* @param stylesString CSS stylesheet.
* @returns An object with properties:
*
* * type - List type, could be `ul` or `ol`.
* * startIndex - List start index, valid only for ordered lists.
* * style - List style, for example: `decimal`, `lower-roman`, etc. It is extracted
* directly from Word stylesheet and adjusted to represent proper values for the CSS `list-style-type` property.
* If it cannot be adjusted, the `null` value is returned.
*/
function detectListStyle(listLikeItem, stylesString) {
const listStyleRegexp = new RegExp(`@list l${listLikeItem.id}:level${listLikeItem.indent}\\s*({[^}]*)`, "gi");
const listStyleTypeRegex = /mso-level-number-format:([^;]{0,100});/gi;
const listStartIndexRegex = /mso-level-start-at:\s{0,100}([0-9]{0,10})\s{0,100};/gi;
const legalStyleListRegex = new RegExp(`@list\\s+l${listLikeItem.id}:level\\d\\s*{[^{]*mso-level-text:"%\\d\\\\.`, "gi");
const multiLevelNumberFormatTypeRegex = new RegExp(`@list l${listLikeItem.id}:level\\d\\s*{[^{]*mso-level-number-format:`, "gi");
const legalStyleListMatch = legalStyleListRegex.exec(stylesString);
const multiLevelNumberFormatMatch = multiLevelNumberFormatTypeRegex.exec(stylesString);
const islegalStyleList = legalStyleListMatch && !multiLevelNumberFormatMatch;
const listStyleMatch = listStyleRegexp.exec(stylesString);
let listStyleType = "decimal";
let type = "ol";
let startIndex = null;
if (listStyleMatch && listStyleMatch[1]) {
const listStyleTypeMatch = listStyleTypeRegex.exec(listStyleMatch[1]);
if (listStyleTypeMatch && listStyleTypeMatch[1]) {
listStyleType = listStyleTypeMatch[1].trim();
type = listStyleType !== "bullet" && listStyleType !== "image" ? "ol" : "ul";
}
if (listStyleType === "bullet") {
const bulletedStyle = findBulletedListStyle(listLikeItem.element);
if (bulletedStyle) listStyleType = bulletedStyle;
} else {
const listStartIndexMatch = listStartIndexRegex.exec(listStyleMatch[1]);
if (listStartIndexMatch && listStartIndexMatch[1]) startIndex = parseInt(listStartIndexMatch[1]);
}
if (islegalStyleList) type = "ol";
}
return {
type,
startIndex,
style: mapListStyleDefinition(listStyleType),
isLegalStyleList: islegalStyleList
};
}
/**
* Tries to extract the `list-style-type` value based on the marker element for bulleted list.
*/
function findBulletedListStyle(element) {
if (element.name == "li" && element.parent.name == "ul" && element.parent.hasAttribute("type")) return element.parent.getAttribute("type");
const listMarkerElement = findListMarkerNode(element);
if (!listMarkerElement) return null;
const listMarker = listMarkerElement._data;
if (listMarker === "o") return "circle";
else if (listMarker === "·") return "disc";
else if (listMarker === "§") return "square";
return null;
}
/**
* Tries to find a text node that represents the marker element (list-style-type).
*/
function findListMarkerNode(element) {
if (element.getChild(0).is("$text")) return null;
for (const childNode of element.getChildren()) {
if (!childNode.is("element", "span")) continue;
const textNodeOrElement = childNode.getChild(0);
if (!textNodeOrElement) continue;
if (textNodeOrElement.is("$text")) return textNodeOrElement;
return textNodeOrElement.getChild(0);
}
/* v8 ignore next -- @preserve */
return null;
}
/**
* Parses the `list-style-type` value extracted directly from the Word CSS stylesheet and returns proper CSS definition.
*/
function mapListStyleDefinition(value) {
if (value.startsWith("arabic-leading-zero")) return "decimal-leading-zero";
switch (value) {
case "alpha-upper": return "upper-alpha";
case "alpha-lower": return "lower-alpha";
case "roman-upper": return "upper-roman";
case "roman-lower": return "lower-roman";
case "circle":
case "disc":
case "square": return value;
default: return null;
}
}
/**
* Creates a new list OL/UL element.
*/
function createNewEmptyList(listStyle, writer, hasMultiLevelListPlugin) {
const list = writer.createElement(listStyle.type);
applyListStyleToElement(list, listStyle, writer, hasMultiLevelListPlugin);
return list;
}
/**
* Applies `list-style-type`, `start`, and the `legal-list` class to a list element based on the detected
* list style. Used both when creating a fresh list and when a real item claims a previously-intermediate
* wrapper (which was created without any of these).
*/
function applyListStyleToElement(list, listStyle, writer, hasMultiLevelListPlugin) {
if (listStyle.style) writer.setStyle("list-style-type", listStyle.style, list);
if (listStyle.startIndex && listStyle.startIndex > 1) writer.setAttribute("start", listStyle.startIndex, list);
if (listStyle.isLegalStyleList && hasMultiLevelListPlugin) writer.addClass("legal-list", list);
}
/**
* Extracts list item information from Word specific list-like element style:
*
* ```
* `style="mso-list:l1 level1 lfo1"`
* ```
*
* where:
*
* ```
* * `l1` is a list id (however it does not mean this is a continuous list - see https://github.com/ckeditor/ckeditor5/issues/43),
* * `level1` is a list item indentation level,
* * `lfo1` is a list insertion order in a document.
* ```
*
* @param element Element from which style data is extracted.
*/
function getListItemData(element) {
const listStyle = element.getStyle("mso-list");
if (listStyle === void 0) return {};
const idMatch = listStyle.match(/(^|\s{1,100})l(\d+)/i);
const orderMatch = listStyle.match(/\s{0,100}lfo(\d+)/i);
const indentMatch = listStyle.match(/\s{0,100}level(\d+)/i);
if (idMatch && orderMatch && indentMatch) return {
id: idMatch[2],
order: orderMatch[1],
indent: parseInt(indentMatch[1])
};
return { indent: 1 };
}
/**
* Removes span with a numbering/bullet from a given element.
*/
function removeBulletElement(element, writer) {
const bulletMatcher = new Matcher({
name: "span",
styles: { "mso-list": "Ignore" }
});
const range = writer.createRangeIn(element);
for (const value of range) if (value.type === "elementStart" && bulletMatcher.match(value.item)) writer.remove(value.item);
}
/**
* Returns element left margin normalized to 'px' if possible.
*/
function getMarginLeftNormalized(element) {
const value = element.getStyle("margin-left");
if (value === void 0 || value.endsWith("px")) return value;
return convertCssLengthToPx(value);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/image
*/
/**
* Replaces source attribute of all `<img>` elements representing regular
* images (not the Word shapes) with inlined base64 image representation extracted from RTF or Blob data.
*
* @param documentFragment Document fragment on which transform images.
* @param rtfData The RTF data from which images representation will be used.
* @internal
*/
function replaceImagesSourceWithBase64(documentFragment, rtfData) {
if (!documentFragment.childCount) return;
const upcastWriter = new ViewUpcastWriter(documentFragment.document);
const shapesIds = findAllShapesIds(documentFragment, upcastWriter);
removeAllImgElementsRepresentingShapes(shapesIds, documentFragment, upcastWriter);
insertMissingImgs(shapesIds, documentFragment, upcastWriter);
removeAllShapeElements(documentFragment, upcastWriter);
const images = findAllImageElementsWithLocalSource(documentFragment, upcastWriter);
if (images.length) replaceImagesFileSourceWithInlineRepresentation(images, extractImageDataFromRtf(rtfData), upcastWriter);
}
/**
* Converts given HEX string to base64 representation.
*
* @internal
* @param hexString The HEX string to be converted.
* @returns Base64 representation of a given HEX string.
*/
function _convertHexToBase64(hexString) {
return btoa(hexString.match(/\w{2}/g).map((char) => {
return String.fromCharCode(parseInt(char, 16));
}).join(""));
}
/**
* Finds all shapes (`<v:*>...</v:*>`) ids. Shapes can represent images (canvas)
* or Word shapes (which does not have RTF or Blob representation).
*
* @param documentFragment Document fragment from which to extract shape ids.
* @returns Array of shape ids.
*/
function findAllShapesIds(documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const shapeElementsMatcher = new Matcher({ name: /v:(.+)/ });
const shapesIds = [];
for (const value of range) {
if (value.type != "elementStart") continue;
const el = value.item;
const previousSibling = el.previousSibling;
const prevSiblingName = previousSibling && previousSibling.is("element") ? previousSibling.name : null;
const exceptionIds = ["Chart"];
const isElementAShape = shapeElementsMatcher.match(el);
const hasElementGfxdataAttribute = el.getAttribute("o:gfxdata");
const isPreviousSiblingAShapeType = prevSiblingName === "v:shapetype";
const isElementIdInExceptionsArray = hasElementGfxdataAttribute && exceptionIds.some((item) => el.getAttribute("id").includes(item));
if (isElementAShape && hasElementGfxdataAttribute && !isPreviousSiblingAShapeType && !isElementIdInExceptionsArray) shapesIds.push(value.item.getAttribute("id"));
}
return shapesIds;
}
/**
* Removes all `<img>` elements which represents Word shapes and not regular images.
*
* @param shapesIds Shape ids which will be checked against `<img>` elements.
* @param documentFragment Document fragment from which to remove `<img>` elements.
*/
function removeAllImgElementsRepresentingShapes(shapesIds, documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const imageElementsMatcher = new Matcher({ name: "img" });
const imgs = [];
for (const value of range) if (value.item.is("element") && imageElementsMatcher.match(value.item)) {
const el = value.item;
const shapes = el.getAttribute("v:shapes") ? el.getAttribute("v:shapes").split(" ") : [];
if (shapes.length && shapes.every((shape) => shapesIds.indexOf(shape) > -1)) imgs.push(el);
else if (!el.getAttribute("src")) imgs.push(el);
}
for (const img of imgs) writer.remove(img);
}
/**
* Removes all shape elements (`<v:*>...</v:*>`) so they do not pollute the output structure.
*
* @param documentFragment Document fragment from which to remove shape elements.
*/
function removeAllShapeElements(documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const shapeElementsMatcher = new Matcher({ name: /v:(.+)/ });
const shapes = [];
for (const value of range) if (value.type == "elementStart" && shapeElementsMatcher.match(value.item)) shapes.push(value.item);
for (const shape of shapes) writer.remove(shape);
}
/**
* Inserts `img` tags if there is none after a shape.
*/
function insertMissingImgs(shapeIds, documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const shapes = [];
for (const value of range) if (value.type == "elementStart" && value.item.is("element", "v:shape")) {
const id = value.item.getAttribute("id");
if (shapeIds.includes(id)) continue;
if (!containsMatchingImg(value.item.parent.getChildren(), id)) shapes.push(value.item);
}
for (const shape of shapes) {
const attrs = { src: findSrc(shape) };
if (shape.hasAttribute("alt")) attrs.alt = shape.getAttribute("alt");
const img = writer.createElement("img", attrs);
writer.insertChild(shape.index + 1, img, shape.parent);
}
function containsMatchingImg(nodes, id) {
for (const node of nodes)
/* v8 ignore else -- @preserve */
if (node.is("element")) {
if (node.name == "img" && node.getAttribute("v:shapes") == id) return true;
if (containsMatchingImg(node.getChildren(), id)) return true;
}
return false;
}
function findSrc(shape) {
for (const child of shape.getChildren())
/* v8 ignore else -- @preserve */
if (child.is("element") && child.getAttribute("src")) return child.getAttribute("src");
}
}
/**
* Finds all `<img>` elements in a given document fragment which have source pointing to local `file://` resource.
* This function also tracks the index position of each image in the document, which is essential for
* precise matching with hexadecimal representations in RTF data.
*
* @param documentFragment Document fragment in which to look for `<img>` elements.
* @returns Array of found images along with their position index in the document.
*/
function findAllImageElementsWithLocalSource(documentFragment, writer) {
const range = writer.createRangeIn(documentFragment);
const imageElementsMatcher = new Matcher({ name: "img" });
const imgs = [];
let currentImageIndex = 0;
for (const value of range) if (value.item.is("element") && imageElementsMatcher.match(value.item)) {
if (value.item.getAttribute("src").startsWith("file://")) imgs.push({
element: value.item,
imageIndex: currentImageIndex
});
currentImageIndex++;
}
return imgs;
}
/**
* Extracts all images HEX representations from a given RTF data.
*
* @param rtfData The RTF data from which to extract images HEX representation.
* @returns Array of found HEX representations. Each array item is an object containing:
*
* * hex Image representation in HEX format.
* * type Type of image, `image/png` or `image/jpeg`.
*/
function extractImageDataFromRtf(rtfData) {
if (!rtfData) return [];
const regexPictureHeader = /{\\pict[\s\S]+?\\bliptag-?\d+(\\blipupi-?\d+)?({\\\*\\blipuid\s?[\da-fA-F]+)?[\s}]*?/;
const regexPicture = new RegExp("(?:(" + regexPictureHeader.source + "))([\\da-fA-F\\s]+)\\}", "g");
const images = rtfData.match(regexPicture);
const result = [];
if (images) for (const image of images) {
let imageType = false;
if (image.includes("\\pngblip")) imageType = "image/png";
else if (image.includes("\\jpegblip")) imageType = "image/jpeg";
if (imageType) result.push({
hex: image.replace(regexPictureHeader, "").replace(/[^\da-fA-F]/g, ""),
type: imageType
});
}
return result;
}
/**
* Replaces `src` attribute value of all given images with the corresponding base64 image representation.
* Uses the image index to precisely match with the correct hexadecimal representation from RTF data.
*
* @param imageElements Array of image elements along with their indices which will have their sources replaced.
* @param imagesHexSources Array of images hex sources (usually the result of `extractImageDataFromRtf()` function).
* Contains hexadecimal representations of ALL images in the document, not just those with `file://` URLs.
* In XML documents, the same image might be defined both as base64 in HTML and as hexadecimal in RTF data.
*/
function replaceImagesFileSourceWithInlineRepresentation(imageElements, imagesHexSources, writer) {
for (let i = 0; i < imageElements.length; i++) {
const { element, imageIndex } = imageElements[i];
const rtfHexSource = imagesHexSources[imageIndex];
if (rtfHexSource) {
const newSrc = `data:${rtfHexSource.type};base64,${_convertHexToBase64(rtfHexSource.hex)}`;
writer.setAttribute("src", newSrc, element);
}
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/removemsattributes
*/
/**
* Cleanup MS attributes like styles, attributes and elements.
*
* @param documentFragment element `data.content` obtained from clipboard.
* @internal
*/
function removeMSAttributes(documentFragment) {
const elementsToUnwrap = [];
const writer = new ViewUpcastWriter(documentFragment.document);
for (const { item } of writer.createRangeIn(documentFragment)) {
if (!item.is("element")) continue;
for (const className of item.getClassNames()) if (/\bmso/gi.exec(className)) writer.removeClass(className, item);
for (const styleName of item.getStyleNames()) if (/\bmso/gi.exec(styleName)) writer.removeStyle(styleName, item);
if (item.is("element", "w:sdt") || item.is("element", "w:sdtpr") && item.isEmpty || item.is("element", "o:p") && item.isEmpty) elementsToUnwrap.push(item);
}
for (const item of elementsToUnwrap) {
const itemParent = item.parent;
const childIndex = itemParent.getChildIndex(item);
writer.insertChild(childIndex, item.getChildren(), itemParent);
writer.remove(item);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Applies border none for table and cells without a border specified.
* Normalizes style length units to px.
* Handles left block table alignment.
*
* @internal
*/
function transformTables(documentFragment, writer, hasTablePropertiesPlugin = false) {
for (const item of writer.createRangeIn(documentFragment).getItems()) {
if (!item.is("element", "table") && !item.is("element", "td") && !item.is("element", "th")) continue;
if (hasTablePropertiesPlugin && item.is("element", "table")) {
const directParent = item.parent?.is("element", "div") ? item.parent : null;
const grandParent = item.parent?.parent?.is("element", "div") ? item.parent.parent : null;
const divParent = directParent ?? grandParent;
if (divParent && divParent.getAttribute("align") === "center" && !item.getAttribute("align")) {
writer.setStyle("margin-left", "auto", item);
writer.setStyle("margin-right", "auto", item);
} else if (divParent && divParent.getAttribute("align") === "right" && !item.getAttribute("align")) {
writer.setStyle("margin-left", "auto", item);
writer.setStyle("margin-right", "0", item);
} else if (!divParent && !item.getAttribute("align")) {
writer.setStyle("margin-left", "0", item);
writer.setStyle("margin-right", "auto", item);
}
}
const sides = [
"left",
"top",
"right",
"bottom"
];
if (sides.every((side) => !item.hasStyle(`border-${side}-style`))) writer.setStyle("border-style", "none", item);
else for (const side of sides) if (!item.hasStyle(`border-${side}-style`)) writer.setStyle(`border-${side}-style`, "none", item);
const props = [
"width",
"height",
...sides.map((side) => `border-${side}-width`),
...sides.map((side) => `padding-${side}`)
];
for (const prop of props) if (item.hasStyle(prop)) writer.setStyle(prop, convertCssLengthToPx(item.getStyle(prop)), item);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Removes the `width:0px` style from table pasted from Google Sheets and `width="0"` attribute from Word tables.
*
* @param documentFragment element `data.content` obtained from clipboard
* @internal
*/
function removeInvalidTableWidth(documentFragment, writer) {
for (const child of writer.createRangeIn(documentFragment).getItems()) if (child.is("element", "table")) {
if (child.getStyle("width") === "0px") writer.removeStyle("width", child);
if (child.getAttribute("width") === "0") writer.removeAttribute("width", child);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Replaces MS Word specific footnotes references and definitions with proper elements.
*
* Things to know about MS Word footnotes:
*
* * Footnote references in Word are marked with `mso-footnote-id` style.
* * Word does not support nested footnotes, so references within definitions are ignored.
* * Word appends extra spaces after footnote references within definitions, which are trimmed.
* * Footnote definitions list is marked with `mso-element: footnote-list` style it contain `mso-element: footnote` elements.
* * Footnote definition might contain tables, lists and other elements, not only text. They are placed directly within `li` element,
* without any wrapper (in opposition to text content of the definition, which is placed within `MsoFootnoteText` element).
*
* Example pseudo document showing MS Word footnote structure:
*
* ```html
* <p>Text with footnote<a style='mso-footnote-id:ftn1'>[1]</a> reference.</p>
*
* <div style='mso-element:footnote-list'>
* <div style='mso-element:footnote' id=ftn1>
* <p class=MsoFootnoteText><a style='mso-footnote-id:ftn1'>[1]</a> Footnote content</p>
* <table class="MsoTableGrid">...</table>
* </div>
* </div>
* ```
*
* Will be transformed into:
*
* ```html
* <p>Text with footnote<sup class="footnote"><a id="ref-footnote-ftn1" href="#footnote-ftn1">1</a></sup> reference.</p>
*
* <div class="footnotes">
* <hr class="footnotes-divider">
* <ol class="footnotes-list">
* <li class="footnote-definition" id="footnote-ftn1">
* <a href="#ref-footnote-ftn1" class="footnote-backlink">^</a>
* <div class="footnote-content">
* <p>Footnote content</p>
* <table>...</table>
* </div>
* </li>
* </ol>
* </div>
* ```
*
* @param documentFragment `data.content` obtained from clipboard.
* @param writer The view writer instance.
* @internal
*/
function replaceMSFootnotes(documentFragment, writer) {
const msFootnotesRefs = /* @__PURE__ */ new Map();
const msFootnotesDefs = /* @__PURE__ */ new Map();
let msFootnotesDefinitionsList = null;
for (const { item } of writer.createRangeIn(documentFragment)) {
if (!item.is("element")) continue;
if (item.getStyle("mso-element") === "footnote-list") {
msFootnotesDefinitionsList = item;
continue;
}
if (item.hasStyle("mso-footnote-id")) {
const msFootnoteDef = item.findAncestor("element", (el) => el.getStyle("mso-element") === "footnote");
if (msFootnoteDef) {
const msFootnoteDefId = msFootnoteDef.getAttribute("id");
msFootnotesDefs.set(msFootnoteDefId, msFootnoteDef);
} else {
const msFootnoteRefId = item.getStyle("mso-footnote-id");
msFootnotesRefs.set(msFootnoteRefId, item);
}
continue;
}
}
if (!msFootnotesRefs.size || !msFootnotesDefinitionsList) return;
const footnotesList = createFootnotesListContainerElement(writer);
writer.replace(msFootnotesDefinitionsList, footnotesList.wrapper);
for (const [footnoteId, msFootnoteRef] of msFootnotesRefs) {
const msFootnoteDef = msFootnotesDefs.get(footnoteId);
if (!msFootnoteDef) continue;
writer.replace(msFootnoteRef, createFootnoteRefViewElement(writer, footnoteId));
const defElements = createFootnoteDefViewElement(writer, footnoteId);
removeMSReferences(writer, msFootnoteDef);
for (const child of msFootnoteDef.getChildren()) {
let clonedChild = child;
/* v8 ignore else -- @preserve */
if (child.is("element")) clonedChild = writer.clone(child, true);
writer.appendChild(clonedChild, defElements.content);
}
writer.appendChild(defElements.listItem, footnotesList.list);
}
}
/**
* Removes all MS Office specific references from the given element.
*
* It also removes leading space from text nodes following the references, as MS Word adds
* them to separate the reference from the rest of the text.
*
* @param writer The view writer.
* @param element The element to trim.
* @returns The trimmed element.
*/
function removeMSReferences(writer, element) {
const elementsToRemove = [];
const textNodesToTrim = [];
for (const { item } of writer.createRangeIn(element)) if (item.is("element") && item.getStyle("mso-footnote-id")) {
elementsToRemove.unshift(item);
const { nextSibling } = item;
if (nextSibling?.is("$text") && nextSibling.data.startsWith(" ")) textNodesToTrim.unshift(nextSibling);
}
for (const element of elementsToRemove) writer.remove(element);
for (const textNode of textNodesToTrim) {
const trimmedData = textNode.data.substring(1);
if (trimmedData.length > 0) {
const parent = textNode.parent;
const index = parent.getChildIndex(textNode);
const newTextNode = writer.createText(trimmedData);
writer.remove(textNode);
writer.insertChild(index, newTextNode, parent);
} else writer.remove(textNode);
}
return element;
}
/**
* Creates a footnotes list container element.
*
* @param writer The view writer instance.
* @returns The footnotes list container element and list itself.
*/
function createFootnotesListContainerElement(writer) {
const divider = writer.createElement("hr", { class: "footnotes-divider" });
const list = writer.createElement("ol", { class: "footnotes-list" });
return {
list,
wrapper: writer.createElement("div", { class: "footnotes" }, [divider, list])
};
}
/**
* Creates a footnote reference view element.
*
* @param writer The view writer instance.
* @param footnoteId The footnote ID.
* @returns The footnote reference view element.
*/
function createFootnoteRefViewElement(writer, footnoteId) {
const sup = writer.createElement("sup", { class: "footnote" });
const link = writer.createElement("a", {
id: `ref-${footnoteId}`,
href: `#${footnoteId}`
});
writer.appendChild(link, sup);
return sup;
}
/**
* Creates a footnote definition view element with a backlink and a content container.
*
* @param writer The view writer instance.
* @param footnoteId The footnote ID.
* @returns An object containing the list item element, backlink and content container.
*/
function createFootnoteDefViewElement(writer, footnoteId) {
const listItem = writer.createElement("li", {
id: footnoteId,
class: "footnote-definition"
});
const backLink = writer.createElement("a", {
href: `#ref-${footnoteId}`,
class: "footnote-backlink"
});
const content = writer.createElement("div", { class: "footnote-content" });
writer.appendChild(writer.createText("^"), backLink);
writer.appendChild(backLink, listItem);
writer.appendChild(content, listItem);
return {
listItem,
content
};
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
const msWordMatch1 = /<meta\s*name="?generator"?\s*content="?microsoft\s*word\s*\d+"?\/?>/i;
const msWordMatch2 = /xmlns:o="urn:schemas-microsoft-com/i;
const msExcelMatch = /<meta\s*name="?generator"?\s*content="?microsoft\s*excel\s*\d+"?\/?>/i;
/**
* Normalizer for the content pasted from Microsoft Word.
*/
var PasteFromOfficeMSWordNormalizer = class {
document;
hasMultiLevelListPlugin;
hasTablePropertiesPlugin;
enableSkipLevelLists;
/**
* Creates a new `PasteFromOfficeMSWordNormalizer` instance.
*
* @param document View document.
*/
constructor(document, hasMultiLevelListPlugin = false, hasTablePropertiesPlugin = false, enableSkipLevelLists = false) {
this.document = document;
this.hasMultiLevelListPlugin = hasMultiLevelListPlugin;
this.hasTablePropertiesPlugin = hasTablePropertiesPlugin;
this.enableSkipLevelLists = enableSkipLevelLists;
}
/**
* @inheritDoc
*/
isActive(htmlString) {
return msWordMatch1.test(htmlString) || msWordMatch2.test(htmlString) || msExcelMatch.test(htmlString);
}
/**
* @inheritDoc
*/
execute(data) {
const writer = new ViewUpcastWriter(this.document);
const stylesString = data.extraContent.stylesString;
transformBookmarks(data.content, writer);
transformListItemLikeElementsIntoLists(data.content, stylesString, this.hasMultiLevelListPlugin, this.enableSkipLevelLists);
replaceImagesSourceWithBase64(data.content, data.dataTransfer.getData("text/rtf"));
transformTables(data.content, writer, this.hasTablePropertiesPlugin);
removeInvalidTableWidth(data.content, writer);
replaceMSFootnotes(data.content, writer);
removeMSAttributes(data.content);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Removes the `<b>` tag wrapper added by Google Docs to a copied content.
*
* @param documentFragment element `data.content` obtained from clipboard
* @internal
*/
function removeBoldWrapper(documentFragment, writer) {
for (const child of documentFragment.getChildren()) if (child.is("element", "b") && child.getStyle("font-weight") === "normal") {
const childIndex = documentFragment.getChildIndex(child);
writer.remove(child);
writer.insertChild(childIndex, child.getChildren(), documentFragment);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/br
*/
/**
* Transforms `<br>` elements that are siblings to some block element into a paragraphs.
*
* @param documentFragment The view structure to be transformed.
* @internal
*/
function transformBlockBrsToParagraphs(documentFragment, writer) {
const viewDocument = new ViewDocument(writer.document.stylesProcessor);
const domConverter = new ViewDomConverter(viewDocument, { renderingMode: "data" });
const blockElements = domConverter.blockElements;
const inlineObjectElements = domConverter.inlineObjectElements;
const elementsToReplace = [];
for (const value of writer.createRangeIn(documentFragment)) {
const element = value.item;
if (element.is("element", "br")) {
const nextSibling = findSibling(element, "forward", writer, {
blockElements,
inlineObjectElements
});
const previousSibling = findSibling(element, "backward", writer, {
blockElements,
inlineObjectElements
});
const nextSiblingIsBlock = isBlockViewElement(nextSibling, blockElements);
if (isBlockViewElement(previousSibling, blockElements) || nextSiblingIsBlock) elementsToReplace.push(element);
}
}
for (const element of elementsToReplace) if (element.hasClass("Apple-interchange-newline")) writer.remove(element);
else writer.replace(element, writer.createElement("p"));
}
/**
* Returns sibling node, threats inline elements as transparent (but should stop on an inline objects).
*/
function findSibling(viewElement, direction, writer, { blockElements, inlineObjectElements }) {
let position = writer.createPositionAt(viewElement, direction == "forward" ? "after" : "before");
position = position.getLastMatchingPosition(({ item }) => item.is("element") && !blockElements.includes(item.name) && !inlineObjectElements.includes(item.name), { direction });
return direction == "forward" ? position.nodeAfter : position.nodeBefore;
}
/**
* Returns true for view elements that are listed as block view elements.
*/
function isBlockViewElement(node, blockElements) {
return !!node && node.is("element") && blockElements.includes(node.name);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Replaces tab characters with spaces in text nodes that are inside elements styled with `white-space: pre-wrap`.
*
* This is a workaround for incorrect detection of pre-like formatting in the DOM converter for pasted Google Docs documents.
* When an element uses `white-space: pre-wrap`, the editor reduces tab characters to a single space, causing
* inconsistent spacing in pasted content. This function replaces tabs with spaces to ensure visual consistency.
* This is intended as a temporary solution.
*
* See: https://github.com/ckeditor/ckeditor5/issues/18995
*
* @param documentFragment The `data.content` element obtained from the clipboard.
* @param writer The upcast writer used to manipulate the view structure.
* @param tabWidth The number of spaces to replace each tab with. Defaults to 8.
* @internal
*/
function replaceTabsWithinPreWithSpaces(documentFragment, writer, tabWidth) {
const textNodesToReplace = /* @__PURE__ */ new Set();
for (const child of writer.createRangeIn(documentFragment).getItems()) {
if (!child.is("view:$textProxy") || !child.data.includes(" ")) continue;
if (hasPreWrapParent(child.parent)) textNodesToReplace.add(child.textNode);
}
for (const textNode of textNodesToReplace) replaceTabsInTextNode(textNode, writer, tabWidth);
}
/**
* Checks if element or any of its parents has `white-space: pre-wrap` style.
*/
function hasPreWrapParent(element) {
let parent = element;
while (parent) {
if (parent.is("element")) {
if (parent.getStyle?.("white-space") === "pre-wrap") return true;
}
parent = parent.parent;
}
return false;
}
/**
* Replaces all tabs with spaces in the given text node.
*/
function replaceTabsInTextNode(textNode, writer, tabWidth) {
const { parent, data } = textNode;
const replacedData = data.replaceAll(" ", " ".repeat(tabWidth));
const index = parent.getChildIndex(textNode);
writer.remove(textNode);
writer.insertChild(index, writer.createText(replacedData), parent);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/normalizers/googledocsnormalizer
*/
const googleDocsMatch = /id=("|')docs-internal-guid-[-0-9a-f]+("|')/i;
/**
* Normalizer for the content pasted from Google Docs.
*
* @internal
*/
var GoogleDocsNormalizer = class {
document;
/**
* Creates a new `GoogleDocsNormalizer` instance.
*
* @param document View document.
*/
constructor(document) {
this.document = document;
}
/**
* @inheritDoc
*/
isActive(htmlString) {
return googleDocsMatch.test(htmlString);
}
/**
* @inheritDoc
*/
execute(data) {
const writer = new ViewUpcastWriter(this.document);
removeBoldWrapper(data.content, writer);
unwrapParagraphInListItem(data.content, writer);
transformBlockBrsToParagraphs(data.content, writer);
replaceTabsWithinPreWithSpaces(data.content, writer, 8);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Removes the `xmlns` attribute from table pasted from Google Sheets.
*
* @param documentFragment element `data.content` obtained from clipboard
* @internal
*/
function removeXmlns(documentFragment, writer) {
for (const child of documentFragment.getChildren()) if (child.is("element", "table") && child.hasAttribute("xmlns")) writer.removeAttribute("xmlns", child);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Removes the `<google-sheets-html-origin>` tag wrapper added by Google Sheets to a copied content.
*
* @param documentFragment element `data.content` obtained from clipboard
* @internal
*/
function removeGoogleSheetsTag(documentFragment, writer) {
for (const child of documentFragment.getChildren())
/* v8 ignore else -- @preserve */
if (child.is("element", "google-sheets-html-origin")) {
const childIndex = documentFragment.getChildIndex(child);
writer.remove(child);
writer.insertChild(childIndex, child.getChildren(), documentFragment);
}
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* Removes `<style>` block added by Google Sheets to a copied content.
*
* @param documentFragment element `data.content` obtained from clipboard
* @internal
*/
function removeStyleBlock(documentFragment, writer) {
for (const child of Array.from(documentFragment.getChildren())) if (child.is("element", "style")) writer.remove(child);
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/normalizers/googlesheetsnormalizer
*/
const googleSheetsMatch = /<google-sheets-html-origin/i;
/**
* Normalizer for the content pasted from Google Sheets.
*
* @internal
*/
var GoogleSheetsNormalizer = class {
document;
/**
* Creates a new `GoogleSheetsNormalizer` instance.
*
* @param document View document.
*/
constructor(document) {
this.document = document;
}
/**
* @inheritDoc
*/
isActive(htmlString) {
return googleSheetsMatch.test(htmlString);
}
/**
* @inheritDoc
*/
execute(data) {
const writer = new ViewUpcastWriter(this.document);
removeGoogleSheetsTag(data.content, writer);
removeXmlns(data.content, writer);
removeInvalidTableWidth(data.content, writer);
removeStyleBlock(data.content, writer);
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/space
*/
/**
* Replaces last space preceding elements closing tag with ` `. Such operation prevents spaces from being removed
* during further DOM/View processing (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDomInlineNodes}).
* This method also takes into account Word specific `<o:p></o:p>` empty tags.
* Additionally multiline sequences of spaces and new lines between tags are removed (see
* https://github.com/ckeditor/ckeditor5-paste-from-office/issues/39
* and https://github.com/ckeditor/ckeditor5-paste-from-office/issues/40).
*
* @param htmlString HTML string in which spacing should be normalized.
* @returns Input HTML with spaces normalized.
* @internal
*/
function normalizeSpacing(htmlString) {
return normalizeSafariSpaceSpans(normalizeSafariSpaceSpans(htmlString)).replace(/(<span\s+style=['"]mso-spacerun:yes['"]>[^\S\r\n]*?)[\r\n]+([^\S\r\n]*<\/span>)/g, "$1$2").replace(/<span\s+style=['"]mso-spacerun:yes['"]><\/span>/g, "").replace(/(<span\s+style=['"]letter-spacing:[^'"]+?['"]>)[\r\n]+(<\/span>)/g, "$1 $2").replace(/ <\//g, "\xA0</").replace(/ <o:p><\/o:p>/g, "\xA0<o:p></o:p>").replace(/<o:p>( |\u00A0)<\/o:p>/g, "").replace(/>([^\S\r\n]*[\r\n]\s*)</g, "><");
}
/**
* Normalizes spacing in special Word `spacerun spans` (`<span style='mso-spacerun:yes'>\s+</span>`) by replacing
* all spaces with ` ` pairs. This prevents spaces from being removed during further DOM/View processing
* (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDomInlineNodes}).
*
* @param htmlDocument Native `Document` object in which spacing should be normalized.
* @internal
*/
function normalizeSpacerunSpans(htmlDocument) {
htmlDocument.querySelectorAll("span[style*=spacerun]").forEach((el) => {
const htmlElement = el;
const innerTextLength = htmlElement.innerText.length || 0;
htmlElement.innerText = Array(innerTextLength + 1).join("\xA0 ").substr(0, innerTextLength);
});
}
/**
* Normalizes specific spacing generated by Safari when content pasted from Word (`<span class="Apple-converted-space"> </span>`)
* by replacing all spaces sequences longer than 1 space with ` ` pairs. This prevents spaces from being removed during
* further DOM/View processing (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDataFromDomText}).
*
* This function is similar to {@link module:clipboard/utils/normalizeclipboarddata normalizeClipboardData util} but uses
* regular spaces / sequence for replacement.
*
* @param htmlString HTML string in which spacing should be normalized
* @returns Input HTML with spaces normalized.
* @internal
*/
function normalizeSafariSpaceSpans(htmlString) {
return htmlString.replace(/<span(?: class="Apple-converted-space"|)>(\s+)<\/span>/g, (fullMatch, spaces) => {
return spaces.length === 1 ? " " : Array(spaces.length + 1).join("\xA0 ").substr(0, spaces.length);
});
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/filters/parse
*/
/**
* Parses the provided HTML extracting contents of `<body>` and `<style>` tags.
*
* @param htmlString HTML string to be parsed.
*/
function parsePasteOfficeHtml(htmlString, stylesProcessor) {
const domParser = new DOMParser();
htmlString = htmlString.replace(/<!--\[if gte vml 1]>/g, "");
htmlString = htmlString.replace(/<o:SmartTagType(?:\s+[^\s>=]+(?:="[^"]*")?)*\s*\/?>/gi, "");
const normalizedHtml = normalizeSpacing(cleanContentAfterBody(htmlString));
const htmlDocument = domParser.parseFromString(normalizedHtml, "text/html");
normalizeSpacerunSpans(htmlDocument);
removeInBodyStyleBlocks(htmlDocument);
const bodyString = htmlDocument.body.innerHTML;
const bodyView = documentToView(htmlDocument, stylesProcessor);
const stylesObject = extractStyles(htmlDocument);
return {
body: bodyView,
bodyString,
styles: stylesObject.styles,
stylesString: stylesObject.stylesString
};
}
/**
* Transforms native `Document` object into {@link module:engine/view/documentfragment~ViewDocumentFragment}. Comments are skipped.
*
* @param htmlDocument Native `Document` object to be transformed.
*/
function documentToView(htmlDocument, stylesProcessor) {
const viewDocument = new ViewDocument(stylesProcessor);
const domConverter = new ViewDomConverter(viewDocument, { renderingMode: "data" });
const fragment = htmlDocument.createDocumentFragment();
const nodes = htmlDocument.body.childNodes;
while (nodes.length > 0) fragment.appendChild(nodes[0]);
return domConverter.domToView(fragment, { skipComments: true });
}
/**
* Removes all `<style>` elements found inside the `<body>` of the provided `htmlDocument`.
*
* This guards against sources that place (or flatten) a `<style>` block into the body, where its CSS text
* would otherwise be converted to visible text. Styles located in the `<head>` are not affected.
*
* @param htmlDocument Native `Document` object to be cleaned.
*/
function removeInBodyStyleBlocks(htmlDocument) {
for (const style of Array.from(htmlDocument.body.querySelectorAll("style"))) style.remove();
}
/**
* Extracts both `CSSStyleSheet` and string representation from all `style` elements available in a provided `htmlDocument`.
*
* @param htmlDocument Native `Document` object from which styles will be extracted.
*/
function extractStyles(htmlDocument) {
const styles = [];
const stylesString = [];
const styleTags = Array.from(htmlDocument.getElementsByTagName("style"));
for (const style of styleTags) if (style.sheet && style.sheet.cssRules && style.sheet.cssRules.length) {
styles.push(style.sheet);
stylesString.push(style.innerHTML);
}
return {
styles,
stylesString: stylesString.join(" ")
};
}
/**
* Removes leftover content from between closing </body> and closing </html> tag:
*
* ```html
* <html><body><p>Foo Bar</p></body><span>Fo</span></html> -> <html><body><p>Foo Bar</p></body></html>
* ```
*
* This function is used as specific browsers (Edge) add some random content after `body` tag when pasting from Word.
* @param htmlString The HTML string to be cleaned.
* @returns The HTML string with leftover content removed.
*/
function cleanContentAfterBody(htmlString) {
const bodyCloseTag = "</body>";
const htmlCloseTag = "</html>";
const bodyCloseIndex = htmlString.indexOf(bodyCloseTag);
if (bodyCloseIndex < 0) return htmlString;
const htmlCloseIndex = htmlString.indexOf(htmlCloseTag, bodyCloseIndex + 7);
return htmlString.substring(0, bodyCloseIndex + 7) + (htmlCloseIndex >= 0 ? htmlString.substring(htmlCloseIndex) : "");
}
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module paste-from-office/pastefromoffice
*/
/**
* The Paste from Office plugin.
*
* This plugin handles content pasted from Office apps and transforms it (if necessary)
* to a valid structure which can then be understood by the editor features.
*
* Transformation is made by a set of predefined {@link module:paste-from-office/normalizer~PasteFromOfficeNormalizer normalizers}.
* This plugin includes following normalizers:
* * {@link module:paste-from-office/normalizers/mswordnormalizer~PasteFromOfficeMSWordNormalizer Microsoft Word normalizer}
* * {@link module:paste-from-office/normalizers/googledocsnormalizer~GoogleDocsNormalizer Google Docs normalizer}
*
* For more information about this feature check the {@glink api/paste-from-office package page}.
*/
var PasteFromOffice = class extends Plugin {
/**
* The priority array of registered normalizers.
*/
_normalizers = [];
/**
* @inheritDoc
*/
static get pluginName() {
return "PasteFromOffice";
}
/**
* @inheritDoc
* @internal
*/
static get licenseFeatureCode() {
return "PFO";
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get isPremiumPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ClipboardPipeline];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const clipboardPipeline = editor.plugins.get("ClipboardPipeline");
const viewDocument = editor.editing.view.document;
const hasMultiLevelListPlugin = this.editor.plugins.has("MultiLevelListEditing");
const hasTablePropertiesPlugin = this.editor.plugins.has("TablePropertiesEditing");
const enableSkipLevelLists = !!this.editor.config.get("list.enableSkipLevelLists");
this.registerNormalizer(new PasteFromOfficeMSWordNormalizer(viewDocument, hasMultiLevelListPlugin, hasTablePropertiesPlugin, enableSkipLevelLists));
this.registerNormalizer(new GoogleDocsNormalizer(viewDocument));
this.registerNormalizer(new GoogleSheetsNormalizer(viewDocument));
viewDocument.on("clipboardInput", (evt, data) => {
if (typeof data.content != "string") return;
const htmlString = data.dataTransfer.getData("text/html");
if (this._normalizers.find(({ normalizer }) => normalizer.isActive(htmlString))) {
const parsedData = parsePasteOfficeHtml(data.content, viewDocument.stylesProcessor);
data.content = parsedData.body;
data.extraContent = {
...parsedData,
isTransformedWithPasteFromOffice: true
};
}
}, { priority: priorities.low + 10 });
clipboardPipeline.on("inputTransformation", (evt, data) => {
if (!data.extraContent || !data.extraContent.isTransformedWithPasteFromOffice) return;
const htmlString = data.dataTransfer.getData("text/html");
const normalizers = this._normalizers.filter(({ normalizer }) => normalizer.isActive(htmlString));
for (const { normalizer } of normalizers) normalizer.execute(data);
}, { priority: "high" });
}
/**
* Registers a normalizer with the given priority.
*/
registerNormalizer(normalizer, priority) {
insertToPriorityArray(this._normalizers, {
normalizer,
priority: priorities.get(priority)
});
}
};
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
export { PasteFromOffice, GoogleDocsNormalizer as PasteFromOfficeGoogleDocsNormalizer, GoogleSheetsNormalizer as PasteFromOfficeGoogleSheetsNormalizer, PasteFromOfficeMSWordNormalizer, _convertHexToBase64, convertCssLengthToPx as _convertPasteOfficeCssLengthToPx, isPx as _isPasteOfficePxValue, normalizeSpacerunSpans as _normalizePasteOfficeSpaceRunSpans, normalizeSpacing as _normalizePasteOfficeSpacing, removeGoogleSheetsTag as _removePasteGoogleOfficeSheetsTag, removeMSAttributes as _removePasteMSOfficeAttributes, removeBoldWrapper as _removePasteOfficeBoldWrapper, removeInvalidTableWidth as _removePasteOfficeInvalidTableWidths, removeStyleBlock as _removePasteOfficeStyleBlock, removeXmlns as _removePasteOfficeXmlnsAttributes, replaceImagesSourceWithBase64 as _replacePasteOfficeImagesSourceWithBase64, toPx as _toPasteOfficePxValue, transformBlockBrsToParagraphs as _transformPasteOfficeBlockBrsToParagraphs, transformBookmarks as _transformPasteOfficeBookmarks, transformListItemLikeElementsIntoLists as _transformPasteOfficeListItemLikeElementsIntoLists, transformTables as _transformPasteOfficeTables, unwrapParagraphInListItem as _unwrapPasteOfficeParagraphInListItem, parsePasteOfficeHtml };
//# sourceMappingURL=index.js.map