UNPKG

@ckeditor/ckeditor5-paste-from-office

Version:

Paste from Office feature for CKEditor 5.

210 lines (209 loc) • 8.77 kB
/** * @license Copyright (c) 2003-2025, 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> * * <ol class="footnotes"> * <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> * ``` * * @param documentFragment `data.content` obtained from clipboard. * @param writer The view writer instance. * @internal */ export function replaceMSFootnotes(documentFragment, writer) { const msFootnotesRefs = new Map(); const msFootnotesDefs = new Map(); let msFootnotesDefinitionsList = null; // Phase 1: Collect all footnotes references and definitions. Find the footnotes definitions list element. for (const { item } of writer.createRangeIn(documentFragment)) { if (!item.is('element')) { continue; } // If spot a footnotes definitions element, let's store it. It'll be replaced later. // There should be only one such element in the document. if (item.getStyle('mso-element') === 'footnote-list') { msFootnotesDefinitionsList = item; continue; } // If spot a footnote reference or definition, store it in the corresponding map. if (item.hasStyle('mso-footnote-id')) { const msFootnoteDef = item.findAncestor('element', el => el.getStyle('mso-element') === 'footnote'); if (msFootnoteDef) { // If it's a reference within a definition, ignore it and track only the definition. // MS Word do not support nested footnotes, so it's safe to assume that all references within // a definition point to the same definition. const msFootnoteDefId = msFootnoteDef.getAttribute('id'); msFootnotesDefs.set(msFootnoteDefId, msFootnoteDef); } else { // If it's a reference outside of a definition, track it as a reference. const msFootnoteRefId = item.getStyle('mso-footnote-id'); msFootnotesRefs.set(msFootnoteRefId, item); } continue; } } // If there are no footnotes references or definitions, or no definitions list, there's nothing to normalize. if (!msFootnotesRefs.size || !msFootnotesDefinitionsList) { return; } // Phase 2: Replace footnotes definitions list with proper element. const footnotesDefinitionsList = createFootnotesListViewElement(writer); writer.replace(msFootnotesDefinitionsList, footnotesDefinitionsList); // Phase 3: Replace all footnotes references and add matching definitions to the definitions list. for (const [footnoteId, msFootnoteRef] of msFootnotesRefs) { const msFootnoteDef = msFootnotesDefs.get(footnoteId); if (!msFootnoteDef) { continue; } // Replace footnote reference. writer.replace(msFootnoteRef, createFootnoteRefViewElement(writer, footnoteId)); // Append found matching definition to the definitions list. // Order doesn't matter here, as it'll be fixed in the post-fixer. const defElements = createFootnoteDefViewElement(writer, footnoteId); removeMSReferences(writer, msFootnoteDef); // Insert content within the `MsoFootnoteText` element. It's usually a definition text content. for (const child of msFootnoteDef.getChildren()) { let clonedChild = child; if (child.is('element')) { clonedChild = writer.clone(child, true); } writer.appendChild(clonedChild, defElements.content); } writer.appendChild(defElements.listItem, footnotesDefinitionsList); } } /** * 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); // MS Word used to add spaces after footnote references within definitions. Let's check if there's a space after // the footnote reference and mark it for trimming. const { nextSibling } = item; if (nextSibling?.is('$text') && nextSibling.data.startsWith(' ')) { textNodesToTrim.unshift(nextSibling); } } } for (const element of elementsToRemove) { writer.remove(element); } // Remove only the leading space from text nodes following reference within definition, preserve the rest of the text. for (const textNode of textNodesToTrim) { const trimmedData = textNode.data.substring(1); if (trimmedData.length > 0) { // Create a new text node and replace the old one. const parent = textNode.parent; const index = parent.getChildIndex(textNode); const newTextNode = writer.createText(trimmedData); writer.remove(textNode); writer.insertChild(index, newTextNode, parent); } else { // If the text node contained only a space, remove it entirely. writer.remove(textNode); } } return element; } /** * Creates a footnotes list view element. * * @param writer The view writer instance. * @returns The footnotes list view element. */ function createFootnotesListViewElement(writer) { return writer.createElement('ol', { class: 'footnotes' }); } /** * 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 }; }