import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js"; import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, imageMarkerRegex, variableRegex} from './markers.js' import {isOdfjsImage} from "../../shared.js" import imageSize from "image-size"; /** @import {OdfjsImage} from "../../types.js" */ /** * @typedef TextPlaceToFill * @property { {expression: string, replacedString:string}[] } expressions * @property {() => void} fill */ class TemplateDOMBranch{ /** @type {Node} */ #branchBaseNode /** @type {Node} */ #leafNode // ancestors with this.#ancestors[0] === this.#branchBaseNode and this.#ancestors.at(-1) === this.#leafNode /** @type {Node[]} */ #ancestors /** * * @param {Node} branchBaseNode * @param {Node} leafNode */ constructor(branchBaseNode, leafNode){ this.#branchBaseNode = branchBaseNode this.#leafNode = leafNode this.#ancestors = getAncestors(this.#leafNode, this.#branchBaseNode).reverse() } /** * * @param {number} n * @returns {Node | undefined} */ at(n){ return this.#ancestors.at(n) } removeLeafAndEmptyAncestors(){ //this.logBranch('[removeLeafAndEmptyAncestors] branch at the start') // it may happen (else marker of if/else/endif) that the leaf was already removed as part of another block // so before removing anything, let's update #ancestors and #leaf this.#ancestors.every((ancestor, i) => { if(!ancestor.parentNode){ // ancestor already removed from tree this.#ancestors = this.#ancestors.slice(0, i) return false; } return true // continue }) this.#leafNode = this.#ancestors.at(-1) //this.logBranch('[removeLeafAndEmptyAncestors] after adjusting this.#ancestors') //console.log('removeLeafAndEmptyAncestors', this.#startNode.textContent) let nextLeaf if(this.#leafNode !== this.#branchBaseNode){ nextLeaf = this.#leafNode.parentNode //console.log('nextLeaf', !!nextLeaf) nextLeaf.removeChild(this.#leafNode) this.#leafNode = nextLeaf } while(this.#leafNode !== this.#branchBaseNode && (this.#leafNode.textContent === null || this.#leafNode.textContent.trim() === '')) { nextLeaf = this.#leafNode.parentNode this.#leafNode.parentNode.removeChild(this.#leafNode) this.#leafNode = nextLeaf } this.#ancestors = getAncestors(this.#leafNode, this.#branchBaseNode).reverse() } /** * * @param {number} [startIndex] */ removeRightContent(startIndex = 0){ //console.log('[removeRightContent]', startIndex, this.#ancestors.slice(startIndex).length) for(const branchNode of this.#ancestors.slice(startIndex)){ //console.log('[removeRightContent]', branchNode.nodeType, branchNode.nodeName) let toRemove = branchNode.nextSibling while(toRemove){ const toRemoveNext = toRemove.nextSibling toRemove.parentNode.removeChild(toRemove) toRemove = toRemoveNext } } } /** * * @param {number} [startIndex] */ removeLeftContent(startIndex = 0){ for(const branchNode of this.#ancestors.slice(startIndex)){ let toRemove = branchNode.previousSibling while(toRemove){ const toRemoveNext = toRemove.previousSibling toRemove.parentNode.removeChild(toRemove) toRemove = toRemoveNext } } } /** * * @returns {number[]} */ getBranchPath(){ //console.log('[getBranchPath]', this.#branchBaseNode.nodeName, this.#branchBaseNode.textContent) //console.log('[getBranchPath] leaf', this.#leafNode.nodeName, this.#leafNode.textContent) /** @type {ReturnType} */ const pathFromLeafToBase = []; let currentNode = this.#leafNode let currentNodeParent = currentNode.parentNode while(currentNodeParent){ //console.log('[getBranchPath] currentNodeParent', currentNodeParent.nodeName) //console.log('[getBranchPath] looking for currentNode', currentNode.nodeName, currentNode.textContent) //console.log('[getBranchPath] currentNodeParent.childNodes.length', currentNodeParent.childNodes.length) /*console.log('[getBranchPath] currentNodeParent.childNodes', Array.from(currentNodeParent.childNodes) .map(n => `${n.nodeName} - ${n.textContent}`) )*/ const index = Array.from(currentNodeParent.childNodes).indexOf(currentNode) //console.log('[getBranchPath] indexOf', index) if(index === -1){ throw new Error(`Could not find currentNode in currentNodeParent's childNodes`) } pathFromLeafToBase.push(index) //console.log('[getBranchPath] currentNodeParent and index', currentNodeParent.nodeName, index) if(currentNodeParent === this.#ancestors[0]){ break; // path is fnished } else{ currentNode = currentNodeParent currentNodeParent = currentNode.parentNode } } //@ts-expect-error ES2023 return pathFromLeafToBase.toReversed() } logBranch(message){ console.group('[TemplateDOMBranch] Showing branch') console.log(message) for(const node of this.#ancestors){ console.log('branch node', node.nodeType, node.nodeName, node.nodeType === node.TEXT_NODE ? node.textContent : '') } console.groupEnd() } } class TemplateBlock{ /** @type {Element | Document | DocumentFragment} */ #commonAncestor; /** @type {TemplateDOMBranch} */ startBranch; /** @type {TemplateDOMBranch} */ endBranch; /** @type {Node[]} */ #middleContent; /**@type {any} */ #addImageToOdtFile; /** * * @param {Node} startNode * @param {Node} endNode * @param {(OdfjsImage) => string} addImageToOdtFile */ constructor(startNode, endNode, addImageToOdtFile){ this.#addImageToOdtFile = addImageToOdtFile // @ts-expect-error xmldom.Node this.#commonAncestor = findCommonAncestor(startNode, endNode) //console.log('create start branch') this.startBranch = new TemplateDOMBranch(this.#commonAncestor, startNode) //console.log('create end branch') this.endBranch = new TemplateDOMBranch(this.#commonAncestor, endNode) this.#middleContent = [] let content = this.startBranch.at(1).nextSibling while(content && content !== this.endBranch.at(1)){ this.#middleContent.push(content) content = content.nextSibling } //console.group('\n== TemplateBlock ==') //this.startBranch.logBranch('startBranch') //console.log('middleContent', this.#middleContent.map(n => n.textContent).join('')) //this.endBranch.logBranch('endBranch') //console.log('common ancestor', this.#commonAncestor.nodeName, '\n') //console.groupEnd() } removeMarkersAndEmptyAncestors(){ //console.log('[removeMarkersAndEmptyAncestors]', this.#commonAncestor.textContent) this.startBranch.removeLeafAndEmptyAncestors() this.endBranch.removeLeafAndEmptyAncestors() //console.log('[removeMarkersAndEmptyAncestors] after', this.#commonAncestor.textContent) } /** * * @param {Compartment} compartement */ fillBlockContentTemplate(compartement){ //console.log('[fillBlockContentTemplate] start') const startChild = this.startBranch.at(1) if(startChild /*&& startChild !== */){ //console.log('[fillBlockContentTemplate] startChild', startChild.nodeName, startChild.textContent) fillOdtElementTemplate(startChild, compartement, this.#addImageToOdtFile) } //console.log('[fillBlockContentTemplate] after startChild') // if content consists of several parts of an {#each}{/each} // when arriving to the {/each}, it will be alone (and imbalanced) // and will trigger an error fillOdtElementTemplate(Array.from(this.#middleContent), compartement, this.#addImageToOdtFile) //console.log('[fillBlockContentTemplate] after middleContent') const endChild = this.endBranch.at(1) //console.log('fillBlockContentTemplate] [endBranch]') //this.endBranch.logBranch('endBranch') if(endChild){ //console.log('[fillBlockContentTemplate] endChild', endChild.nodeName, endChild.textContent) fillOdtElementTemplate(endChild, compartement, this.#addImageToOdtFile) } //console.log('[fillBlockContentTemplate] after endChild') //console.log('[fillBlockContentTemplate] end') } removeContent(){ this.startBranch.removeRightContent(2) for(const content of this.#middleContent){ content.parentNode.removeChild(content) } this.endBranch.removeLeftContent(2) } /** * @returns {TemplateBlock} */ cloneAndAppendAfter(){ //console.log('[cloneAndAppendAfter]') const clonedPieces = [] let startBranchClone; let endBranchClone; for(const sibling of [this.startBranch.at(1), ...this.#middleContent, this.endBranch.at(1)]){ if(sibling){ const siblingClone = sibling.cloneNode(true) clonedPieces.push(siblingClone) if(sibling === this.startBranch.at(1)) startBranchClone = siblingClone if(sibling === this.endBranch.at(1)) endBranchClone = siblingClone } } let startChildPreviousSiblingsCount = 0 let previousSibling = this.startBranch.at(1).previousSibling while(previousSibling){ startChildPreviousSiblingsCount = startChildPreviousSiblingsCount + 1 previousSibling = previousSibling.previousSibling } const startBranchPathFromBaseToLeaf = this.startBranch.getBranchPath().slice(1) const endBranchPathFromBaseToLeaf = this.endBranch.getBranchPath().slice(1) //console.log('startBranchClone', !!startBranchClone) //console.log('startBranchPathFromBaseToLeaf', startBranchPathFromBaseToLeaf) let startLeafCloneNode { let node = startBranchClone for(let pathIndex of startBranchPathFromBaseToLeaf){ //console.log('[startLeafCloneNode] node.childNodes.length', node.childNodes.length) //console.log('[startLeafCloneNode] pathIndex', pathIndex) node = node.childNodes[pathIndex] } startLeafCloneNode = node } //console.log('endBranchClone', !!endBranchClone) //console.log('endBranchPathFromBaseToLeaf', endBranchPathFromBaseToLeaf) let endLeafCloneNode { let node = endBranchClone for(let pathIndex of endBranchPathFromBaseToLeaf){ //console.log('[endLeafCloneNode] node.childNodes.length', node.childNodes.length) //console.log('[endLeafCloneNode] pathIndex', pathIndex) node = node.childNodes[pathIndex] } endLeafCloneNode = node } let insertBeforePoint = this.endBranch.at(1) && this.endBranch.at(1).nextSibling if(insertBeforePoint){ for(const node of clonedPieces){ this.#commonAncestor.insertBefore(node, insertBeforePoint) } } else{ for(const node of clonedPieces){ this.#commonAncestor.appendChild(node) } } return new TemplateBlock(startLeafCloneNode, endLeafCloneNode, this.#addImageToOdtFile) } } /** * @param {string} str * @param {Compartment} compartment * @returns {TextPlaceToFill | undefined} */ function findPlacesToFillInString(str, compartment) { const varRexExp = new RegExp(variableRegex.source, 'g'); const matches = str.matchAll(varRexExp) /** @type {TextPlaceToFill['expressions']} */ const expressions = [] /** @type {(string | ((data:any) => void))[]} */ const parts = [] let remaining = str; for(const match of matches) { //console.log('match', match) const [matched, group1] = match const replacedString = matched const expression = group1.trim() expressions.push({expression, replacedString}) const [fixedPart, newRemaining] = remaining.split(replacedString, 2) if(fixedPart.length >= 1) parts.push(fixedPart) parts.push(() => compartment.evaluate(expression)) remaining = newRemaining } if(remaining.length >= 1) parts.push(remaining) //console.log('parts', parts) if(remaining === str) { // no match found return undefined } else { return { expressions, fill: (data) => { return parts.map(p => { if(typeof p === 'string') return p else return p(data) }) .join('') } } } } /** * * @param {Node} ifOpeningMarkerNode * @param {Node | undefined} ifElseMarkerNode * @param {Node} ifClosingMarkerNode * @param {string} ifBlockConditionExpression * @param {Compartment} compartment * // TODO type,addImageToOdtFile */ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile) { //const docEl = ifOpeningMarkerNode.ownerDocument.documentElement const conditionValue = compartment.evaluate(ifBlockConditionExpression) /** @type {TemplateBlock | undefined} */ let thenTemplateBlock /** @type {TemplateBlock | undefined} */ let elseTemplateBlock if(ifElseMarkerNode) { /*console.log('before first extract', ifOpeningMarkerNode.childNodes.length, ifOpeningMarkerNode.textContent, ifElseMarkerNode.childNodes.length, ifElseMarkerNode.textContent )*/ thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode, addImageToOdtFile) elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode, addImageToOdtFile) } else { thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode, addImageToOdtFile) } if(conditionValue) { if(elseTemplateBlock){ elseTemplateBlock.removeContent() } thenTemplateBlock.removeMarkersAndEmptyAncestors() if(elseTemplateBlock){ elseTemplateBlock.removeMarkersAndEmptyAncestors() } thenTemplateBlock.fillBlockContentTemplate(compartment) } else{ // remove content before removing markers so that right and left content are fully removed thenTemplateBlock.removeContent() thenTemplateBlock.removeMarkersAndEmptyAncestors() if(elseTemplateBlock){ elseTemplateBlock.removeMarkersAndEmptyAncestors() } if(elseTemplateBlock){ elseTemplateBlock.fillBlockContentTemplate(compartment) } } } /** * * @param {Node} startNode * @param {string} iterableExpression * @param {string} itemExpression * @param {Node} endNode * @param {Compartment} compartment * // TODO type addImageToOdtFile */ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment, addImageToOdtFile) { //console.log('fillEachBlock', iterableExpression, itemExpression) const docEl = startNode.ownerDocument.documentElement //console.log('[fillEachBlock] docEl', docEl.textContent) const repeatedTemplateBlock = new TemplateBlock(startNode, endNode, addImageToOdtFile) // Find the iterable in the data let iterable = compartment.evaluate(iterableExpression) if(!iterable || typeof iterable[Symbol.iterator] !== 'function') { // when there is no iterable, silently replace with empty array iterable = [] } // convert to array to know the size and know which element is last if(!Array.isArray(iterable)) iterable = [...iterable] if(iterable.length === 0){ repeatedTemplateBlock.removeMarkersAndEmptyAncestors() repeatedTemplateBlock.removeContent() } else{ let nextTemplateBlock = repeatedTemplateBlock iterable.forEach((item, i) => { //console.log('[fillEachBlock] loop i', i, docEl.textContent) const firstItem = i === 0 const lastItem = i === iterable.length - 1 let currentTemplateBlock = nextTemplateBlock; //console.log('currentTemplateBlock', currentTemplateBlock.startBranch.at(0).textContent) if(!lastItem){ nextTemplateBlock = currentTemplateBlock.cloneAndAppendAfter() } let insideCompartment = new Compartment({ globals: Object.assign({}, compartment.globalThis, {[itemExpression]: item}), __options__: true }) if(!firstItem){ currentTemplateBlock.startBranch.removeLeftContent(2) } if(!lastItem){ //console.log('[fillEachBlock] removeRightContent') currentTemplateBlock.endBranch.removeRightContent(2) } //console.log('[fillEachBlock] docEl i before removeMarkers', i, docEl.textContent) currentTemplateBlock.removeMarkersAndEmptyAncestors() //console.log('[fillEachBlock] docEl i after removeMarkers', i, docEl.textContent) //console.log('\nrecursive call to fillBlockContentTemplate') currentTemplateBlock.fillBlockContentTemplate(insideCompartment) //console.log('[fillEachBlock] docEl i after remove contents', i, docEl.textContent) }) } } /** * @param {string} str * @param {Compartement} compartment * @returns { {expression: string, odfjsImage: OdfjsImage | undefined} | undefined} */ function findImageMarker(str, compartment) { const imageRexExp = new RegExp(imageMarkerRegex.source, 'g'); const match = imageRexExp.exec(str) if (match===null){ return; } const expression = match[1] const value = compartment.evaluate(expression) if (isOdfjsImage(value)) { return { expression, odfjsImage: value} } else { return { expression } } } const IF = ifStartMarkerRegex.source const EACH = eachStartMarkerRegex.source /** @typedef {Element | DocumentFragment | Document} RootElementArgument */ /** * * @param {RootElementArgument | RootElementArgument[]} rootElements * @param {Compartment} compartment * @param {(OdfjsImage) => string} addImageToOdtFile * @returns {void} */ export default function fillOdtElementTemplate(rootElements, compartment, addImageToOdtFile) { if(!Array.isArray(rootElements)){ rootElements = [rootElements] } //console.log('[fillTemplatedOdtElement]', rootElements.length, rootElements[0].nodeType, rootElements[0].nodeName, rootElements[0].textContent) //console.log('[fillTemplatedOdtElement]', rootElement.documentElement && rootElement.documentElement.textContent) let currentlyOpenBlocks = [] /** @type {Node | undefined} */ let eachOpeningMarkerNode /** @type {Node | undefined} */ let eachClosingMarkerNode let eachBlockIterableExpression, eachBlockItemExpression; /** @type {Node | undefined} */ let ifOpeningMarkerNode /** @type {Node | undefined} */ let ifElseMarkerNode /** @type {Node | undefined} */ let ifClosingMarkerNode let ifBlockConditionExpression // Traverse "in document order" for(const rootElement of rootElements){ // @ts-ignore traverse(rootElement, currentNode => { //console.log('currentlyOpenBlocks', currentlyOpenBlocks) //console.log('eachOpeningMarkerNode', eachOpeningMarkerNode) const insideAnOpenBlock = currentlyOpenBlocks.length >= 1 if(currentNode.nodeType === Node.TEXT_NODE) { const text = currentNode.textContent || '' /** * looking for {#each x as y} */ const eachStartMatch = text.match(eachStartMarkerRegex); if(eachStartMatch) { //console.log('startMatch', startMatch) currentlyOpenBlocks.push(EACH) if(insideAnOpenBlock) { // do nothing } else { let [_, _iterableExpression, _itemExpression] = eachStartMatch eachBlockIterableExpression = _iterableExpression eachBlockItemExpression = _itemExpression eachOpeningMarkerNode = currentNode } } /** * Looking for {/each} */ const isEachClosingBlock = text.includes(eachClosingMarker) if(isEachClosingBlock) { //console.log('isEachClosingBlock', isEachClosingBlock, currentlyOpenBlocks) if(!insideAnOpenBlock) throw new Error('{/each} found without corresponding opening {#each x as y}') if(currentlyOpenBlocks.at(-1) !== EACH) throw new Error(`{/each} found while the last opened block was not an opening {#each x as y}`) if(currentlyOpenBlocks.length === 1) { eachClosingMarkerNode = currentNode // found an {#each} and its corresponding {/each} // execute replacement loop //console.log('start of fillEachBlock') fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment, addImageToOdtFile) //console.log('end of fillEachBlock') eachOpeningMarkerNode = undefined eachBlockIterableExpression = undefined eachBlockItemExpression = undefined eachClosingMarkerNode = undefined } else { // ignore because it will be treated as part of the outer {#each} } //console.log('popping currentlyOpenBlocks') currentlyOpenBlocks.pop() } /** * Looking for {#if ...} */ const ifStartMatch = text.match(ifStartMarkerRegex); if(ifStartMatch) { currentlyOpenBlocks.push(IF) if(insideAnOpenBlock) { // do nothing because the marker is too deep } else { let [_, _ifBlockConditionExpression] = ifStartMatch ifBlockConditionExpression = _ifBlockConditionExpression ifOpeningMarkerNode = currentNode } } /** * Looking for {:else} */ const hasElseMarker = text.includes(elseMarker); if(hasElseMarker) { if(!insideAnOpenBlock) throw new Error('{:else} without a corresponding {#if}') if(currentlyOpenBlocks.length === 1) { if(currentlyOpenBlocks[0] === IF) { ifElseMarkerNode = currentNode } else throw new Error('{:else} inside an {#each} but without a corresponding {#if}') } else { // do nothing because the marker is too deep } } /** * Looking for {/if} */ const ifClosingMarker = text.includes(closingIfMarker); if(ifClosingMarker) { if(!insideAnOpenBlock) throw new Error('{/if} without a corresponding {#if}') if(currentlyOpenBlocks.length === 1) { if(currentlyOpenBlocks[0] === IF) { ifClosingMarkerNode = currentNode // found an {#if} and its corresponding {/if} // execute replacement loop fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile) ifOpeningMarkerNode = undefined ifElseMarkerNode = undefined ifClosingMarkerNode = undefined ifBlockConditionExpression = undefined } else throw new Error('{/if} inside an {#each} but without a corresponding {#if}') } else { // do nothing because the marker is too deep } currentlyOpenBlocks.pop() } /** * Looking for variables for substitutions */ if(!insideAnOpenBlock) { // @ts-ignore if(currentNode.data) { // @ts-ignore const placesToFill = findPlacesToFillInString(currentNode.data, compartment) if(placesToFill) { const newText = placesToFill.fill() // @ts-ignore const newTextNode = currentNode.ownerDocument?.createTextNode(newText) // @ts-ignore currentNode.parentNode?.replaceChild(newTextNode, currentNode) } else { const imageMarker = findImageMarker(currentNode.data, compartment) if (imageMarker){ //console.log({imageMarker}, "dans le if imageMarker") if (imageMarker.odfjsImage) { const href = addImageToOdtFile(imageMarker.odfjsImage) const newImageNode = currentNode.ownerDocument?.createElement("draw:image") newImageNode.setAttribute("xlink:href", href) newImageNode.setAttribute("xlink:type", "simple") newImageNode.setAttribute("xlink:show", "embed") newImageNode.setAttribute("xlink:actuate", "onLoad") newImageNode.setAttribute("draw:mime-type", imageMarker.odfjsImage.mediaType) const newFrameNode = currentNode.ownerDocument?.createElement('draw:frame') newFrameNode.setAttribute("text:anchor-type", "as-char") const buffer = new Uint8Array(imageMarker.odfjsImage.content) const dimensions = imageSize(buffer) const MAX_WIDTH = 10 // cm const MAX_HEIGHT = 10 // cm let width; let height; if(dimensions.width > dimensions.height){ // image in landscape width = MAX_WIDTH; height = width*dimensions.height/dimensions.width } else{ // image in portrait height = MAX_HEIGHT; width = height*dimensions.width/dimensions.height } newFrameNode.setAttribute("svg:width", `${width}cm`) newFrameNode.setAttribute("svg:height", `${height}cm`) newFrameNode.appendChild(newImageNode) currentNode.parentNode?.replaceChild(newFrameNode, currentNode) } else { throw new Error(`No valid OdfjsImage value has been found for expression: ${imageMarker.expression}`) } } } } } else { // ignore because it will be treated as part of the outer {#each} block } } if(currentNode.nodeType === Node.ATTRIBUTE_NODE) { // Looking for variables for substitutions if(!insideAnOpenBlock) { // @ts-ignore if(currentNode.value) { // @ts-ignore const placesToFill = findPlacesToFillInString(currentNode.value, compartment) if(placesToFill) { // @ts-ignore currentNode.value = placesToFill.fill() } } } else { // ignore because it will be treated as part of the {#each} block } } }) } }