From b54d0a370c965358243273d154c800594e71203a Mon Sep 17 00:00:00 2001 From: David Bruant Date: Thu, 8 May 2025 14:04:34 +0200 Subject: [PATCH] refactoring moving different parts to their own files --- exports.js | 2 +- scripts/odf/fillOdtTemplate.js | 1209 ----------------- scripts/odf/odt/getOdtTextContent.js | 2 +- .../odf/templating/fillOdtElementTemplate.js | 485 +++++++ scripts/odf/templating/fillOdtTemplate.js | 171 +++ scripts/odf/templating/markers.js | 7 + .../odf/templating/prepareTemplateDOMTree.js | 545 ++++++++ 7 files changed, 1210 insertions(+), 1211 deletions(-) delete mode 100644 scripts/odf/fillOdtTemplate.js create mode 100644 scripts/odf/templating/fillOdtElementTemplate.js create mode 100644 scripts/odf/templating/fillOdtTemplate.js create mode 100644 scripts/odf/templating/markers.js create mode 100644 scripts/odf/templating/prepareTemplateDOMTree.js diff --git a/exports.js b/exports.js index 2c2e9e8..676a557 100644 --- a/exports.js +++ b/exports.js @@ -1,6 +1,6 @@ //@ts-check -export {default as fillOdtTemplate} from './scripts/odf/fillOdtTemplate.js' +export {default as fillOdtTemplate} from './scripts/odf/templating/fillOdtTemplate.js' export {getOdtTextContent} from './scripts/odf/odt/getOdtTextContent.js' export { createOdsFile } from './scripts/createOdsFile.js' diff --git a/scripts/odf/fillOdtTemplate.js b/scripts/odf/fillOdtTemplate.js deleted file mode 100644 index 678b9aa..0000000 --- a/scripts/odf/fillOdtTemplate.js +++ /dev/null @@ -1,1209 +0,0 @@ -import {ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter} from '@zip.js/zip.js'; - -import {traverse, parseXML, serializeToString, Node} from '../DOMUtils.js' -import {makeManifestFile, getManifestFileData} from './manifest.js'; - -import 'ses' - -lockdown(); - - -/** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */ -/** @import {ODFManifest} from './manifest.js' */ - -/** @typedef {ArrayBuffer} ODTFile */ - -const ODTMimetype = 'application/vnd.oasis.opendocument.text' - - - - -// For a given string, split it into fixed parts and parts to replace - -/** - * @typedef TextPlaceToFill - * @property { {expression: string, replacedString:string}[] } expressions - * @property {() => void} fill - */ - - - -/** - * @param {string} str - * @param {Compartment} compartment - * @returns {TextPlaceToFill | undefined} - */ -function findPlacesToFillInString(str, compartment) { - const matches = str.matchAll(/\{([^{#\/]+?)\}/g) - - /** @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('') - } - } - } - - -} - - -/** - * Content between blockStartNode and blockEndNode is extracted to a documentFragment - * The original document is modified because nodes are removed from it to be part of the returned documentFragment - * - * startChild and endChild are ancestors of, respectively, blockStartNode and blockEndNode - * and startChild.parentNode === endChild.parentNode - * - * @precondition blockStartNode needs to be before blockEndNode in document order - * - * @param {Node} blockStartNode - * @param {Node} blockEndNode - * @returns {{startChild: Node, endChild:Node, content: DocumentFragment}} - */ -function extractBlockContent(blockStartNode, blockEndNode) { - // find common ancestor of blockStartNode and blockEndNode - let commonAncestor - - let startAncestor = blockStartNode - let endAncestor = blockEndNode - - const startAncestry = new Set([startAncestor]) - const endAncestry = new Set([endAncestor]) - - while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)) { - if(startAncestor.parentNode) { - startAncestor = startAncestor.parentNode - startAncestry.add(startAncestor) - } - if(endAncestor.parentNode) { - endAncestor = endAncestor.parentNode - endAncestry.add(endAncestor) - } - } - - if(startAncestry.has(endAncestor)) { - commonAncestor = endAncestor - } - else { - commonAncestor = startAncestor - } - - const startAncestryToCommonAncestor = [...startAncestry].slice(0, [...startAncestry].indexOf(commonAncestor)) - const endAncestryToCommonAncestor = [...endAncestry].slice(0, [...endAncestry].indexOf(commonAncestor)) - - const startChild = startAncestryToCommonAncestor.at(-1) - const endChild = endAncestryToCommonAncestor.at(-1) - - // Extract DOM content in a documentFragment - const contentFragment = blockStartNode.ownerDocument.createDocumentFragment() - - /** @type {Element[]} */ - const repeatedPatternArray = [] - let sibling = startChild.nextSibling - - while(sibling !== endChild) { - repeatedPatternArray.push(sibling) - sibling = sibling.nextSibling; - } - - for(const sibling of repeatedPatternArray) { - sibling.parentNode?.removeChild(sibling) - contentFragment.appendChild(sibling) - } - - return { - startChild, - endChild, - content: contentFragment - } -} - - - - -/** - * - * @param {Node} ifOpeningMarkerNode - * @param {Node | undefined} ifElseMarkerNode - * @param {Node} ifClosingMarkerNode - * @param {string} ifBlockConditionExpression - * @param {Compartment} compartment - */ -function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) { - const conditionValue = compartment.evaluate(ifBlockConditionExpression) - - let startChild - let endChild - - let markerNodes = new Set() - - let chosenFragment - - if(ifElseMarkerNode) { - const { - startChild: startIfThenChild, - endChild: endIfThenChild, - content: thenFragment - } = extractBlockContent(ifOpeningMarkerNode, ifElseMarkerNode) - - const { - startChild: startIfElseChild, - endChild: endIfElseChild, - content: elseFragment - } = extractBlockContent(ifElseMarkerNode, ifClosingMarkerNode) - - chosenFragment = conditionValue ? thenFragment : elseFragment - startChild = startIfThenChild - endChild = endIfElseChild - - markerNodes - .add(startIfThenChild).add(endIfThenChild) - .add(startIfElseChild).add(endIfElseChild) - } - else { - const { - startChild: startIfThenChild, - endChild: endIfThenChild, - content: thenFragment - } = extractBlockContent(ifOpeningMarkerNode, ifClosingMarkerNode) - - chosenFragment = conditionValue ? thenFragment : undefined - startChild = startIfThenChild - endChild = endIfThenChild - - markerNodes - .add(startIfThenChild).add(endIfThenChild) - } - - - if(chosenFragment) { - fillTemplatedOdtElement( - chosenFragment, - compartment - ) - - endChild.parentNode.insertBefore(chosenFragment, endChild) - } - - for(const markerNode of markerNodes) { - try { - // may throw if node already out of tree - // might happen if - markerNode.parentNode.removeChild(markerNode) - } - catch(e) {} - } - -} - - -/** - * - * @param {Node} startNode - * @param {string} iterableExpression - * @param {string} itemExpression - * @param {Node} endNode - * @param {Compartment} compartment - */ -function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment) { - //console.log('fillEachBlock', iterableExpression, itemExpression) - //console.log('startNode', startNode.nodeType, startNode.nodeName) - //console.log('endNode', endNode.nodeType, endNode.nodeName) - - const {startChild, endChild, content: repeatedFragment} = extractBlockContent(startNode, endNode) - - // Find the iterable in the data - // PPP eventually, evaluate the expression as a JS expression - let iterable = compartment.evaluate(iterableExpression) - if(!iterable || typeof iterable[Symbol.iterator] !== 'function') { - // when there is no iterable, silently replace with empty array - iterable = [] - } - - // create each loop result - // using a for-of loop to accept all iterable values - for(const item of iterable) { - /** @type {DocumentFragment} */ - // @ts-ignore - const itemFragment = repeatedFragment.cloneNode(true) - - let insideCompartment = new Compartment({ - globals: Object.assign({}, compartment.globalThis, {[itemExpression]: item}), - __options__: true - }) - - // recursive call to fillTemplatedOdtElement on itemFragment - fillTemplatedOdtElement( - itemFragment, - insideCompartment - ) - - endChild.parentNode.insertBefore(itemFragment, endChild) - } - - // remove block marker elements - startChild.parentNode.removeChild(startChild) - endChild.parentNode.removeChild(endChild) -} - - -const IF = 'IF' -const EACH = 'EACH' - -// the regexps below are shared, so they shoudn't have state (no 'g' flag) -const ifStartMarkerRegex = /{#if\s+([^}]+?)\s*}/; -const elseMarker = '{:else}' -const closingIfMarker = '{/if}' - -const eachStartMarkerRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/; -const eachClosingMarker = '{/each}' - - - -/** - * - * @param {Element | DocumentFragment | Document} rootElement - * @param {Compartment} compartment - * @returns {void} - */ -function fillTemplatedOdtElement(rootElement, compartment) { - //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) - - 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" - - // @ts-ignore - traverse(rootElement, currentNode => { - //console.log('currentlyUnclosedBlocks', currentlyUnclosedBlocks) - 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) - - if(!eachOpeningMarkerNode) - 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 - fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment) - - eachOpeningMarkerNode = undefined - eachBlockIterableExpression = undefined - eachBlockItemExpression = undefined - eachClosingMarkerNode = undefined - } - else { - // ignore because it will be treated as part of the outer {#each} - } - - 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 hasClosingMarker = text.includes(closingIfMarker); - - if(hasClosingMarker) { - 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) - - 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 - } - } - - - /** - * 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 { - // 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 - } - } - }) -} - - -// Helper function to find all regex matches with positions -/** - * - * @param {string} text - * @param {string | RegExp} pattern - * @returns {{marker: string, index: number}[]} - */ -function findAllMatches(text, pattern) { - const results = []; - let match; - - if(typeof pattern === 'string') { - // For string markers like elseMarker and closingIfMarker - let index = 0; - while((index = text.indexOf(pattern, index)) !== -1) { - results.push({ - marker: pattern, - index: index - }); - index += pattern.length; - } - } else { - // For regex patterns - pattern = new RegExp(pattern.source, 'g'); - while((match = pattern.exec(text)) !== null) { - results.push({ - marker: match[0], - index: match.index - }); - } - } - - return results; -} - - -/** - * - * @param {Node} node1 - * @param {Node} node2 - * @returns {Node} - */ -function findCommonAncestor(node1, node2) { - const ancestors1 = getAncestors(node1); - const ancestors2 = getAncestors(node2); - - for(const ancestor of ancestors1) { - if(ancestors2.includes(ancestor)) { - return ancestor; - } - } - - return null; -} - -/** - * - * @param {Node} node - * @returns {Node[]} - */ -function getAncestors(node) { - const ancestors = []; - let current = node; - - while(current) { - ancestors.push(current); - current = current.parentNode; - } - - return ancestors; -} - -// Helper function to find nodes between start and end (inclusive) -function findNodesBetween(startNode, endNode) { - const commonAncestor = findCommonAncestor(startNode, endNode); - if(!commonAncestor) return []; - - const result = []; - let capturing = false; - - function traverse(node) { - if(node === startNode) { - capturing = true; - } - - if(capturing) { - result.push(node); - } - - if(node === endNode) { - capturing = false; - return true; - } - - for(let child = node.firstChild; child; child = child.nextSibling) { - if(traverse(child)) return true; - } - - return false; - } - - traverse(commonAncestor); - - return result; -} - - -/** - * text position of a node relative to a text nodes within a container - * - * @param {Text} node - * @param {Text[]} containerTextNodes - * @returns {number} - */ -function getNodeTextPosition(node, containerTextNodes) { - let position = 0; - - for(const currentTextNode of containerTextNodes) { - if(currentTextNode === node) { - return position - } - else { - position += (currentTextNode.textContent || '').length; - } - } - - throw new Error(`[${getNodeTextPosition.name}] None of containerTextNodes elements is equal to node`) -} - - -/** @typedef {Node[]} DOMPath */ - -/** - * get the path from ancestor to descendant - * - * @param {Node} node - * @param {Node} ancestor - * @returns {DOMPath} - */ -function getPathToNode(node, ancestor) { - /** @type {DOMPath} */ - const path = []; - let current = node; - - while(current && current !== ancestor) { - path.unshift(current); - current = current.parentNode; - } - - return path; -} - -/** - * find the point where two paths diverge - * - * @param {DOMPath} path1 - * @param {DOMPath} path2 - * @returns {Node | undefined} - */ -function findDivergingPoint(path1, path2) { - for(let i = 0; i < Math.min(path1.length, path2.length); i++) { - if(path1[i] !== path2[i]) { - return path1[i - 1] || undefined; // Return the last common node - } - } - - // One path is a prefix of the other - return path1[Math.min(path1.length, path2.length) - 1]; -} - -/** - * handle the case where start and end nodes have a direct relationship - * @param {Text} startNode - * @param {Text} endNode - * @param {number} posInStartNode - * @param {number} posInEndNode - * @param {string} markerText - */ -function consolidateDirectRelationship(startNode, endNode, posInStartNode, posInEndNode, markerText) { - const startNodeParent = startNode.parentNode; - const endNodeParent = endNode.parentNode; - const document = startNode.ownerDocument; - - console.log('consolidateDirectRelationship - startNodeParent === endNodeParent', startNodeParent === endNodeParent) - - if(startNodeParent === endNodeParent) { - // Siblings case - let currentNode = startNode; - let nextSibling; - - // Handle start node - split if needed to preserve text before marker - if(posInStartNode > 0) { - console.log('posInStartNode > 0', posInStartNode) - console.log('startNode', startNode.textContent) - // Split text node to preserve text before marker - const remainingNode = startNode.splitText(posInStartNode); - currentNode = remainingNode.previousSibling; // Now we'll work with the second part - remainingNode.parentNode?.removeChild(remainingNode) - console.log('remainingNode', remainingNode.textContent) - } - - // Create marker node - const markerNode = document.createTextNode(markerText); - - // Insert marker after current node - if(currentNode.nextSibling) { - startNodeParent.insertBefore(markerNode, currentNode.nextSibling); - } else { - startNodeParent.appendChild(markerNode); - } - - // Remove nodes between start split and end node - currentNode = markerNode.nextSibling; - while(currentNode && currentNode !== endNode) { - nextSibling = currentNode.nextSibling; - startNodeParent.removeChild(currentNode); - currentNode = nextSibling; - } - - // Handle end node - split if needed to preserve text after marker - if(posInEndNode < endNode.textContent.length) { - // Split to keep text after marker - endNode.splitText(posInEndNode); - // First part (up to the split point) should be removed - startNodeParent.removeChild(endNode); - } else { - // No text after marker, remove the entire end node - startNodeParent.removeChild(endNode); - } - } else { - // Handle nested case (one is ancestor of other) - // This is more complex and needs customized handling - // Simplified approach: replace everything with marker - // A more sophisticated approach would be needed for production - - const isStartAncestorOfEnd = isAncestor(startNode, endNode); - if(isStartAncestorOfEnd) { - replaceWithMarker(startNode, markerText); - } else { - replaceWithMarker(endNode, markerText); - } - } -} - -// Helper function to check if one node is ancestor of another -function isAncestor(potentialAncestor, node) { - let current = node.parentNode; - while(current) { - if(current === potentialAncestor) return true; - current = current.parentNode; - } - return false; -} - -// Helper function to replace a node with marker text -function replaceWithMarker(node, markerText) { - const document = node.ownerDocument; - const markerNode = document.createTextNode(markerText); - node.parentNode.replaceChild(markerNode, node); -} - -// Helper function to remove nodes between two sibling branches -function removeNodesBetween(startBranch, endBranch, commonAncestor) { - let removing = false; - let nodesToRemove = []; - - for(let child = commonAncestor.firstChild; child; child = child.nextSibling) { - if(child === startBranch) { - removing = true; - continue; // Don't remove the start branch - } - - if(removing) { - if(child === endBranch) { - break; // Stop when we reach end branch - } - nodesToRemove.push(child); - } - } - - // Remove all nodes marked for removal - for(const nodeToRemove of nodesToRemove) { - commonAncestor.removeChild(nodeToRemove); - } -} - - -/** - * - * @param {Document} document - * @param {Compartment} compartment - * @returns {void} - */ -function fillTemplatedOdtDocument(document, compartment) { - - // Prepare tree to be used as template - // Perform a first pass to detect templating markers with formatting to remove it - const potentialMarkerContainers = [ - ...Array.from(document.getElementsByTagName('text:p')), - ...Array.from(document.getElementsByTagName('text:h')) - ] - - for(const potentialMarkerContainer of potentialMarkerContainers) { - // Check if any template marker is split across multiple text nodes - // Get all text nodes within this container - /** @type {Text[]} */ - const containerTextNodesInTreeOrder = []; - let fullText = '' - traverse(potentialMarkerContainer, node => { - if(node.nodeType === Node.TEXT_NODE) { - containerTextNodesInTreeOrder.push(/** @type {Text} */(node)) - fullText = fullText + node.textContent - } - }) - - // Check for each template marker - const positionedMarkers = [ - ...findAllMatches(fullText, ifStartMarkerRegex), - ...findAllMatches(fullText, elseMarker), - ...findAllMatches(fullText, closingIfMarker), - ...findAllMatches(fullText, eachStartMarkerRegex), - ...findAllMatches(fullText, eachClosingMarker) - ]; - - console.log('positionedMarkers', positionedMarkers) - - // If no markers found, skip this container - if(positionedMarkers.length >= 1) { - - // For each marker, check if it's contained within a single text node - for(const positionedMarker of positionedMarkers) { - console.log('positionedMarker', positionedMarker) - - let markerStart = -1; - let markerEnd = -1; - let currentPos = 0; - let markerSpansNodes = false; - let startNode = null; - let endNode = null; - - // Find which text node(s) contain this marker - for(const textNode of containerTextNodesInTreeOrder) { - const nodeStart = currentPos; - const nodeEnd = nodeStart + textNode.textContent.length; - - // If start of marker is in this node - if(markerStart === -1 && positionedMarker.index >= nodeStart && positionedMarker.index < nodeEnd) { - markerStart = positionedMarker.index; - startNode = textNode; - } - - // If end of marker is in this node - if(markerStart !== -1 && positionedMarker.index + positionedMarker.marker.length > nodeStart && - positionedMarker.index + positionedMarker.marker.length <= nodeEnd) { - markerEnd = positionedMarker.index + positionedMarker.marker.length; - endNode = textNode; - break; - } - - currentPos = nodeEnd; - } - - // Check if marker spans multiple nodes - if(startNode !== endNode) { - console.log('startNode !== endNode') - const commonAncestor = findCommonAncestor(startNode, endNode); - - // Calculate relative positions within the nodes - let startNodeTextContent = startNode.textContent || ''; - let endNodeTextContent = endNode.textContent || ''; - - // Calculate the position within the start node - let posInStartNode = positionedMarker.index - getNodeTextPosition(startNode, containerTextNodesInTreeOrder); - - // Calculate the position within the end node - let posInEndNode = (positionedMarker.index + positionedMarker.marker.length) - getNodeTextPosition(endNode, containerTextNodesInTreeOrder); - - // Get the path from common ancestor to start and end nodes - const pathToStart = getPathToNode(startNode, commonAncestor); - const pathToEnd = getPathToNode(endNode, commonAncestor); - - // Find the diverging point in the paths - const lowestCommonAncestorChild = findDivergingPoint(pathToStart, pathToEnd); - - console.log('lowestCommonAncestorChild', lowestCommonAncestorChild) - - if(!lowestCommonAncestorChild) { - // Direct parent-child relationship or other simple case - // Handle separately - consolidateDirectRelationship(startNode, endNode, posInStartNode, posInEndNode, positionedMarker.marker); - } else { - // Complex case: we need to: - // 1. Preserve text before marker in startNode - // 2. Preserve text after marker in endNode - // 3. Replace everything in-between with marker text - - // Get all nodes between the diverging branches (including the branches) - const startBranch = pathToStart[pathToStart.indexOf(lowestCommonAncestorChild)]; - const endBranch = pathToEnd[pathToEnd.indexOf(lowestCommonAncestorChild)]; - - // First, handle the start node - split if necessary - if(posInStartNode > 0) { - // Text exists before the marker - preserve it - const textBeforeMarker = startNodeTextContent.substring(0, posInStartNode); - const parentOfStartNode = startNode.parentNode; - - // Replace the start node with the text before marker - startNode.textContent = textBeforeMarker; - - // Create a new node for the start of the marker - const startOfMarkerNode = document.createTextNode(positionedMarker.marker); - - // Insert after the modified start node - if(startNode.nextSibling) { - parentOfStartNode.insertBefore(startOfMarkerNode, startNode.nextSibling); - } else { - parentOfStartNode.appendChild(startOfMarkerNode); - } - } else { - // No text before marker, just replace the content - startNode.textContent = positionedMarker.marker; - } - - // Handle the end node - split if necessary - if(posInEndNode < endNodeTextContent.length) { - // Text exists after the marker - preserve it - const textAfterMarker = endNodeTextContent.substring(posInEndNode); - const parentOfEndNode = endNode.parentNode; - - // Replace the end node with just the text after marker - endNode.textContent = textAfterMarker; - - // Create a new node for the end of the marker if needed - // Only needed if we haven't already added the full marker to the start node - if(posInStartNode > 0) { - const endOfMarkerNode = document.createTextNode(""); // Empty as marker is in start node - - // Insert before the modified end node - parentOfEndNode.insertBefore(endOfMarkerNode, endNode); - } - } else { - // No text after marker - if(posInStartNode > 0) { - // If we preserved text before the marker, remove the end node - // as the marker is now fully in the start branch - endNode.parentNode.removeChild(endNode); - } else { - // Otherwise just replace the content - endNode.textContent = ""; - } - } - - // Now remove all nodes between start branch and end branch - // but not the branches themselves - removeNodesBetween(startBranch, endBranch, commonAncestor); - } - - // After consolidation, we can break as the DOM structure has changed - break; - } - } - } - - - - } - - - - - - // Perform a second pass to split textnodes when they contain several block markers - traverse(document, currentNode => { - if(currentNode.nodeType === Node.TEXT_NODE) { - // find all marker starts and ends and split textNode - let remainingText = currentNode.textContent || '' - - while(remainingText.length >= 1) { - let matchText; - let matchIndex; - - // looking for a block marker - for(const marker of [ifStartMarkerRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingMarker]) { - if(typeof marker === 'string') { - const index = remainingText.indexOf(marker) - - if(index !== -1) { - matchText = marker - matchIndex = index - - // found the first match - break; // get out of loop - } - } - else { - // marker is a RegExp - const match = remainingText.match(marker) - - if(match) { - matchText = match[0] - matchIndex = match.index - - // found the first match - break; // get out of loop - } - } - } - - if(matchText) { - // split 3-way : before-match, match and after-match - - if(matchText.length < remainingText.length) { - // @ts-ignore - let afterMatchTextNode = currentNode.splitText(matchIndex + matchText.length) - if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1) { - remainingText = afterMatchTextNode.textContent - } - else { - remainingText = '' - } - - // per spec, currentNode now contains before-match and match text - - // @ts-ignore - if(matchIndex > 0) { - // @ts-ignore - currentNode.splitText(matchIndex) - } - - if(afterMatchTextNode) { - currentNode = afterMatchTextNode - } - } - else { - remainingText = '' - } - } - else { - remainingText = '' - } - } - - } - else { - // skip - } - }) - - // now, each Node contains at most one block marker - - fillTemplatedOdtElement(document, compartment) -} - - - -const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml']) - -/** - * - * @param {string} filename - * @returns {boolean} - */ -function keepFile(filename) { - return keptFiles.has(filename) || filename.startsWith('Pictures/') -} - - -/** - * @param {ODTFile} odtTemplate - * @param {any} data - * @returns {Promise} - */ -export default async function fillOdtTemplate(odtTemplate, data) { - - const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtTemplate))); - - // Lire toutes les entrées du fichier ODT - const entries = reader.getEntriesGenerator(); - - // Créer un ZipWriter pour le nouveau fichier ODT - const writer = new ZipWriter(new Uint8ArrayWriter()); - - /** @type {ODFManifest} */ - let manifestFileData; - - /** @type {{filename: string, content: Reader, options?: ZipWriterAddDataOptions}[]} */ - const zipEntriesToAdd = [] - - // Parcourir chaque entrée du fichier ODT - for await(const entry of entries) { - const filename = entry.filename - - //console.log('entry', filename, entry.directory) - - // remove other files - if(!keepFile(filename)) { - // ignore, do not create a corresponding entry in the new zip - } - else { - let content - let options - - switch(filename) { - case 'mimetype': - content = new TextReader(ODTMimetype) - options = { - level: 0, - compressionMethod: 0, - dataDescriptor: false, - extendedTimestamp: false, - } - - zipEntriesToAdd.push({filename, content, options}) - - break; - case 'content.xml': - // @ts-ignore - const contentXml = await entry.getData(new TextWriter()); - const contentDocument = parseXML(contentXml); - - const compartment = new Compartment({ - globals: data, - __options__: true - }) - - fillTemplatedOdtDocument(contentDocument, compartment) - - const updatedContentXml = serializeToString(contentDocument) - - content = new TextReader(updatedContentXml) - options = { - lastModDate: entry.lastModDate, - level: 9 - }; - - zipEntriesToAdd.push({filename, content, options}) - - break; - - case 'META-INF/manifest.xml': - // @ts-ignore - const manifestXml = await entry.getData(new TextWriter()); - const manifestDocument = parseXML(manifestXml); - manifestFileData = getManifestFileData(manifestDocument) - - break; - - case 'styles.xml': - default: - const blobWriter = new BlobWriter(); - // @ts-ignore - await entry.getData(blobWriter); - const blob = await blobWriter.getData(); - - content = new BlobReader(blob) - zipEntriesToAdd.push({filename, content}) - break; - } - } - } - - - for(const {filename, content, options} of zipEntriesToAdd) { - await writer.add(filename, content, options); - } - - const newZipFilenames = new Set(zipEntriesToAdd.map(ze => ze.filename)) - - if(!manifestFileData) { - throw new Error(`'META-INF/manifest.xml' zip entry missing`) - } - - // remove ignored files from manifest.xml - for(const filename of manifestFileData.fileEntries.keys()) { - if(!newZipFilenames.has(filename)) { - manifestFileData.fileEntries.delete(filename) - } - } - - const manifestFileXml = makeManifestFile(manifestFileData) - await writer.add('META-INF/manifest.xml', new TextReader(manifestFileXml)); - - await reader.close(); - - return writer.close(); -} - - - - - - diff --git a/scripts/odf/odt/getOdtTextContent.js b/scripts/odf/odt/getOdtTextContent.js index cd468e3..36b3160 100644 --- a/scripts/odf/odt/getOdtTextContent.js +++ b/scripts/odf/odt/getOdtTextContent.js @@ -1,7 +1,7 @@ import { ZipReader, Uint8ArrayReader, TextWriter } from '@zip.js/zip.js'; import {parseXML, Node} from '../../DOMUtils.js' -/** @import {ODTFile} from '../fillOdtTemplate.js' */ +/** @import {ODTFile} from '../templating/fillOdtTemplate.js' */ /** * @param {ODTFile} odtFile diff --git a/scripts/odf/templating/fillOdtElementTemplate.js b/scripts/odf/templating/fillOdtElementTemplate.js new file mode 100644 index 0000000..f4f48be --- /dev/null +++ b/scripts/odf/templating/fillOdtElementTemplate.js @@ -0,0 +1,485 @@ +import {traverse, Node} from '../../DOMUtils.js' +import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex} from './markers.js' + +/** + * @typedef TextPlaceToFill + * @property { {expression: string, replacedString:string}[] } expressions + * @property {() => void} fill + */ + + +/** + * @param {string} str + * @param {Compartment} compartment + * @returns {TextPlaceToFill | undefined} + */ +function findPlacesToFillInString(str, compartment) { + const matches = str.matchAll(/\{([^{#\/]+?)\}/g) + + /** @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('') + } + } + } + + +} + + +/** + * Content between blockStartNode and blockEndNode is extracted to a documentFragment + * The original document is modified because nodes are removed from it to be part of the returned documentFragment + * + * startChild and endChild are ancestors of, respectively, blockStartNode and blockEndNode + * and startChild.parentNode === endChild.parentNode + * + * @precondition blockStartNode needs to be before blockEndNode in document order + * + * @param {Node} blockStartNode + * @param {Node} blockEndNode + * @returns {{startChild: Node, endChild:Node, content: DocumentFragment}} + */ +function extractBlockContent(blockStartNode, blockEndNode) { + // find common ancestor of blockStartNode and blockEndNode + let commonAncestor + + let startAncestor = blockStartNode + let endAncestor = blockEndNode + + const startAncestry = new Set([startAncestor]) + const endAncestry = new Set([endAncestor]) + + while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)) { + if(startAncestor.parentNode) { + startAncestor = startAncestor.parentNode + startAncestry.add(startAncestor) + } + if(endAncestor.parentNode) { + endAncestor = endAncestor.parentNode + endAncestry.add(endAncestor) + } + } + + if(startAncestry.has(endAncestor)) { + commonAncestor = endAncestor + } + else { + commonAncestor = startAncestor + } + + const startAncestryToCommonAncestor = [...startAncestry].slice(0, [...startAncestry].indexOf(commonAncestor)) + const endAncestryToCommonAncestor = [...endAncestry].slice(0, [...endAncestry].indexOf(commonAncestor)) + + const startChild = startAncestryToCommonAncestor.at(-1) + const endChild = endAncestryToCommonAncestor.at(-1) + + // Extract DOM content in a documentFragment + const contentFragment = blockStartNode.ownerDocument.createDocumentFragment() + + /** @type {Element[]} */ + const repeatedPatternArray = [] + let sibling = startChild.nextSibling + + while(sibling !== endChild) { + repeatedPatternArray.push(sibling) + sibling = sibling.nextSibling; + } + + for(const sibling of repeatedPatternArray) { + sibling.parentNode?.removeChild(sibling) + contentFragment.appendChild(sibling) + } + + return { + startChild, + endChild, + content: contentFragment + } +} + + + + +/** + * + * @param {Node} ifOpeningMarkerNode + * @param {Node | undefined} ifElseMarkerNode + * @param {Node} ifClosingMarkerNode + * @param {string} ifBlockConditionExpression + * @param {Compartment} compartment + */ +function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) { + const conditionValue = compartment.evaluate(ifBlockConditionExpression) + + let startChild + let endChild + + let markerNodes = new Set() + + let chosenFragment + + if(ifElseMarkerNode) { + const { + startChild: startIfThenChild, + endChild: endIfThenChild, + content: thenFragment + } = extractBlockContent(ifOpeningMarkerNode, ifElseMarkerNode) + + const { + startChild: startIfElseChild, + endChild: endIfElseChild, + content: elseFragment + } = extractBlockContent(ifElseMarkerNode, ifClosingMarkerNode) + + chosenFragment = conditionValue ? thenFragment : elseFragment + startChild = startIfThenChild + endChild = endIfElseChild + + markerNodes + .add(startIfThenChild).add(endIfThenChild) + .add(startIfElseChild).add(endIfElseChild) + } + else { + const { + startChild: startIfThenChild, + endChild: endIfThenChild, + content: thenFragment + } = extractBlockContent(ifOpeningMarkerNode, ifClosingMarkerNode) + + chosenFragment = conditionValue ? thenFragment : undefined + startChild = startIfThenChild + endChild = endIfThenChild + + markerNodes + .add(startIfThenChild).add(endIfThenChild) + } + + + if(chosenFragment) { + fillOdtElementTemplate( + chosenFragment, + compartment + ) + + endChild.parentNode.insertBefore(chosenFragment, endChild) + } + + for(const markerNode of markerNodes) { + try { + // may throw if node already out of tree + // might happen if + markerNode.parentNode.removeChild(markerNode) + } + catch(e) {} + } + +} + + +/** + * + * @param {Node} startNode + * @param {string} iterableExpression + * @param {string} itemExpression + * @param {Node} endNode + * @param {Compartment} compartment + */ +function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment) { + //console.log('fillEachBlock', iterableExpression, itemExpression) + //console.log('startNode', startNode.nodeType, startNode.nodeName) + //console.log('endNode', endNode.nodeType, endNode.nodeName) + + const {startChild, endChild, content: repeatedFragment} = extractBlockContent(startNode, endNode) + + // Find the iterable in the data + // PPP eventually, evaluate the expression as a JS expression + let iterable = compartment.evaluate(iterableExpression) + if(!iterable || typeof iterable[Symbol.iterator] !== 'function') { + // when there is no iterable, silently replace with empty array + iterable = [] + } + + // create each loop result + // using a for-of loop to accept all iterable values + for(const item of iterable) { + /** @type {DocumentFragment} */ + // @ts-ignore + const itemFragment = repeatedFragment.cloneNode(true) + + let insideCompartment = new Compartment({ + globals: Object.assign({}, compartment.globalThis, {[itemExpression]: item}), + __options__: true + }) + + // recursive call to fillTemplatedOdtElement on itemFragment + fillOdtElementTemplate( + itemFragment, + insideCompartment + ) + + endChild.parentNode.insertBefore(itemFragment, endChild) + } + + // remove block marker elements + startChild.parentNode.removeChild(startChild) + endChild.parentNode.removeChild(endChild) +} + + +const IF = 'IF' +const EACH = 'EACH' + +/** + * + * @param {Element | DocumentFragment | Document} rootElement + * @param {Compartment} compartment + * @returns {void} + */ +export default function fillOdtElementTemplate(rootElement, compartment) { + //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) + + 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" + + // @ts-ignore + traverse(rootElement, currentNode => { + //console.log('currentlyUnclosedBlocks', currentlyUnclosedBlocks) + 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) + + if(!eachOpeningMarkerNode) + 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 + fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment) + + eachOpeningMarkerNode = undefined + eachBlockIterableExpression = undefined + eachBlockItemExpression = undefined + eachClosingMarkerNode = undefined + } + else { + // ignore because it will be treated as part of the outer {#each} + } + + 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 hasClosingMarker = text.includes(closingIfMarker); + + if(hasClosingMarker) { + 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) + + 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 + } + } + + + /** + * 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 { + // 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 + } + } + }) +} diff --git a/scripts/odf/templating/fillOdtTemplate.js b/scripts/odf/templating/fillOdtTemplate.js new file mode 100644 index 0000000..a13f3d9 --- /dev/null +++ b/scripts/odf/templating/fillOdtTemplate.js @@ -0,0 +1,171 @@ +import {ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter} from '@zip.js/zip.js'; + +import {parseXML, serializeToString, Node} from '../../DOMUtils.js' +import {makeManifestFile, getManifestFileData} from '../manifest.js'; +import prepareTemplateDOMTree from './prepareTemplateDOMTree.js'; + +import 'ses' +import fillOdtElementTemplate from './fillOdtElementTemplate.js'; + +lockdown(); + + +/** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */ +/** @import {ODFManifest} from '../manifest.js' */ + +/** @typedef {ArrayBuffer} ODTFile */ + +const ODTMimetype = 'application/vnd.oasis.opendocument.text' + + + +/** + * + * @param {Document} document + * @param {Compartment} compartment + * @returns {void} + */ +function fillOdtDocumentTemplate(document, compartment) { + prepareTemplateDOMTree(document) + fillOdtElementTemplate(document, compartment) +} + + + +const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml']) + +/** + * + * @param {string} filename + * @returns {boolean} + */ +function keepFile(filename) { + return keptFiles.has(filename) || filename.startsWith('Pictures/') +} + + +/** + * @param {ODTFile} odtTemplate + * @param {any} data + * @returns {Promise} + */ +export default async function fillOdtTemplate(odtTemplate, data) { + + const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtTemplate))); + + // Lire toutes les entrées du fichier ODT + const entries = reader.getEntriesGenerator(); + + // Créer un ZipWriter pour le nouveau fichier ODT + const writer = new ZipWriter(new Uint8ArrayWriter()); + + /** @type {ODFManifest} */ + let manifestFileData; + + /** @type {{filename: string, content: Reader, options?: ZipWriterAddDataOptions}[]} */ + const zipEntriesToAdd = [] + + // Parcourir chaque entrée du fichier ODT + for await(const entry of entries) { + const filename = entry.filename + + //console.log('entry', filename, entry.directory) + + // remove other files + if(!keepFile(filename)) { + // ignore, do not create a corresponding entry in the new zip + } + else { + let content + let options + + switch(filename) { + case 'mimetype': + content = new TextReader(ODTMimetype) + options = { + level: 0, + compressionMethod: 0, + dataDescriptor: false, + extendedTimestamp: false, + } + + zipEntriesToAdd.push({filename, content, options}) + + break; + case 'content.xml': + // @ts-ignore + const contentXml = await entry.getData(new TextWriter()); + const contentDocument = parseXML(contentXml); + + const compartment = new Compartment({ + globals: data, + __options__: true + }) + + fillOdtDocumentTemplate(contentDocument, compartment) + + const updatedContentXml = serializeToString(contentDocument) + + content = new TextReader(updatedContentXml) + options = { + lastModDate: entry.lastModDate, + level: 9 + }; + + zipEntriesToAdd.push({filename, content, options}) + + break; + + case 'META-INF/manifest.xml': + // @ts-ignore + const manifestXml = await entry.getData(new TextWriter()); + const manifestDocument = parseXML(manifestXml); + manifestFileData = getManifestFileData(manifestDocument) + + break; + + case 'styles.xml': + default: + const blobWriter = new BlobWriter(); + // @ts-ignore + await entry.getData(blobWriter); + const blob = await blobWriter.getData(); + + content = new BlobReader(blob) + zipEntriesToAdd.push({filename, content}) + break; + } + } + } + + + for(const {filename, content, options} of zipEntriesToAdd) { + await writer.add(filename, content, options); + } + + const newZipFilenames = new Set(zipEntriesToAdd.map(ze => ze.filename)) + + if(!manifestFileData) { + throw new Error(`'META-INF/manifest.xml' zip entry missing`) + } + + // remove ignored files from manifest.xml + for(const filename of manifestFileData.fileEntries.keys()) { + if(!newZipFilenames.has(filename)) { + manifestFileData.fileEntries.delete(filename) + } + } + + const manifestFileXml = makeManifestFile(manifestFileData) + await writer.add('META-INF/manifest.xml', new TextReader(manifestFileXml)); + + await reader.close(); + + return writer.close(); +} + + + + + + diff --git a/scripts/odf/templating/markers.js b/scripts/odf/templating/markers.js new file mode 100644 index 0000000..da1928a --- /dev/null +++ b/scripts/odf/templating/markers.js @@ -0,0 +1,7 @@ +// the regexps below are shared, so they shoudn't have state (no 'g' flag) +export const ifStartMarkerRegex = /{#if\s+([^}]+?)\s*}/; +export const elseMarker = '{:else}' +export const closingIfMarker = '{/if}' + +export const eachStartMarkerRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/; +export const eachClosingMarker = '{/each}' \ No newline at end of file diff --git a/scripts/odf/templating/prepareTemplateDOMTree.js b/scripts/odf/templating/prepareTemplateDOMTree.js new file mode 100644 index 0000000..6ead989 --- /dev/null +++ b/scripts/odf/templating/prepareTemplateDOMTree.js @@ -0,0 +1,545 @@ +import {traverse, Node} from "../../DOMUtils.js"; +import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex} from './markers.js' + +/** + * + * @param {string} text + * @param {string | RegExp} pattern + * @returns {{marker: string, index: number}[]} + */ +function findAllMatches(text, pattern) { + const results = []; + let match; + + if(typeof pattern === 'string') { + // For string markers like elseMarker and closingIfMarker + let index = 0; + while((index = text.indexOf(pattern, index)) !== -1) { + results.push({ + marker: pattern, + index: index + }); + index += pattern.length; + } + } else { + // For regex patterns + pattern = new RegExp(pattern.source, 'g'); + while((match = pattern.exec(text)) !== null) { + results.push({ + marker: match[0], + index: match.index + }); + } + } + + return results; +} + + +/** + * + * @param {Node} node1 + * @param {Node} node2 + * @returns {Node | undefined} + */ +function findCommonAncestor(node1, node2) { + const ancestors1 = getAncestors(node1); + const ancestors2 = getAncestors(node2); + + for(const ancestor of ancestors1) { + if(ancestors2.includes(ancestor)) { + return ancestor; + } + } + + return undefined; +} + +/** + * + * @param {Node} node + * @returns {Node[]} + */ +function getAncestors(node) { + const ancestors = []; + let current = node; + + while(current) { + ancestors.push(current); + current = current.parentNode; + } + + return ancestors; +} + +// Helper function to find nodes between start and end (inclusive) +function findNodesBetween(startNode, endNode) { + const commonAncestor = findCommonAncestor(startNode, endNode); + if(!commonAncestor) return []; + + const result = []; + let capturing = false; + + function traverse(node) { + if(node === startNode) { + capturing = true; + } + + if(capturing) { + result.push(node); + } + + if(node === endNode) { + capturing = false; + return true; + } + + for(let child = node.firstChild; child; child = child.nextSibling) { + if(traverse(child)) return true; + } + + return false; + } + + traverse(commonAncestor); + + return result; +} + + +/** + * text position of a node relative to a text nodes within a container + * + * @param {Text} node + * @param {Text[]} containerTextNodes + * @returns {number} + */ +function getNodeTextPosition(node, containerTextNodes) { + let position = 0; + + for(const currentTextNode of containerTextNodes) { + if(currentTextNode === node) { + return position + } + else { + position += (currentTextNode.textContent || '').length; + } + } + + throw new Error(`[${getNodeTextPosition.name}] None of containerTextNodes elements is equal to node`) +} + + +/** @typedef {Node[]} DOMPath */ + +/** + * get the path from ancestor to descendant + * + * @param {Node} node + * @param {Node} ancestor + * @returns {DOMPath} + */ +function getPathToNode(node, ancestor) { + /** @type {DOMPath} */ + const path = []; + let current = node; + + while(current && current !== ancestor) { + path.unshift(current); + current = current.parentNode; + } + + return path; +} + +/** + * find the point where two paths diverge + * + * @param {DOMPath} path1 + * @param {DOMPath} path2 + * @returns {Node | undefined} + */ +function findDivergingPoint(path1, path2) { + for(let i = 0; i < Math.min(path1.length, path2.length); i++) { + if(path1[i] !== path2[i]) { + return path1[i - 1] || undefined; // Return the last common node + } + } + + // One path is a prefix of the other + return path1[Math.min(path1.length, path2.length) - 1]; +} + +/** + * handle the case where start and end nodes have a direct relationship + * @param {Text} startNode + * @param {Text} endNode + * @param {number} posInStartNode + * @param {number} posInEndNode + * @param {string} markerText + */ +function consolidateDirectRelationship(startNode, endNode, posInStartNode, posInEndNode, markerText) { + const startNodeParent = startNode.parentNode; + const endNodeParent = endNode.parentNode; + const document = startNode.ownerDocument; + + console.log('consolidateDirectRelationship - startNodeParent === endNodeParent', startNodeParent === endNodeParent) + + if(startNodeParent === endNodeParent) { + // Siblings case + let currentNode = startNode; + let nextSibling; + + // Handle start node - split if needed to preserve text before marker + if(posInStartNode > 0) { + console.log('posInStartNode > 0', posInStartNode) + console.log('startNode', startNode.textContent) + // Split text node to preserve text before marker + const remainingNode = startNode.splitText(posInStartNode); + currentNode = remainingNode.previousSibling; // Now we'll work with the second part + remainingNode.parentNode?.removeChild(remainingNode) + console.log('remainingNode', remainingNode.textContent) + } + + // Create marker node + const markerNode = document.createTextNode(markerText); + + // Insert marker after current node + if(currentNode.nextSibling) { + startNodeParent.insertBefore(markerNode, currentNode.nextSibling); + } else { + startNodeParent.appendChild(markerNode); + } + + // Remove nodes between start split and end node + currentNode = markerNode.nextSibling; + while(currentNode && currentNode !== endNode) { + nextSibling = currentNode.nextSibling; + startNodeParent.removeChild(currentNode); + currentNode = nextSibling; + } + + // Handle end node - split if needed to preserve text after marker + if(posInEndNode < endNode.textContent.length) { + // Split to keep text after marker + endNode.splitText(posInEndNode); + // First part (up to the split point) should be removed + startNodeParent.removeChild(endNode); + } else { + // No text after marker, remove the entire end node + startNodeParent.removeChild(endNode); + } + } else { + // Handle nested case (one is ancestor of other) + // This is more complex and needs customized handling + // Simplified approach: replace everything with marker + // A more sophisticated approach would be needed for production + + const isStartAncestorOfEnd = isAncestor(startNode, endNode); + if(isStartAncestorOfEnd) { + replaceWithMarker(startNode, markerText); + } else { + replaceWithMarker(endNode, markerText); + } + } +} + +// Helper function to check if one node is ancestor of another +function isAncestor(potentialAncestor, node) { + let current = node.parentNode; + while(current) { + if(current === potentialAncestor) return true; + current = current.parentNode; + } + return false; +} + +// Helper function to replace a node with marker text +function replaceWithMarker(node, markerText) { + const document = node.ownerDocument; + const markerNode = document.createTextNode(markerText); + node.parentNode.replaceChild(markerNode, node); +} + +// Helper function to remove nodes between two sibling branches +function removeNodesBetween(startBranch, endBranch, commonAncestor) { + let removing = false; + let nodesToRemove = []; + + for(let child = commonAncestor.firstChild; child; child = child.nextSibling) { + if(child === startBranch) { + removing = true; + continue; // Don't remove the start branch + } + + if(removing) { + if(child === endBranch) { + break; // Stop when we reach end branch + } + nodesToRemove.push(child); + } + } + + // Remove all nodes marked for removal + for(const nodeToRemove of nodesToRemove) { + commonAncestor.removeChild(nodeToRemove); + } +} + + +export default function prepareTemplateDOMTree(document){ + // Prepare tree to be used as template + // Perform a first pass to detect templating markers with formatting to remove it + const potentialMarkerContainers = [ + ...Array.from(document.getElementsByTagName('text:p')), + ...Array.from(document.getElementsByTagName('text:h')) + ] + + for(const potentialMarkerContainer of potentialMarkerContainers) { + // Check if any template marker is split across multiple text nodes + // Get all text nodes within this container + /** @type {Text[]} */ + const containerTextNodesInTreeOrder = []; + let fullText = '' + traverse(potentialMarkerContainer, node => { + if(node.nodeType === Node.TEXT_NODE) { + containerTextNodesInTreeOrder.push(/** @type {Text} */(node)) + fullText = fullText + node.textContent + } + }) + + // Check for each template marker + const positionedMarkers = [ + ...findAllMatches(fullText, ifStartMarkerRegex), + ...findAllMatches(fullText, elseMarker), + ...findAllMatches(fullText, closingIfMarker), + ...findAllMatches(fullText, eachStartMarkerRegex), + ...findAllMatches(fullText, eachClosingMarker) + ]; + + console.log('positionedMarkers', positionedMarkers) + + // If no markers found, skip this container + if(positionedMarkers.length >= 1) { + + // For each marker, check if it's contained within a single text node + for(const positionedMarker of positionedMarkers) { + console.log('positionedMarker', positionedMarker) + + let markerStart = -1; + let markerEnd = -1; + let currentPos = 0; + let markerSpansNodes = false; + let startNode = null; + let endNode = null; + + // Find which text node(s) contain this marker + for(const textNode of containerTextNodesInTreeOrder) { + const nodeStart = currentPos; + const nodeEnd = nodeStart + textNode.textContent.length; + + // If start of marker is in this node + if(markerStart === -1 && positionedMarker.index >= nodeStart && positionedMarker.index < nodeEnd) { + markerStart = positionedMarker.index; + startNode = textNode; + } + + // If end of marker is in this node + if(markerStart !== -1 && positionedMarker.index + positionedMarker.marker.length > nodeStart && + positionedMarker.index + positionedMarker.marker.length <= nodeEnd) { + markerEnd = positionedMarker.index + positionedMarker.marker.length; + endNode = textNode; + break; + } + + currentPos = nodeEnd; + } + + // Check if marker spans multiple nodes + if(startNode !== endNode) { + console.log('startNode !== endNode') + const commonAncestor = findCommonAncestor(startNode, endNode); + + // Calculate relative positions within the nodes + let startNodeTextContent = startNode.textContent || ''; + let endNodeTextContent = endNode.textContent || ''; + + // Calculate the position within the start node + let posInStartNode = positionedMarker.index - getNodeTextPosition(startNode, containerTextNodesInTreeOrder); + + // Calculate the position within the end node + let posInEndNode = (positionedMarker.index + positionedMarker.marker.length) - getNodeTextPosition(endNode, containerTextNodesInTreeOrder); + + // Get the path from common ancestor to start and end nodes + const pathToStart = getPathToNode(startNode, commonAncestor); + const pathToEnd = getPathToNode(endNode, commonAncestor); + + // Find the diverging point in the paths + const lowestCommonAncestorChild = findDivergingPoint(pathToStart, pathToEnd); + + console.log('lowestCommonAncestorChild', lowestCommonAncestorChild) + + if(!lowestCommonAncestorChild) { + // Direct parent-child relationship or other simple case + // Handle separately + consolidateDirectRelationship(startNode, endNode, posInStartNode, posInEndNode, positionedMarker.marker); + } else { + // Complex case: we need to: + // 1. Preserve text before marker in startNode + // 2. Preserve text after marker in endNode + // 3. Replace everything in-between with marker text + + // Get all nodes between the diverging branches (including the branches) + const startBranch = pathToStart[pathToStart.indexOf(lowestCommonAncestorChild)]; + const endBranch = pathToEnd[pathToEnd.indexOf(lowestCommonAncestorChild)]; + + // First, handle the start node - split if necessary + if(posInStartNode > 0) { + // Text exists before the marker - preserve it + const textBeforeMarker = startNodeTextContent.substring(0, posInStartNode); + const parentOfStartNode = startNode.parentNode; + + // Replace the start node with the text before marker + startNode.textContent = textBeforeMarker; + + // Create a new node for the start of the marker + const startOfMarkerNode = document.createTextNode(positionedMarker.marker); + + // Insert after the modified start node + if(startNode.nextSibling) { + parentOfStartNode.insertBefore(startOfMarkerNode, startNode.nextSibling); + } else { + parentOfStartNode.appendChild(startOfMarkerNode); + } + } else { + // No text before marker, just replace the content + startNode.textContent = positionedMarker.marker; + } + + // Handle the end node - split if necessary + if(posInEndNode < endNodeTextContent.length) { + // Text exists after the marker - preserve it + const textAfterMarker = endNodeTextContent.substring(posInEndNode); + const parentOfEndNode = endNode.parentNode; + + // Replace the end node with just the text after marker + endNode.textContent = textAfterMarker; + + // Create a new node for the end of the marker if needed + // Only needed if we haven't already added the full marker to the start node + if(posInStartNode > 0) { + const endOfMarkerNode = document.createTextNode(""); // Empty as marker is in start node + + // Insert before the modified end node + parentOfEndNode.insertBefore(endOfMarkerNode, endNode); + } + } else { + // No text after marker + if(posInStartNode > 0) { + // If we preserved text before the marker, remove the end node + // as the marker is now fully in the start branch + endNode.parentNode.removeChild(endNode); + } else { + // Otherwise just replace the content + endNode.textContent = ""; + } + } + + // Now remove all nodes between start branch and end branch + // but not the branches themselves + removeNodesBetween(startBranch, endBranch, commonAncestor); + } + + // After consolidation, we can break as the DOM structure has changed + break; + } + } + } + + + + } + + + + + + // Perform a second pass to split textnodes when they contain several block markers + traverse(document, currentNode => { + if(currentNode.nodeType === Node.TEXT_NODE) { + // find all marker starts and ends and split textNode + let remainingText = currentNode.textContent || '' + + while(remainingText.length >= 1) { + let matchText; + let matchIndex; + + // looking for a block marker + for(const marker of [ifStartMarkerRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingMarker]) { + if(typeof marker === 'string') { + const index = remainingText.indexOf(marker) + + if(index !== -1) { + matchText = marker + matchIndex = index + + // found the first match + break; // get out of loop + } + } + else { + // marker is a RegExp + const match = remainingText.match(marker) + + if(match) { + matchText = match[0] + matchIndex = match.index + + // found the first match + break; // get out of loop + } + } + } + + if(matchText) { + // split 3-way : before-match, match and after-match + + if(matchText.length < remainingText.length) { + // @ts-ignore + let afterMatchTextNode = currentNode.splitText(matchIndex + matchText.length) + if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1) { + remainingText = afterMatchTextNode.textContent + } + else { + remainingText = '' + } + + // per spec, currentNode now contains before-match and match text + + // @ts-ignore + if(matchIndex > 0) { + // @ts-ignore + currentNode.splitText(matchIndex) + } + + if(afterMatchTextNode) { + currentNode = afterMatchTextNode + } + } + else { + remainingText = '' + } + } + else { + remainingText = '' + } + } + + } + else { + // skip + } + }) + + // now, each Node contains at most one block marker +} \ No newline at end of file