From 58076594b79adafed096420136c297889ee2fe44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 6 Sep 2024 20:30:25 +0200 Subject: [PATCH 1/3] functional import refactor - not tested, not used --- .../core/super-converter/SuperConverter.js | 2 +- .../src/core/super-converter/v2/docxHelper.js | 92 ++++++ .../v2/importer/bookmarkNodeImporter.js | 28 ++ .../v2/importer/boomarkNodeImporter.test.js | 33 ++ .../v2/importer/docxImporter.js | 164 ++++++++++ .../v2/importer/hyperlinkImporter.js | 60 ++++ .../v2/importer/imageImporter.js | 77 +++++ .../v2/importer/importerHelpers.js | 85 +++++ .../v2/importer/lineBreakImporter.js | 24 ++ .../v2/importer/lineBreakImporter.test.js | 23 ++ .../v2/importer/listImporter.js | 309 ++++++++++++++++++ .../v2/importer/markImporter.js | 160 +++++++++ .../v2/importer/paragraphNodeImporter.js | 151 +++++++++ .../v2/importer/runNodeImporter.js | 27 ++ .../v2/importer/standardNodeImporter.js | 50 +++ .../v2/importer/tableImporter.js | 225 +++++++++++++ .../v2/importer/testUtils.test.js | 29 ++ .../v2/importer/textNodeImporter.js | 44 +++ .../v2/importer/textNodeImporter.test.js | 33 ++ .../v2/importer/trackChangesImporter.js | 37 +++ .../v2/importer/trackChangesImporter.test.js | 143 ++++++++ .../src/core/utilities/cabonCopy.js | 14 + 22 files changed, 1809 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/docxHelper.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/listImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/markImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js create mode 100644 packages/super-editor/src/core/utilities/cabonCopy.js diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 09ae8c7a7c..a521e21d34 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -14,7 +14,7 @@ class SuperConverter { 'w:p': 'paragraph', 'w:r': 'run', 'w:t': 'text', - 'w:delText': 'deletedText', + 'w:delText': 'text', 'w:br': 'lineBreak', 'w:tbl': 'table', 'w:tr': 'tableRow', diff --git a/packages/super-editor/src/core/super-converter/v2/docxHelper.js b/packages/super-editor/src/core/super-converter/v2/docxHelper.js new file mode 100644 index 0000000000..2b4090b747 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/docxHelper.js @@ -0,0 +1,92 @@ +import xmljs from 'xml-js'; +/** + * @typedef {Object.} ParsedDocx + * this should have the `media` as a key for image handling + */ + +/** + * Convert docx to our inner representation + * @param {{name: string, content: string}[]} docxAsXmlFileList + * @returns {ParsedDocx} + */ +export const docxAsXmlFileList2ParsedDocx = (docxAsXmlFileList) => { + const convertedXml = {} + docxAsXmlFileList.forEach(file => { + convertedXml[file.name] = parseXmlToJson(file.content); + }); + return convertedXml; +} + +const defaultInitialXml = `` + +export const parseXmlToJson = (xml) => { + return JSON.parse(xmljs.xml2json(xml, null, 2)) +} + +/** + * + * @param {ParsedDocx} parsedDocx + * @param {string} [fallbackXml] + */ +export const getInitialJSON = (parsedDocx, fallbackXml = defaultInitialXml) => { + return parsedDocx['word/document.xml'] || parseXmlToJson(fallbackXml); +} +/** + * + * @param {ParsedDocx} parsedDocx + * @returns * + */ +const getDeclaration = (parsedDocx) => { + return getInitialJSON(parsedDocx).declaration; +} + +const getThemeInfo = (themeName) =>{ + themeName = themeName.toLowerCase(); + const theme1 = this.convertedXml['word/theme/theme1.xml']; + const themeData = theme1.elements.find((el) => el.name === 'a:theme'); + const themeElements = themeData.elements.find((el) => el.name === "a:themeElements"); + const fontScheme = themeElements.elements.find((el) => el.name === 'a:fontScheme'); + let fonts; + + if (themeName.startsWith('major')) { + fonts = fontScheme.elements.find((el) => el.name === 'a:majorFont').elements[0]; + } else if (themeName.startsWith('minor')) { + fonts = fontScheme.elements.find((el) => el.name === 'a:minorFont').elements[0]; + } + + const { typeface, panose } = fonts.attributes; + return { typeface, panose }; +} + +/** + * + * @param {ParsedDocx} parsedDocx + * @returns {{} | {fontSizePt: number, kern: string, typeface: string, panose: string}} + */ +const getDocumentDefaultStyles = (parsedDocx) => { + const styles = parsedDocx['word/styles.xml']; + if (!styles) return {}; + + const defaults = styles.elements[0].elements.find((el) => el.name === 'w:docDefaults'); + + // TODO: Check if we need this + // const pDefault = defaults.elements.find((el) => el.name === 'w:pPrDefault'); + + // Get the run defaults for this document - this will include font, theme etc. + const rDefault = defaults.elements.find((el) => el.name === 'w:rPrDefault'); + if ('elements' in rDefault) { + const rElements = rDefault.elements[0].elements + const fontThemeName = rElements.find((el) => el.name === 'w:rFonts')?.attributes['w:asciiTheme']; + let typeface, panose; + if (fontThemeName) { + const fontInfo = getThemeInfo(fontThemeName); + typeface = fontInfo.typeface; + panose = fontInfo.panose; + } + + const fontSizePt = Number(rElements.find((el) => el.name === 'w:sz')?.attributes['w:val']) / 2; + const kern = rElements.find((el) => el.name === 'w:kern')?.attributes['w:val']; + return { fontSizePt, kern, typeface, panose }; + } + return {}; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js new file mode 100644 index 0000000000..43d8c1733c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js @@ -0,0 +1,28 @@ +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleBookmarkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:bookmarkStart') { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.nodes.length === 1) { + result.nodes[0].attrs.name = node.attributes['w:name']; + } + return result; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const bookmarkNodeHandlerEntity = { + handlerName: 'bookmarkNodeHandler', + handler: handleBookmarkNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js new file mode 100644 index 0000000000..4d418ddc1a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js @@ -0,0 +1,33 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleBookmarkNode} from "./bookmarkNodeImporter.js"; +import {createNodeListHandlerMock} from "./testUtils.test.js"; + + +describe('BookmarkNodeImporter', () => { + it("parses only bookmark nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:bookmarkStart'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleBookmarkNode(nodes, null, null, false); + expect(result.nodes.length).toBe(0); + expect(result.consumed).toBe(0); + } + }) + it("parses bookmark nodes and w:name attributes", () => { + const nodes = [{name: 'w:bookmarkStart', attributes: {'w:name': 'bookmarkName'}}]; + const result = handleBookmarkNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('standardNodeHandler'); + expect(result.nodes[0].attrs.name).toBe("bookmarkName"); + }) + it("parser relies on handleStandardNode", () => { + const consoleMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const nodes = [{name: 'w:bookmarkStart', attributes: {'w:name': 'bookmarkName'}}]; + const result = handleBookmarkNode(nodes, null, {handlerEntities: []}, false); + expect(result.nodes.length).toBe(0); + expect(result.consumed).toBe(0); + expect(consoleMock).toHaveBeenCalledOnce(); + }) +}) \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js new file mode 100644 index 0000000000..5a91ddacc8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -0,0 +1,164 @@ +import {getInitialJSON} from "../docxHelper.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {twipsToInches} from "../../helpers.js"; +import {tableNodeHandlerEntity} from "./tableImporter.js"; +import {drawingNodeHandlerEntity} from "./imageImporter.js"; +import { + trackChangeNodeHandlerEntity +} from "./trackChangesImporter.js"; +import {hyperlinkNodeHandlerEntity} from "./hyperlinkImporter.js"; +import {runNodeHandlerEntity} from "./runNodeImporter.js"; +import {textNodeHandlerEntity} from "./textNodeImporter.js"; +import {paragraphNodeHandlerEntity} from "./paragraphNodeImporter.js"; +import {standardNodeHandlerEntity} from "./standardNodeImporter.js"; +import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; +import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; + +/** + * @typedef {{type: string, content: *, attrs: {}}} PmNodeJson + * @typedef {{type: string, attrs: {}}} PmMarkJson + * + * @typedef {(nodes: XmlNode[], docx: ParsedDocx, insideTrackCahange: boolean) => PmNodeJson[]} NodeListHandlerFn + * @typedef {{handler: NodeListHandlerFn, handlerEntities: NodeHandlerEntry[]}} NodeListHandler + * + * @typedef {(nodes: XmlNode[], docx: ParsedDocx, nodeListHandler: NodeListHandler, insideTrackCahange: boolean) => {nodes: PmNodeJson[], consumed: number}} NodeHandler + * @typedef {{handlerName: string, handler: NodeHandler}} NodeHandlerEntry + */ + +/** + * + * @param {ParsedDocx} docx + * @returns {{pmNodes: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} + */ +export const createDocumentJson = (docx) => { + const json = carbonCopy(getInitialJSON(docx)); + if (!json) return null; + + console.debug('\n\n JSON', json,) + const nodeListHandler = defaultNodeListHandler(); + if(json.elements[0].elements[0].type === 'w:body') { + const node = json.elements[0].elements[0]; + const ignoreNodes = ['w:sectPr']; + const content = node.elements.filter((n) => !ignoreNodes.includes(n.name)); + + const result = { + type: 'doc', + content: nodeListHandler.handler(content, docx, false), + attrs: { + attributes: json.elements[0].attributes, + } + } + return { + pmNodes: result, + savedTagsToRestore: node, + pageStyles: getDocumentStyles(node), + }; + } + return null; +} + +export const defaultNodeListHandler = () => { + const entities = [ + runNodeHandlerEntity, + paragraphNodeHandlerEntity, + textNodeHandlerEntity, + lineBreakNodeHandlerEntity, + bookmarkNodeHandlerEntity, + hyperlinkNodeHandlerEntity, + drawingNodeHandlerEntity, + trackChangeNodeHandlerEntity, + tableNodeHandlerEntity, + standardNodeHandlerEntity, //this should be the last one, bcs this parses everything!!! + ] + const handler = createNodeListHandler(entities); + return { + handler, + handlerEntities: entities + } +} + +/** + * + * @param {NodeHandlerEntry[]} nodeHandlers + */ +const createNodeListHandler = (nodeHandlers) => { + /** + * @param {XmlNode[]} elements + * @param {ParsedDocx} docx + * @param {boolean} insideTrackChange + * @return {{type: string, content: *, attrs: {attributes}}[]} + */ + const nodeListHandlerFn = (elements, docx, insideTrackChange) => { + if (!elements || !elements.length) return []; + const processedElements = []; + + for (let index = 0; index < elements.length; index++) { + const {nodes, consumed} = nodeHandlers.reduce((res, handler) => { + if(res.consumed > 0) return res; + const nodesToHandle = elements.slice(index); + if(!nodesToHandle || nodesToHandle.length === 0) return res; + return handler.handler(nodesToHandle, docx, {handler: nodeListHandlerFn, handlerEntities: nodeHandlers}, insideTrackChange); + }, {nodes: [], consumed: 0}); + index += consumed-1; + if(consumed === 0) { + console.warn("We have a node that we can't handle!", elements[index]) + } + for(let node of nodes) { + if (node?.type) { + const ignore = ['runProperties']; + if (!ignore.includes(node.type)) processedElements.push(node); + } + } + } + return processedElements; + } + + return nodeListHandlerFn; +} + +/** + * + * @param {XmlNode} node + * @returns {*} + */ +function getDocumentStyles(node) { + const sectPr = node.elements.find((n) => n.name === 'w:sectPr'); + const styles = {}; + + sectPr.elements.forEach((el) => { + const { name, attributes } = el; + switch (name) { + case 'w:pgSz': + styles['pageSize'] = { + width: twipsToInches(attributes['w:w']), + height: twipsToInches(attributes['w:h']), + } + break; + case 'w:pgMar': + styles['pageMargins'] = { + top: twipsToInches(attributes['w:top']), + right: twipsToInches(attributes['w:right']), + bottom: twipsToInches(attributes['w:bottom']), + left: twipsToInches(attributes['w:left']), + header: twipsToInches(attributes['w:header']), + footer: twipsToInches(attributes['w:footer']), + gutter: twipsToInches(attributes['w:gutter']), + } + break; + case 'w:cols': + styles['columns'] = { + space: twipsToInches(attributes['w:space']), + num: attributes['w:num'], + equalWidth: attributes['w:equalWidth'], + } + break; + case 'w:docGrid': + styles['docGrid'] = { + linePitch: twipsToInches(attributes['w:linePitch']), + type: attributes['w:type'], + } + break; + } + }); + return styles; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js new file mode 100644 index 0000000000..e6bcfce742 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js @@ -0,0 +1,60 @@ + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleHyperlinkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:hyperlink') { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + const rels = docx['word/_rels/document.xml.rels']; + const relationships = rels.elements.find((el) => el.name === 'Relationships'); + const { elements } = relationships; + + const { attributes } = node; + const rId = attributes['r:id']; + const anchor = attributes['w:anchor']; + + // TODO: Check if we need this atr + const history = attributes['w:history']; + + const rel = elements.find((el) => el.attributes['Id'] === rId) || {}; + const { attributes: relAttributes = {} } = rel; + let href = relAttributes['Target']; + + if (anchor && !href) href = `#${anchor}`; + + // Add marks to the run node and process it + const runNode = node.elements.find((el) => el.name === 'w:r'); + const linkMark = { type: 'link', attrs: { href } }; + + if (!runNode.marks) runNode.marks = []; + runNode.marks.push(linkMark); + + const rPr = runNode.elements.find((el) => el.name === 'w:rPr'); + if (rPr) { + const styleRel = rPr.elements.find((el) => el.name === 'w:rStyle'); + if (styleRel) { + const styles = docx['word/styles.xml']; + const { elements } = styles.elements[0]; + + const styleElements = elements.filter((el) => el.name === 'w:style'); + const style = styleElements.find((el) => el.attributes['w:styleId'] === 'Hyperlink'); + const styleRpr = style.elements.find((el) => el.name === 'w:rPr'); + if (styleRpr) runNode.elements.unshift(styleRpr); + } + } + + const updatedNode = nodeListHandler.handler([runNode], docx, insideTrackChange); + return {nodes: updatedNode, consumed: 1}; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const hyperlinkNodeHandlerEntity = { + handlerName: 'hyperlinkNodeHandler', + handler: handleHyperlinkNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js new file mode 100644 index 0000000000..42776f4202 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -0,0 +1,77 @@ +import {emuToPixels} from "../../helpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:drawing') { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + let result; + const { elements } = node; + + // Inline images + const inlineImage = elements.find((el) => el.name === 'wp:inline'); + if (inlineImage) result = handleInlineImageNode(inlineImage); + return {nodes: result ? [result] : [], consumed: 1}; +} + +export function handleInlineImageNode(node, docx) { + const { attributes } = node; + const padding = { + top: emuToPixels(attributes['distT']), + bottom: emuToPixels(attributes['distB']), + left: emuToPixels(attributes['distL']), + right: emuToPixels(attributes['distR']), + }; + + const extent = node.elements.find((el) => el.name === 'wp:extent'); + const size = { + width: emuToPixels(extent.attributes['cx']), + height: emuToPixels(extent.attributes['cy']) + } + + // TODO: Do we need this? + const effectExtent = node.elements.find((el) => el.name === 'wp:effectExtent'); + + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const graphicData = graphic.elements.find((el) => el.name === 'a:graphicData'); + + const picture = graphicData.elements.find((el) => el.name === 'pic:pic'); + const blipFill = picture.elements.find((el) => el.name === 'pic:blipFill'); + const blip = blipFill.elements.find((el) => el.name === 'a:blip'); + const { attributes: blipAttributes } = blip; + const rEmbed = blipAttributes['r:embed']; + const print = blipAttributes['r:print']; + + const rels = docx['word/_rels/document.xml.rels']; + const relationships = rels.elements.find((el) => el.name === 'Relationships'); + const { elements } = relationships; + + const rel = elements.find((el) => el.attributes['Id'] === rEmbed); + const { attributes: relAttributes } = rel; + const media = docx.media; + const path = `word/${relAttributes['Target']}`; + + return { + type: 'image', + attrs: { + src: media[path], + alt: 'Image', + title: 'Image', + inline: true, + padding, + size, + } + } +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const drawingNodeHandlerEntity = { + handlerName: 'drawingNodeHandler', + handler: handleDrawingNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js new file mode 100644 index 0000000000..4e498a7b34 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -0,0 +1,85 @@ +import {handleStyleChangeMarks, parseMarks} from "./markImporter.js"; +import {SuperConverter} from "../../SuperConverter.js"; + + +/** + * + * @param {XmlNode} node + * @returns {{elements: *, attributes: {}, marks: *}} + * + */ +export function parseProperties(node) { + /** + * What does it mean for a node to have a properties element? + * It would have a child element that is: w:pPr, w:rPr, w:sectPr + */ + let marks = []; + const { attributes = {}, elements = [] } = node; + const { nodes, paragraphProperties = {}, runProperties = {} } = splitElementsAndProperties(elements); + paragraphProperties.elements = paragraphProperties?.elements?.filter((el) => el.name !== 'w:rPr'); + + // Get the marks from the run properties + if (runProperties && runProperties?.elements?.length) marks = parseMarks(runProperties); + if (paragraphProperties && paragraphProperties.elements?.length) { + marks.push(...parseMarks(paragraphProperties)); + } + //add style change marks + marks.push(...handleStyleChangeMarks(runProperties, marks)); + + // Maintain any extra properties + if (paragraphProperties && paragraphProperties.elements?.length) { + attributes['paragraphProperties'] = paragraphProperties; + } + + // If this is a paragraph, don't apply marks but apply attributes directly + if (marks && node.name === 'w:p') { + marks.forEach((mark) => { + const attrValue = Object.keys(mark.attrs)[0]; + const value = mark.attrs[attrValue]; + attributes[attrValue] = value; + }); + marks = []; + } + return { elements: nodes, attributes, marks } +} + + +/** + * + * @param {XmlNode[]} elements + * @returns {{nodes: *, runProperties: *, sectionProperties: *, paragraphProperties: *}} + */ +function splitElementsAndProperties(elements) { + const pPr = elements.find((el) => el.name === 'w:pPr'); + const rPr = elements.find((el) => el.name === 'w:rPr'); + const sectPr = elements.find((el) => el.name === 'w:sectPr'); + const els = elements.filter((el) => el.name !== 'w:pPr' && el.name !== 'w:rPr' && el.name !== 'w:sectPr'); + + return { + nodes: els, + paragraphProperties: pPr, + runProperties: rPr, + sectionProperties: sectPr, + } +} + +/** + * + * @param {XmlNode} element + * @returns {*} + */ +export function getElementName(element) { + return SuperConverter.allowedElements[element.name || element.type]; +} + + +/** + * + * @param {XmlNode[]} elements + * @returns {*} + */ +export function hasTextNode(elements) { + const runs = elements.filter((el) => el.name === 'w:r'); + const runsHaveText = runs.some((run) => run.elements.some((el) => el.name === 'w:t')); + return runsHaveText; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js new file mode 100644 index 0000000000..eda9d6de7d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -0,0 +1,24 @@ +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:br') { + return {nodes: [], consumed: 0}; + } + + return { + nodes: [{ + type: 'lineBreak', + content: [], + attrs: {}, + }], consumed: 1 + }; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const lineBreakNodeHandlerEntity = { + handlerName: 'lineBreakNodeHandler', + handler: handleLineBreakNode +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js new file mode 100644 index 0000000000..c8a89d5859 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js @@ -0,0 +1,23 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleLineBreakNode} from "./lineBreakImporter.js"; +import {createNodeListHandlerMock} from "./testUtils.test.js"; + +describe('LineBreakNodeImporter', () => { + it("parses only line break nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:br'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleLineBreakNode(nodes, null, null, false); + expect(result.nodes.length).toBe(0); + expect(result.consumed).toBe(0); + } + }); + + it("parses line break nodes and w:br attributes", () => { + const nodes = [{name: 'w:br'}]; + const result = handleLineBreakNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('lineBreak'); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js new file mode 100644 index 0000000000..de1500a64e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -0,0 +1,309 @@ +import {convertToSchema} from "./docxImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {hasTextNode} from "./importerHelpers.js"; + + +export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:p') { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + let schemaNode; + + // We need to pre-process paragraph nodes to combine various possible elements we will find ie: lists, links. + const processedElements = preProcessNodesForFldChar(node.elements); + node.elements = processedElements; + + // Check if this paragraph node is a list + if (testForList(node)) { + // Get all siblings that are list items and haven't been processed yet. + const siblings = carbonCopy(nodes); + const listItems = []; + let consumed = 0; + + // Iterate each item until we find the end of the list (a non-list item), + // then send to the list handler for processing. + let possibleList = siblings.shift(); + while (possibleList && testForList(possibleList, true)) { + listItems.push(possibleList); + possibleList = siblings.shift(); + if (possibleList?.elements && !hasTextNode(possibleList.elements)) { + listItems.push(possibleList); + possibleList = siblings.shift(); + } + } + + // TODO - Check that this change is OK + return {nodes: handleListNodes(listItems, 0, node), consumed: listItems.filter(i => i.seen).length}; + } else { + return {nodes: [], consumed: 0}; + } +} + + + + +/** + * List processing + * + * This recursive function takes a list of known list items and combines them into nested lists. + * + * It begins with listLevel = 0, and if we find an indented node, we call this function again and increase the level. + * with the same set of list items (as we do not know the node levels until we process them). + * + * @param {Array} listItems - Array of list items to process. + * @param {number} [listLevel=0] - The current indentation level of the list. + * @returns {Object} The processed list node with structured content. + */ +function handleListNodes(listItems, listLevel = 0) { + const parsedListItems = []; + let overallListType; + let listStyleType; + + for (let [index, item] of listItems.entries()) { + // Skip items we've already processed + if (item.seen) continue; + + // Sometimes there are paragraph nodes that only have pPr element and no text node - these are + // Spacers in the XML and need to be appended to the last item. + if (item.elements && !hasTextNode(item.elements)) { + const n = handleStandardNode(item, listItems, index); + parsedListItems[parsedListItems.length - 1]?.content.push(n); + item.seen = true; + continue; + } + + // Get the properties of the node - this is where we will find depth level for the node + // As well as many other list properties + const { attributes, elements, marks = [] } = parseProperties(item); + const { + listType, + listOrderingType, + ilvl, + listrPrs, + listpPrs, + start, + lvlText, + lvlJc + } = getNodeNumberingDefinition(attributes, listLevel, docx); + listStyleType = listOrderingType; + const intLevel = parseInt(ilvl); + + // Append node if it belongs on this list level + const nodeAttributes = {}; + if (listLevel === intLevel) { + overallListType = listType; + item.seen = true; + + const schemaElements = []; + schemaElements.push({ + type: 'paragraph', + content: convertToSchema(elements)?.filter(n => n) + }); + + console.debug('\n\n LIST ITEM', listpPrs, listrPrs, start, lvlText, lvlJc, '\n\n') + + if (listpPrs) nodeAttributes['listParagraphProperties'] = listpPrs; + if (listrPrs) nodeAttributes['listRunProperties'] = listrPrs; + nodeAttributes['order'] = start; + nodeAttributes['lvlText'] = lvlText; + nodeAttributes['lvlJc'] = lvlJc; + nodeAttributes['attributes'] = { + parentAttributes: item?.attributes || null, + } + parsedListItems.push(createListItem(schemaElements, nodeAttributes, [])); + } + + // If this item belongs in a deeper list level, we need to process it by calling this function again + // But going one level deeper. + else if (listLevel < intLevel) { + const sublist = handleListNodes(listItems.slice(index), listLevel + 1); + const lastItem = parsedListItems[parsedListItems.length - 1]; + if (!lastItem) { + parsedListItems.push(createListItem([sublist], nodeAttributes, [])); + } else { + lastItem.content.push(sublist); + } + } + + // If this item belongs in a higher list level, we need to break out of the loop and return to higher levels + else break; + } + + return { + type: overallListType || 'bulletList', + content: parsedListItems, + attrs: { + 'list-style-type': listStyleType, + attributes: { + 'parentAttributes': listItems[0]?.attributes || null, + } + } + }; +} + + +/** + * + * @param {XmlNode} node + * @param {boolean} isInsideList + * @returns {boolean|*} + */ +export function testForList(node, isInsideList = false) { + const { elements } = node; + const pPr = elements?.find(el => el.name === 'w:pPr') + if (!pPr) return false; + + const paragraphStyle = pPr.elements?.find(el => el.name === 'w:pStyle'); + const isList = paragraphStyle?.attributes['w:val'] === 'ListParagraph'; + const hasNumPr = pPr.elements?.find(el => el.name === 'w:numPr'); + return isList || hasNumPr; +} + + +/** + * Creates a list item node with specified content and marks. + * + * @param {Array} content - The content of the list item. + * @param {Array} marks - The marks associated with the list item. + * @returns {Object} The created list item node. + */ +function createListItem(content, attrs, marks) { + return { + type: 'listItem', + content, + attrs, + marks, + }; +} + + + +const orderedListTypes = [ + "decimal", // eg: 1, 2, 3, 4, 5, ... + "decimalZero", // eg: 01, 02, 03, 04, 05, ... + "lowerRoman", // eg: i, ii, iii, iv, v, ... + "upperRoman", // eg: I, II, III, IV, V, ... + "lowerLetter", // eg: a, b, c, d, e, ... + "upperLetter", // eg: A, B, C, D, E, ... + "ordinal", // eg: 1st, 2nd, 3rd, 4th, 5th, ... + "cardinalText", // eg: one, two, three, four, five, ... + "ordinalText", // eg: first, second, third, fourth, fifth, ... + "hex", // eg: 0, 1, 2, ..., 9, A, B, C, ..., F, 10, 11, ... + "chicago", // eg: (0, 1, 2, ..., 9, 10, 11, 12, ..., 19, 1A, 1B, 1C, ..., 1Z, 20, 21, ..., 2Z) +]; + +const unorderedListTypes = [ + "bullet", // A standard bullet point (•) + "square", // Square bullets (▪) + "circle", // Circle bullets (◦) + "disc", // Disc bullets (●) +] + +/** + * Main function to get list item information from numbering.xml + * + * @param {object} attributes + * @param {int} level + * @param {ParsedDocx} docx + * @returns + */ +function getNodeNumberingDefinition(attributes, level, docx) { + if (!attributes) return; + + const def = docx['word/numbering.xml']; + if (!def) return {}; + + const { elements } = def; + const listData = elements[0]; + + const { paragraphProperties } = attributes; + const { elements: listStyles } = paragraphProperties; + const numPr = listStyles.find(style => style.name === 'w:numPr'); + if (!numPr) { + return {}; + throw new Error(`No numbering properties found in paragraph: ${JSON.stringify(attributes)}`); + } + + // Get the indent level + const ilvlTag = numPr.elements.find(style => style.name === 'w:ilvl'); + const ilvl = ilvlTag.attributes['w:val']; + + // Get the list style id + const numIdTag = numPr.elements.find(style => style.name === 'w:numId'); + const numId = numIdTag.attributes['w:val']; + + + // Get the list styles + const numberingElements = listData.elements; + const abstractDefinitions = numberingElements.filter(style => style.name === 'w:abstractNum') + const numDefinitions = numberingElements.filter(style => style.name === 'w:num') + const numDefinition = numDefinitions.find(style => style.attributes['w:numId'] === numId); + const abstractNumId = numDefinition?.elements[0].attributes['w:val'] + const listDefinitionForThisNumId = abstractDefinitions?.find(style => style.attributes['w:abstractNumId'] === abstractNumId); + + // Determine list type and formatting for this list level + const currentLevel = getDefinitionForLevel(listDefinitionForThisNumId, level); + if (!currentLevel) return {} + + const start = currentLevel.elements.find(style => style.name === 'w:start')?.attributes['w:val']; + const listTypeDef = currentLevel.elements.find(style => style.name === 'w:numFmt').attributes['w:val']; + const lvlText = currentLevel.elements.find(style => style.name === 'w:lvlText').attributes['w:val']; + const lvlJc = currentLevel.elements.find(style => style.name === 'w:lvlJc').attributes['w:val']; + + // Properties - there can be run properties and paragraph properties + const pPr = currentLevel.elements.find(style => style.name === 'w:pPr'); + let listpPrs, listrPrs; + if (pPr) listpPrs = _processListParagraphProperties(pPr); + + const rPr = currentLevel.elements.find(style => style.name === 'w:rPr'); + if (rPr) listrPrs = _processListRunProperties(rPr); + + // Get style for this list level + let listType; + if (unorderedListTypes.includes(listTypeDef.toLowerCase())) listType = 'bulletList'; + else if (orderedListTypes.includes(listTypeDef)) listType = 'orderedList'; + else { + throw new Error(`Unknown list type found during import: ${listTypeDef}`); + } + + return { listType, listOrderingType: listTypeDef, ilvl, numId, listrPrs, listpPrs, start, lvlText, lvlJc }; +} + +function getDefinitionForLevel(data, level) { + return data?.elements?.find((item) => Number(item.attributes['w:ilvl']) === level); +} + +function _processListParagraphProperties(data) { + const { elements } = data; + const expectedTypes = ['w:ind', 'w:jc', 'w:tabs']; + const paragraphProperties = {}; + if (!elements) return paragraphProperties; + + elements.forEach((item) => { + if (!expectedTypes.includes(item.name)) throw new Error(`[numbering.xml] Unexpected list paragraph prop found: ${item.name}`); + const { attributes = {} } = item; + Object.keys(attributes).forEach(key => { + paragraphProperties[key] = attributes[key]; + }); + }); + return paragraphProperties; +} + +function _processListRunProperties(data) { + const { elements } = data; + const expectedTypes = ['w:rFonts', 'w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:strike', 'w:dstrike', 'w:color', 'w:sz', 'w:szCs', 'w:u', 'w:bdr', 'w:shd', 'w:vertAlign', 'w:jc', 'w:spacing', 'w:w', 'w:smallCaps']; + const runProperties = {}; + if (!elements) return runProperties; + + elements.forEach((item) => { + if (!expectedTypes.includes(item.name)) throw new Error(`[numbering.xml] Unexpected list run prop found: ${item.name}`); + const { attributes = {} } = item; + Object.keys(attributes).forEach(key => { + runProperties[key] = attributes[key]; + }); + }); + return runProperties; +} + diff --git a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js new file mode 100644 index 0000000000..5178e7cdd6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -0,0 +1,160 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {TrackMarksMarkName} from "../../../../extensions/track-changes/constants.js"; +import {twipsToInches} from "../../helpers.js"; + +/** + * + * @param property + * @returns {PmMarkJson[]} + */ +export function parseMarks(property) { + const marks = []; + const seen = new Set(); + + property.elements.forEach((element) => { + const marksForType = SuperConverter.markTypes.filter((mark) => mark.name === element.name); + if (!marksForType.length) { + const missingMarks = [ + 'w:shd', + 'w:rStyle', + 'w:pStyle', + 'w:numPr', + 'w:outlineLvl', + 'w:bdr', + 'w:pBdr', + 'w:noProof', + 'w:highlight', + 'w:contextualSpacing', + 'w:keepNext', + 'w:tabs', + 'w:keepLines' + ] + if (missingMarks.includes(element.name)) console.debug('❗️❗️ATTN: No marks found for element:', element.name); + // else throw new Error(`No marks found for element: ${element.name}`); + } + + marksForType.forEach((m) => { + if (!m || seen.has(m.type)) return; + seen.add(m.type); + + const { attributes = {} } = element; + const newMark = { type: m.type } + + if (attributes['w:val'] == "0" || attributes['w:val'] === 'none') { + return; + } + + // Use the parent mark (ie: textStyle) if present + if (m.mark) newMark.type = m.mark; + + // Marks with attrs: we need to get their values + if (Object.keys(attributes).length) { + const value = getMarkValue(m.type, attributes); + + newMark.attrs = {}; + newMark.attrs[m.property] = value; + } + marks.push(newMark); + }) + }); + return createImportMarks(marks); +} + + +/** + * + * @param {XmlNode} rPr + * @param {PmMarkJson[]} currentMarks + * @returns {PmMarkJson[]} a trackMarksMark, or an empty array + */ +export function handleStyleChangeMarks(rPr, currentMarks) { + const styleChangeMark = rPr.elements?.find((el) => el.name === 'w:rPrChange') + if(!styleChangeMark) { + return [] + } + + const { attributes } = styleChangeMark; + const mappedAttributes = { + wid: attributes['w:id'], + date: attributes['w:date'], + author: attributes['w:author'], + } + const submarks = parseMarks(styleChangeMark); + return [{type: TrackMarksMarkName, attrs: {...mappedAttributes, before: submarks, after: [...currentMarks]}}] +} + + +/** + * + * @param {PmMarkJson[]} marks + * @returns {PmMarkJson[]} + */ +export function createImportMarks(marks) { + const textStyleMarksToCombine = marks.filter((mark) => mark.type === 'textStyle'); + const remainingMarks = marks.filter((mark) => mark.type !== 'textStyle'); + + // Combine text style marks + const combinedTextAttrs = {}; + if (textStyleMarksToCombine.length) { + textStyleMarksToCombine.forEach((mark) => { + const { attrs } = mark; + + Object.keys(attrs).forEach((attr) => { + combinedTextAttrs[attr] = attrs[attr]; + }); + }); + }; + + const result = [...remainingMarks, { type: 'textStyle', attrs: combinedTextAttrs }]; + return result; +} + + +/** + * + * @param {string} markType + * @param attributes + * @returns {*} + */ +function getMarkValue(markType, attributes) { + if (markType === 'tabs') markType = 'textIndent'; + + const markValueMapper = { + color: () => `#${attributes['w:val']}`, + fontSize: () => `${attributes['w:val']/2}pt`, + textIndent: () => getIndentValue(attributes), + fontFamily: () => attributes['w:ascii'], + lineHeight: () => getLineHeightValue(attributes), + textAlign: () => attributes['w:val'], + link: () => attributes['href'], + underline: () => attributes['w:val'], + } + + if (!(markType in markValueMapper)) { + console.debug('\n\n ❗️❗️ No value mapper for:', markType, 'Attributes:', attributes) + }; + + // Returned the mapped mark value + if (markType in markValueMapper) { + const f = markValueMapper[markType]; + return markValueMapper[markType](); + } +} + + +function getIndentValue(attributes) { + let value = attributes['w:left']; + if (!value) value = attributes['w:firstLine']; + return `${twipsToInches(value)}in` +} + +function getLineHeightValue(attributes) { + let value = attributes['w:line']; + + // TODO: Figure out handling of additional line height attributes from docx + // if (!value) value = attributes['w:lineRule']; + // if (!value) value = attributes['w:after']; + // if (!value) value = attributes['w:before']; + if (!value || value === "0") return null; + return `${twipsToInches(value)}in`; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js new file mode 100644 index 0000000000..79c993ffe8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -0,0 +1,151 @@ +import {twipsToPixels} from "../../helpers.js"; +import {testForList} from "./listImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; + +/** + * Special cases of w:p based on paragraph properties + * + * If we detect a list node, we need to get all nodes that are also lists and process them together + * in order to combine list item nodes into list nodes. + * + * @type {import("docxImporter").NodeHandler} + */ +export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:p') { + return {nodes: [], consumed: 0}; + } + const node = carbonCopy(nodes[0]) + + let schemaNode; + + // We need to pre-process paragraph nodes to combine various possible elements we will find ie: lists, links. + const processedElements = preProcessNodesForFldChar(node.elements); + node.elements = processedElements; + + // Check if this paragraph node is a list + if (testForList(node)) { + return {nodes: [], consumed: 0}; + } + + // If it is a standard paragraph node, process normally + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.nodes.length === 1) { + schemaNode = result.nodes[0]; + } + + if ('attributes' in node) { + const defaultStyleId = node.attributes['w:rsidRDefault']; + const { lineSpaceAfter, lineSpaceBefore } = getDefaultStyleDefinition(defaultStyleId, docx); + + if (!('attributes' in schemaNode)) schemaNode.attributes = {}; + schemaNode.attrs['paragraphSpacing'] = { lineSpaceAfter, lineSpaceBefore }; + } + return { nodes: schemaNode ? [schemaNode] : [], consumed: 1 }; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const paragraphNodeHandlerEntity = { + handlerName: 'paragraphNodeHandler', + handler: handleParagraphNode +}; + + +/** + * TODO: There are so many possible styles here - confirm what else we need. + * @param {string} defaultStyleId + * @param {ParsedDocx} docx + */ +function getDefaultStyleDefinition(defaultStyleId, docx) { + const result = { lineSpaceBefore: null, lineSpaceAfter: null }; + const styles = docx['word/styles.xml']; + if (!styles) return result; + + const { elements } = styles.elements[0]; + // console.debug('Default style ID elements:', elements) + const elementsWithId = elements.filter((el) => { + return el.elements.some((e) => { + return 'attributes' in e && e.attributes['w:val'] === defaultStyleId; + }); + }); + + const firstMatch = elementsWithId[0]; + if (!firstMatch) return result; + + const pPr = firstMatch.elements.find((el) => el.name === 'w:pPr'); + const spacing = pPr?.elements.find((el) => el.name === 'w:spacing'); + if (!spacing) return result; + const lineSpaceBefore = twipsToPixels(spacing.attributes['w:before']); + const lineSpaceAfter = twipsToPixels(spacing.attributes['w:after']); + return { lineSpaceBefore, lineSpaceAfter }; +} + +/** + * We need to pre-process nodes in a paragraph to combine nodes together where necessary ie: links + * TODO: Likely will find more w:fldChar to deal with. + * + * @param {XmlNode[]} nodes + * @returns + */ +function preProcessNodesForFldChar(nodes) { + const processedNodes = []; + const nodesToCombine = []; + let isCombiningNodes = false; + nodes?.forEach((n) => { + const fldChar = n.elements?.find((el) => el.name === 'w:fldChar'); + if (fldChar) { + const fldType = fldChar.attributes['w:fldCharType']; + if (fldType === 'begin') { + isCombiningNodes = true; + nodesToCombine.push(n); + } else if (fldType === 'end') { + nodesToCombine.push(n); + isCombiningNodes = false; + } + } + + if (isCombiningNodes) { + nodesToCombine.push(n); + } else if (!isCombiningNodes && nodesToCombine.length) { + + // Need to extract all nodes between 'separate' and 'end' fldChar nodes + const textStart = nodesToCombine.findIndex((n) => n.elements?.some((el) => el.name === 'w:fldChar' && el.attributes['w:fldCharType'] === 'separate')); + const textEnd = nodesToCombine.findIndex((n) => n.elements?.some((el) => el.name === 'w:fldChar' && el.attributes['w:fldCharType'] === 'end')); + const textNodes = nodesToCombine.slice(textStart + 1, textEnd); + const instrText = nodesToCombine.find((n) => n.elements?.some((el) => el.name === 'w:instrText'))?.elements[0]?.elements[0].text; + const urlMatch = instrText.match(/HYPERLINK\s+"([^"]+)"/); + + if (!urlMatch || urlMatch?.length < 2) return []; + const url = urlMatch[1]; + + const textMarks = []; + textNodes.forEach((n) => { + const rPr = n.elements.find((el) => el.name === 'w:rPr'); + if (!rPr) return; + + const { elements } = rPr; + elements.forEach((el) => { + textMarks.push(el); + }); + }); + + // Create a rPr and replace all nodes with the updated node. + const linkMark = { name: 'link', attributes: { href: url} }; + const rPr = { name: 'w:rPr', type: 'element', elements: [linkMark, ...textMarks] } + processedNodes.push({ + name: 'w:r', + type: 'element', + elements: [rPr, ...textNodes] + }); + } else { + processedNodes.push(n); + } + }) + return processedNodes; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js new file mode 100644 index 0000000000..dc83bcaf50 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -0,0 +1,27 @@ +import {parseProperties} from "./importerHelpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || nodes[0].name !== 'w:r') { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + let processedRun = nodeListHandler.handler(node.elements, docx, insideTrackChange)?.filter(n => n) || []; + const hasRunProperties = node.elements.some(el => el.name === 'w:rPr'); + if (hasRunProperties) { + const { marks = [], attributes = {} } = parseProperties(node); + if (node.marks) marks.push(...node.marks); + processedRun = processedRun.map(n => ({ ...n, marks, attributes })); + } + return {nodes: processedRun, consumed: 1}; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const runNodeHandlerEntity = { + handlerName: 'runNodeHandler', + handler: handleRunNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js new file mode 100644 index 0000000000..7e75629693 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -0,0 +1,50 @@ +import {convertToSchema} from "./docxImporter.js"; +import {getElementName, parseProperties} from "./importerHelpers.js"; +import {handleTextNode} from "./textNodeImporter.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(!nodes || nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + // Parse properties + const { name, type } = node; + const { attributes, elements, marks = [] } = parseProperties(node); + + // Iterate through the children and build the schemaNode content + const content = []; + if (elements && elements.length) { + const updatedElements = elements.map((el) => { + if (!el.marks) el.marks = []; + el.marks.push(...marks); + return el; + }) + content.push(...nodeListHandler.handler(updatedElements)); + } + + const resultNode = { + type: getElementName(node), + content, + attrs: { ...attributes }, + marks: [], + }; + + if(node.name === 'w:tab') { + resultNode.content = [{ type: 'text', text: ' ' }]; + return { nodes: [resultNode], consumed: 1 }; + } else { + return { nodes: [resultNode], consumed: 1 }; + } +} + + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const standardNodeHandlerEntity = { + handlerName: 'standardNodeHandler', + handler: handleStandardNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js new file mode 100644 index 0000000000..44d3637ecc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -0,0 +1,225 @@ +import {halfPointToPixels, twipsToInches, twipsToPixels} from "../../helpers.js"; + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleAllTableNodes = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + switch (node.name) { + case 'w:tbl': + return {nodes: [handleTableNode(node, docx, nodeListHandler)], consumed: 1}; + case 'w:tr': + return {nodes: [handleTableRowNode(node, undefined, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + case 'w:tc': + return {nodes: [handleTableCellNode(node, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + } + + return {nodes: [], consumed: 0}; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const tableNodeHandlerEntity = { + handlerName: 'tableNodeHandler', + handler: handleAllTableNodes +}; + + + +/** + * + * @param {XmlNode} node + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {{type: string, content: *, attrs: {borders: *, tableWidth: *, tableWidthType: *, gridColumnWidths: *}}} + */ +export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) { + // Table styles + const tblPr = node.elements.find((el) => el.name === 'w:tblPr'); + + // Table borders can be specified in tblPr or inside a referenced style tag + const tableBordersElement = tblPr.elements.find((el) => el.name === 'w:tblBorders'); + const tableBorders = tableBordersElement?.elements || []; + const { borders, rowBorders } = processTableBorders(tableBorders); + const tblStyleTag = tblPr.elements.find((el) => el.name === 'w:tblStyle'); + const referencedStyles = getReferencedTableStyles(tblStyleTag, docx); + + const tblW = tblPr.elements.find((el) => el.name === 'w:tblW'); + const tableWidth = twipsToInches(tblW.attributes['w:w']); + const tableWidthType = tblW.attributes['w:type']; + + // TODO: What does this do? + // const tblLook = tblPr.elements.find((el) => el.name === 'w:tblLook'); + const tblGrid = node.elements.find((el) => el.name === 'w:tblGrid'); + const gridColumnWidths = tblGrid.elements.map((el) => twipsToInches(el.attributes['w:w'])); + + const rows = node.elements.filter((el) => el.name === 'w:tr'); + + const borderData = Object.keys(borders)?.length ? borders : referencedStyles.borders; + const borderRowData = Object.keys(rowBorders)?.length ? rowBorders : referencedStyles.rowBorders; + const content = rows.map((row) => handleTableRowNode(row, borderRowData, docx, nodeListHandler, insideTrackChange)); + + return { + type: 'table', + content, + attrs: { + tableWidth, + tableWidthType, + gridColumnWidths, + borders: borderData + } + } +} + + +/** + * + * @param node + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {{type: string, content: (*|*[]), attrs: {}}} + */ +export function handleTableCellNode(node, docx, nodeListHandler, insideTrackChange) { + const tcPr = node.elements.find((el) => el.name === 'w:tcPr'); + const borders = tcPr?.elements?.find((el) => el.name === 'w:tcBorders'); + const tcWidth = tcPr?.elements?.find((el) => el.name === 'w:tcW'); + const width = tcWidth ? twipsToInches(tcWidth.attributes['w:w']) : null; + const widthType = tcWidth?.attributes['w:type']; + + // TODO: Do we need other background attrs? + const backgroundColor = tcPr?.elements?.find((el) => el.name === 'w:shd'); + const background = { + color: backgroundColor?.attributes['w:fill'], + } + + const colspanTag = tcPr?.elements?.find((el) => el.name === 'w:gridSpan'); + const colspan = colspanTag?.attributes['w:val']; + + const marginTag = tcPr?.elements?.find((el) => el.name === 'w:tcMar'); + const marginLeft = marginTag?.elements?.find((el) => el.name === 'w:left'); + const marginRight = marginTag?.elements?.find((el) => el.name === 'w:right'); + const marginTop = marginTag?.elements?.find((el) => el.name === 'w:top'); + const marginBottom = marginTag?.elements?.find((el) => el.name === 'w:bottom'); + + const verticalAlignTag = tcPr?.elements?.find((el) => el.name === 'w:vAlign'); + const verticalAlign = verticalAlignTag?.attributes['w:val']; + + const attributes = {}; + if (width) attributes['width'] = width; + if (widthType) attributes['widthType'] = widthType; + if (colspan) attributes['colspan'] = colspan; + if (background) attributes['background'] = background; + if (verticalAlign) attributes['verticalAlign'] = verticalAlign; + + return { + type: 'tableCell', + content: nodeListHandler.handler(node.elements, docx, insideTrackChange), + attrs: attributes, + } +} + +/** + * + * @param tblStyleTag + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @returns {{uiPriotity: *, borders: {}, name: *, rowBorders: {}, basedOn: *}|null} + */ +function getReferencedTableStyles(tblStyleTag, docx, nodeListHandler) { + if (!tblStyleTag) return null; + + const { attributes } = tblStyleTag; + const tableStyleReference = attributes['w:val']; + if (!tableStyleReference) return null; + + const styles = docx['word/styles.xml']; + const { elements } = styles.elements[0]; + const styleElements = elements.filter((el) => el.name === 'w:style'); + const styleTag = styleElements.find((el) => el.attributes['w:styleId'] === tableStyleReference); + if (!styleTag) return null; + + const name = styleTag.elements.find((el) => el.name === 'w:name'); + const basedOn = styleTag.elements.find((el) => el.name === 'w:basedOn'); + const uiPriotity = styleTag.elements.find((el) => el.name === 'w:uiPriority'); + + const tblPr = styleTag.elements.find((el) => el.name === 'w:tblPr'); + const tableBorders = tblPr?.elements.find((el) => el.name === 'w:tblBorders'); + const { elements: borderElements = [] } = tableBorders || {}; + const { borders, rowBorders } = processTableBorders(borderElements); + + return { + name, + basedOn, + uiPriotity, + borders, + rowBorders, + } +} + +function processTableBorders(borderElements) { + const borders = {}; + const rowBorders = {}; + borderElements.forEach((borderElement) => { + const { name } = borderElement; + const borderName = name.split('w:')[1]; + const { attributes } = borderElement; + + const attrs = {}; + const color = attributes['w:color']; + const size = attributes['w:sz']; + if (color && color !== 'auto') attrs['color'] = `#${color}`; + if (size && size !== 'auto') attrs['size'] = halfPointToPixels(size); + + const rowBorderNames = ['insideH', 'insideV']; + if (rowBorderNames.includes(borderName)) rowBorders[borderName] = attrs; + borders[borderName] = attrs; + }); + + return { + borders, + rowBorders + } +} + +/** + * + * @param node + * @param {undefined | null | {insideH?: *, insideV?: *}} rowBorders + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {*} + */ +export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insideTrackChange) { + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + const newNode = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + + const tPr = node.elements.find((el) => el.name === 'w:trPr'); + const rowHeightTag = tPr?.elements.find((el) => el.name === 'w:trHeight'); + const rowHeight = rowHeightTag?.attributes['w:val']; + const rowHeightRule = rowHeightTag?.attributes['w:hRule']; + + const borders = {}; + if (rowBorders?.insideH) borders['bottom'] = rowBorders.insideH; + if (rowBorders?.insideV) borders['right'] = rowBorders.insideV; + newNode.attrs['borders'] = borders; + + if (rowHeight && newNode.attrs['rowHeight']) { + newNode.attrs['rowHeight'] = twipsToPixels(rowHeight); + console.debug('Row node:', newNode); + } + + return newNode; +} diff --git a/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js b/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js new file mode 100644 index 0000000000..64025bd0e1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js @@ -0,0 +1,29 @@ +/** + * + * @returns {NodeListHandler} + */ +export const createNodeListHandlerMock = () => { + return { + handlerEntities: [ + { + handlerName: 'standardNodeHandler', + handler: () => ({ + nodes: [{ + type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + }, + { + handlerName: 'textNodeHandler', + handler: () => ({ + nodes: [{ + type: 'textNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + } + ], + handler: () => [{type: "dummyNode", content: {}, attrs: {}}] + } +} + +test.skip("", () => {}) \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js new file mode 100644 index 0000000000..6631bf062d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js @@ -0,0 +1,44 @@ +import {getElementName, parseProperties} from "./importerHelpers.js"; + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleTextNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || !(nodes[0].name === 'w:t' || (insideTrackChange && nodes[0].name === 'w:delText'))) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + const { type } = node; + + // Parse properties + const { attributes, elements, marks = [] } = parseProperties(node); + + // Text nodes have no children. Only text, and there should only be one child + let text; + if (elements.length === 1) text = elements[0].text; + + // Word sometimes will have an empty text node with a space attribute, in that case it should be a space + else if (!elements.length && 'attributes' in node && node.attributes['xml:space'] === 'preserve') { + text = ' '; + } + + // Ignore others - can catch other special cases here if necessary + else return {nodes: [], consumed: 0}; + + return {nodes: [{ + type: getElementName(node), + text: text, + attrs: { type, attributes: attributes || {}, }, + marks, + }], consumed: 1}; +} + + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const textNodeHandlerEntity = { + handlerName: 'textNodeHandler', + handler: handleTextNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js new file mode 100644 index 0000000000..50258165a1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js @@ -0,0 +1,33 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTextNode} from "./textNodeImporter.js"; +import {createNodeListHandlerMock} from "./testUtils.test.js"; + +describe('TextNodeImporter', () => { + it("parses only text nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:t'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleTextNode(nodes, null, null, false); + expect(result.nodes.length).toBe(0); + expect(result.consumed).toBe(0); + } + }); + + it("parses text nodes with xml:space attribute", () => { + const nodes = [{name: 'w:t', attributes: {'xml:space': 'preserve'}, elements: []}]; + const result = handleTextNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('text'); + expect(result.nodes[0].text).toBe(" "); + }); + + it("parses text nodes", () => { + const nodes = [{name: 'w:t', attributes: {}, elements: [{text: "This is a test text!"}]}]; + const result = handleTextNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('text'); + expect(result.nodes[0].text).toBe("This is a test text!"); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js new file mode 100644 index 0000000000..9f49f62cb9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -0,0 +1,37 @@ +import {TrackDeleteMarkName, TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; +import {parseProperties} from "./importerHelpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || !(nodes[0].name === 'w:del' || nodes[0].name === 'w:ins')) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + const { name } = node; + const { attributes, elements } = parseProperties(node); + + const subs = nodeListHandler.handler(elements, docx,true) + const changeType = name === 'w:del' ? TrackDeleteMarkName : TrackInsertMarkName; + const mappedAttributes = { + wid: attributes['w:id'], + date: attributes['w:date'], + author: attributes['w:author'], + } + + subs.forEach(subElement => { + if(subElement.marks === undefined) subElement.marks = []; + subElement.marks.push({ type: changeType, attrs: mappedAttributes }); + }); + + return {nodes: subs, consumed: 1}; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const trackChangeNodeHandlerEntity = { + handlerName: 'trackChangeNodeHandler', + handler: handleTrackChangeNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js new file mode 100644 index 0000000000..0c7ae7489d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js @@ -0,0 +1,143 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTrackChangeNode, handleDelText} from "./trackChangesImporter.js"; +import {createNodeListHandlerMock} from "./testUtils.test.js"; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../../../extensions/track-changes/constants.js"; +import {parseXmlToJson} from "../docxHelper.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; + +describe('TrackChangesImporter', () => { + it("parses only track change nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:del' && name !== 'w:ins'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleTrackChangeNode(nodes, null, null, false); + expect(result.nodes.length).toBe(0); + expect(result.consumed).toBe(0); + } + }); + + it("parses track change del node and their attributes", () => { + const nodes = [{ + name: 'w:del', + attributes: {'w:id': '1', 'w:date': '2023-10-01', 'w:author': 'Author'}, + elements: [ + {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} + ]}]; + const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].marks[0].type).toBe(TrackDeleteMarkName); + expect(result.nodes[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + }); + + it("parses track change ins node and their attributes", () => { + const nodes = [{ + name: 'w:ins', + attributes: {'w:id': '1', 'w:date': '2023-10-01', 'w:author': 'Author'}, + elements: [ + {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} + ]}]; + const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].marks[0].type).toBe(TrackInsertMarkName); + expect(result.nodes[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + }); +}); + + +describe("trackChanges live xml test", () => { + const inserXml = ` + + + + + short + + ` + const deleteXml = ` + + + + + long + + ` + const markChangeXml = ` + + + + + + + + + + + + that + + ` + + it("parses insert xml", () => { + const nodes = parseXmlToJson(inserXml).elements + const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + const insertionMark = result.nodes[0].marks.find(mark => mark.type === TrackInsertMarkName); + expect(insertionMark).toBeDefined(); + expect(insertionMark.attrs).toEqual({ + wid: '0', + date: '2024-09-02T15:56:00Z', + author: 'torcsi@harbourcollaborators.com', + }); + expect(result.nodes[0].text).toBe('short '); + }); + it("parses delete xml", () => { + const nodes = parseXmlToJson(deleteXml).elements + const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + const deletionMark = result.nodes[0].marks.find(mark => mark.type === TrackDeleteMarkName); + expect(deletionMark).toBeDefined(); + expect(deletionMark.attrs).toEqual({ + wid: '1', + date: '2024-09-02T15:56:00Z', + author: 'torcsi@harbourcollaborators.com', + }); + expect(result.nodes[0].text).toBe('long '); + }); + it("parses mark change xml", () => { + const nodes = parseXmlToJson(markChangeXml).elements + console.log(nodes) + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, null, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content.length).toBe(1); + const changeMark = result[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(changeMark).toBeDefined(); + expect(changeMark.attrs).toEqual({ + wid: '2', + date: '2024-09-04T09:29:00Z', + author: 'torcsi@harbourcollaborators.com', + before: [ + { + type: 'textStyle', + attrs: {} + } + ], + after: [ + { + type: 'bold', + }, + { + type: 'textStyle', + attrs: {} + }, + ] + }); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/utilities/cabonCopy.js b/packages/super-editor/src/core/utilities/cabonCopy.js new file mode 100644 index 0000000000..63bd3cd0d9 --- /dev/null +++ b/packages/super-editor/src/core/utilities/cabonCopy.js @@ -0,0 +1,14 @@ +/** + * + * @template T + * @param {T} obj + * @returns {T} + */ +export const carbonCopy = (obj) => { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (e) { + console.error('Error in carbonCopy', obj, e); + return undefined; + } +} \ No newline at end of file From 05f2431f9cee89914051ff6bd5a6b2a07d07f795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 9 Sep 2024 11:57:24 +0200 Subject: [PATCH 2/3] functional import refactor v2 - used instead of importer v1 --- .../core/super-converter/SuperConverter.js | 13 +- .../v2/importer/docxImporter.js | 10 +- .../v2/importer/imageImporter.js | 2 +- .../v2/importer/lineBreakImporter.js | 8 +- .../v2/importer/listImporter.js | 1 - .../v2/importer/paragraphNodeImporter.js | 6 + .../v2/importer/standardNodeImporter.js | 4 +- .../v2/importer/tableImporter.js | 20 ++- .../v2/importer/tableImporter.test.js | 121 ++++++++++++++++++ .../v2/importer/trackChangesImporter.test.js | 1 - 10 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index a521e21d34..396a4f8e02 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1,9 +1,10 @@ import xmljs from 'xml-js'; import { getNodeNumberingDefinition } from './numbering'; -import { toKebabCase } from '@harbour-enterprises/common'; import { DocxExporter, exportSchemaToJson } from './exporter'; import { DocxImporter } from './importer'; +import {createDocumentJson} from "./v2/importer/docxImporter.js"; +import {getInitialJSON} from "./v2/docxHelper.js"; class SuperConverter { @@ -158,8 +159,14 @@ class SuperConverter { } getSchema() { - const importer = new DocxImporter(this); - return importer.getSchema(); + const result = createDocumentJson({...this.convertedXml, media: this.media}); + if (result) { + this.savedTagsToRestore.push({...result.savedTagsToRestore}); + this.pageStyles = result.pageStyles; + return result.pmDoc; + } else { + return null; + } } schemaToXml(data) { diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 5a91ddacc8..792a7bed70 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -15,6 +15,7 @@ import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; /** + * @typedef {import()} XmlNode * @typedef {{type: string, content: *, attrs: {}}} PmNodeJson * @typedef {{type: string, attrs: {}}} PmMarkJson * @@ -28,18 +29,19 @@ import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; /** * * @param {ParsedDocx} docx - * @returns {{pmNodes: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} + * @returns {{pmDoc: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} */ export const createDocumentJson = (docx) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; + console.log("JSON", json); console.debug('\n\n JSON', json,) const nodeListHandler = defaultNodeListHandler(); - if(json.elements[0].elements[0].type === 'w:body') { + if(json.elements[0].elements[0].name === 'w:body') { const node = json.elements[0].elements[0]; const ignoreNodes = ['w:sectPr']; - const content = node.elements.filter((n) => !ignoreNodes.includes(n.name)); + const content = node.elements?.filter((n) => !ignoreNodes.includes(n.name)) ?? []; const result = { type: 'doc', @@ -49,7 +51,7 @@ export const createDocumentJson = (docx) => { } } return { - pmNodes: result, + pmDoc: result, savedTagsToRestore: node, pageStyles: getDocumentStyles(node), }; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js index 42776f4202..728813fd9d 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -14,7 +14,7 @@ export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange // Inline images const inlineImage = elements.find((el) => el.name === 'wp:inline'); - if (inlineImage) result = handleInlineImageNode(inlineImage); + if (inlineImage) result = handleInlineImageNode(inlineImage, docx); return {nodes: result ? [result] : [], consumed: 1}; } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js index eda9d6de7d..e1324ba0d4 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -6,11 +6,17 @@ export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackCha return {nodes: [], consumed: 0}; } + const attrs = {}; + + const lineBreakType = nodes[0].attributes?.['w:type']; + if (lineBreakType) attrs['lineBreakType'] = lineBreakType; + + return { nodes: [{ type: 'lineBreak', content: [], - attrs: {}, + attrs, }], consumed: 1 }; } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js index de1500a64e..4f99cdbfe8 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,4 +1,3 @@ -import {convertToSchema} from "./docxImporter.js"; import {carbonCopy} from "../../../utilities/cabonCopy.js"; import {hasTextNode} from "./importerHelpers.js"; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 79c993ffe8..9ff885dbb9 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -42,6 +42,12 @@ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackCha const defaultStyleId = node.attributes['w:rsidRDefault']; const { lineSpaceAfter, lineSpaceBefore } = getDefaultStyleDefinition(defaultStyleId, docx); + const pPr = node.elements.find((el) => el.name === 'w:pPr'); + const styleTag = pPr?.elements.find((el) => el.name === 'w:pStyle'); + if (styleTag) { + schemaNode.attrs['styleId'] = styleTag.attributes['w:val']; + } + if (!('attributes' in schemaNode)) schemaNode.attributes = {}; schemaNode.attrs['paragraphSpacing'] = { lineSpaceAfter, lineSpaceBefore }; } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js index 7e75629693..bb55b9bd7f 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -1,6 +1,4 @@ -import {convertToSchema} from "./docxImporter.js"; import {getElementName, parseProperties} from "./importerHelpers.js"; -import {handleTextNode} from "./textNodeImporter.js"; /** * @type {import("docxImporter").NodeHandler} @@ -22,7 +20,7 @@ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChan el.marks.push(...marks); return el; }) - content.push(...nodeListHandler.handler(updatedElements)); + content.push(...nodeListHandler.handler(updatedElements, docx, insideTrackChange)); } const resultNode = { diff --git a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js index 44d3637ecc..eab25b4572 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -49,6 +49,13 @@ export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) const tableBorders = tableBordersElement?.elements || []; const { borders, rowBorders } = processTableBorders(tableBorders); const tblStyleTag = tblPr.elements.find((el) => el.name === 'w:tblStyle'); + + const tableStyleId = tblStyleTag?.attributes['w:val']; + + // Other table properties + const tableIndent = tblPr?.elements.find((el) => el.name === 'w:tblInd'); + const tableLayout = tblPr?.elements.find((el) => el.name === 'w:tblLayout'); + const referencedStyles = getReferencedTableStyles(tblStyleTag, docx); const tblW = tblPr.elements.find((el) => el.name === 'w:tblW'); @@ -73,6 +80,9 @@ export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) tableWidth, tableWidthType, gridColumnWidths, + tableStyleId, + tableIndent, + tableLayout, borders: borderData } } @@ -204,7 +214,12 @@ export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insi console.error('Standard node handler not found'); return {nodes: [], consumed: 0}; } - const newNode = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + + const newNodes = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(newNodes.nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const newNode = newNodes.nodes[0]; const tPr = node.elements.find((el) => el.name === 'w:trPr'); const rowHeightTag = tPr?.elements.find((el) => el.name === 'w:trHeight'); @@ -214,9 +229,10 @@ export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insi const borders = {}; if (rowBorders?.insideH) borders['bottom'] = rowBorders.insideH; if (rowBorders?.insideV) borders['right'] = rowBorders.insideV; + if(!newNode.attrs) newNode.attrs = {}; newNode.attrs['borders'] = borders; - if (rowHeight && newNode.attrs['rowHeight']) { + if (rowHeight) { newNode.attrs['rowHeight'] = twipsToPixels(rowHeight); console.debug('Row node:', newNode); } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js new file mode 100644 index 0000000000..dc24335b33 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js @@ -0,0 +1,121 @@ +import {parseXmlToJson} from "../docxHelper.js"; +import {handleTrackChangeNode} from "./trackChangesImporter.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; +import {TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; +import {handleAllTableNodes} from "./tableImporter.js"; + + +describe("table live xml test", () => { + const simpleTableStyleXml = `` + const simpleTableXml = ` + + + + + + + + + + + + + + + + + COL 1 ROW 1 + + + + + + + + + + COL 2 ROW 1 + + + + + + + + + + + + COL 1 ROW 2 + + + + + + + + + + COL 2 ROW 2 + + + + + ` + + it("parses simple xml", () => { + const nodes = parseXmlToJson(simpleTableXml).elements + const styles = parseXmlToJson(simpleTableStyleXml) + const docx = { + 'word/styles.xml': styles + } + + const result = handleAllTableNodes(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + + expect(result.nodes[0].type).toBe('table'); + expect(result.nodes[0].content.length).toBe(2); + expect(result.nodes[0].attrs).toEqual({ + tableWidth: '0.00', + tableWidthType: 'auto', + gridColumnWidths: ['3.25', '3.25'], + tableIndent: undefined, + tableLayout: undefined, + tableStyleId: "TableGrid", + borders: { + top: {size: '1.33'}, + left: {size: '1.33'}, + bottom: {size: '1.33'}, + right: {size: '1.33'}, + insideH: {size: '1.33'}, + insideV: {size: '1.33'} + } + }); + + expect(result.nodes[0].content[0].type).toBe('tableRow'); + expect(result.nodes[0].content[0].content.length).toBe(2); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].content[0].text).toBe("COL 1 ROW 1"); + expect(result.nodes[0].content[0].content[1].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].text).toBe("COL 2 ROW 1"); + expect(result.nodes[0].content[0].attrs.borders).toBeDefined(); + + expect(result.nodes[0].content[1].type).toBe('tableRow'); + expect(result.nodes[0].content[1].content.length).toBe(2); + expect(result.nodes[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[1].content[0].content[0].content[0].text).toBe("COL 1 ROW 2"); + expect(result.nodes[0].content[1].content[1].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[1].content[1].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[1].content[1].content[0].content[0].text).toBe("COL 2 ROW 2"); + expect(result.nodes[0].content[1].attrs.borders).toBeDefined(); + + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js index 0c7ae7489d..b4ee594231 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js @@ -111,7 +111,6 @@ describe("trackChanges live xml test", () => { }); it("parses mark change xml", () => { const nodes = parseXmlToJson(markChangeXml).elements - console.log(nodes) const handler = defaultNodeListHandler() const result = handler.handler(nodes, null, false); expect(result.length).toBe(1); From 18d206101c9fc8315495107ea8e5adc61beca51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 9 Sep 2024 12:54:47 +0200 Subject: [PATCH 3/3] functional import refactor v3 - list fixes --- .../v2/importer/docxImporter.js | 3 +- .../v2/importer/listImporter.js | 39 +++- .../v2/importer/listImporter.test.js | 191 ++++++++++++++++++ .../v2/importer/paragraphNodeImporter.js | 2 +- 4 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 792a7bed70..c1fa2eb9b1 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -13,6 +13,7 @@ import {paragraphNodeHandlerEntity} from "./paragraphNodeImporter.js"; import {standardNodeHandlerEntity} from "./standardNodeImporter.js"; import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; +import {listHandlerEntity} from "./listImporter.js"; /** * @typedef {import()} XmlNode @@ -34,7 +35,6 @@ import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; export const createDocumentJson = (docx) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; - console.log("JSON", json); console.debug('\n\n JSON', json,) const nodeListHandler = defaultNodeListHandler(); @@ -63,6 +63,7 @@ export const defaultNodeListHandler = () => { const entities = [ runNodeHandlerEntity, paragraphNodeHandlerEntity, + listHandlerEntity, textNodeHandlerEntity, lineBreakNodeHandlerEntity, bookmarkNodeHandlerEntity, diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js index 4f99cdbfe8..a271ce4ebb 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,12 +1,15 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; -import {hasTextNode} from "./importerHelpers.js"; - +import {hasTextNode, parseProperties} from "./importerHelpers.js"; +import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; +/** + * @type {import("docxImporter").NodeHandler} + */ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:p') { return {nodes: [], consumed: 0}; } - const node = nodes[0]; + const node = carbonCopy(nodes[0]) let schemaNode; @@ -34,13 +37,22 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) } // TODO - Check that this change is OK - return {nodes: handleListNodes(listItems, 0, node), consumed: listItems.filter(i => i.seen).length}; + return { + nodes: [handleListNodes(listItems, docx, nodeListHandler, 0)], + consumed: listItems.filter(i => i.seen).length + }; } else { return {nodes: [], consumed: 0}; } } - +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const listHandlerEntity = { + handlerName: 'listHandler', + handler: handleListNode +} /** @@ -52,14 +64,23 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) * with the same set of list items (as we do not know the node levels until we process them). * * @param {Array} listItems - Array of list items to process. + * @param {ParsedDocx} docx - The parsed docx object. + * @param {NodeListHandler} nodeListHandler - The node list handler function. + * @param {boolean} insideTrackChange - Whether we are inside a track change. * @param {number} [listLevel=0] - The current indentation level of the list. * @returns {Object} The processed list node with structured content. */ -function handleListNodes(listItems, listLevel = 0) { +function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, listLevel = 0) { const parsedListItems = []; let overallListType; let listStyleType; + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + for (let [index, item] of listItems.entries()) { // Skip items we've already processed if (item.seen) continue; @@ -67,7 +88,7 @@ function handleListNodes(listItems, listLevel = 0) { // Sometimes there are paragraph nodes that only have pPr element and no text node - these are // Spacers in the XML and need to be appended to the last item. if (item.elements && !hasTextNode(item.elements)) { - const n = handleStandardNode(item, listItems, index); + const n = handleStandardNode([item], docx, nodeListHandler, insideTrackChange).nodes[0]; parsedListItems[parsedListItems.length - 1]?.content.push(n); item.seen = true; continue; @@ -98,7 +119,7 @@ function handleListNodes(listItems, listLevel = 0) { const schemaElements = []; schemaElements.push({ type: 'paragraph', - content: convertToSchema(elements)?.filter(n => n) + content: nodeListHandler.handler(elements, docx, insideTrackChange)?.filter(n => n) }); console.debug('\n\n LIST ITEM', listpPrs, listrPrs, start, lvlText, lvlJc, '\n\n') @@ -117,7 +138,7 @@ function handleListNodes(listItems, listLevel = 0) { // If this item belongs in a deeper list level, we need to process it by calling this function again // But going one level deeper. else if (listLevel < intLevel) { - const sublist = handleListNodes(listItems.slice(index), listLevel + 1); + const sublist = handleListNodes(listItems.slice(index), docx, nodeListHandler, insideTrackChange, listLevel + 1); const lastItem = parsedListItems[parsedListItems.length - 1]; if (!lastItem) { parsedListItems.push(createListItem([sublist], nodeAttributes, [])); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js new file mode 100644 index 0000000000..87f2d71f76 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js @@ -0,0 +1,191 @@ +import {parseXmlToJson} from "../docxHelper.js"; +import {handleAllTableNodes} from "./tableImporter.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; +import {handleListNode} from "./listImporter.js"; + + +describe("table live xml test", () => { + it("parses simple bullet xml", () => { + const exampleSingleBulletXml = ` + + + + + + + + + + TEXTITEM + + + `; + const numberingXml = `` + const nodes = parseXmlToJson(exampleSingleBulletXml).elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("bulletList"); + expect(result.nodes[0].content.length).toBe(1); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].text).toBe("TEXTITEM"); + }); + + it("parses simple numbered xml", () => { + const exampleSingleNumberedXml = ` + + + + + + + + + + numbered + + + `; + const numberingXml = `` + const nodes = parseXmlToJson(exampleSingleNumberedXml).elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("orderedList"); + expect(result.nodes[0].content.length).toBe(1); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].text).toBe("numbered"); + }); + + + it("parses multi nested list xml", () => { + const exampleMultiNestedListXml = ` + + + + + + + + + + L1: A + + + + + + + + + + + + L2: B + + + + + + + + + + + + L1: C + + + + + + + + + + + + L2: D + + + + + + + + + + + + L3: E + + + + + + + + + + + + L2: F + + + + + + + + + + + + L1: G + + + `; + const numberingXml = `` + const nodes = parseXmlToJson(exampleMultiNestedListXml).elements[0].elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("bulletList"); + expect(result.nodes[0].content.length).toBe(3); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(2); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].text).toBe("L1: A"); + expect(result.nodes[0].content[0].content[1].type).toBe("bulletList"); + expect(result.nodes[0].content[0].content[1].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content[1].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].content[0].text).toBe("L2: B"); + }); + +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 9ff885dbb9..3c6cb97001 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -99,7 +99,7 @@ function getDefaultStyleDefinition(defaultStyleId, docx) { * @param {XmlNode[]} nodes * @returns */ -function preProcessNodesForFldChar(nodes) { +export function preProcessNodesForFldChar(nodes) { const processedNodes = []; const nodesToCombine = []; let isCombiningNodes = false;