From 5400c963a79ef7301b2c66e30721f98308651a47 Mon Sep 17 00:00:00 2001 From: David Bruant Date: Fri, 9 May 2025 18:48:34 +0200 Subject: [PATCH] after-text in #each block (#7) * Text 'text after {/each} in same text node' failing * some tests passing * tests passing * unused var * styling --- .../odf/templating/fillOdtElementTemplate.js | 67 +++++++- .../odf/templating/prepareTemplateDOMTree.js | 146 +++++++++++++++++- tests/fill-odt-template/each.js | 36 +++++ tests/fixtures/text-after-closing-each.odt | Bin 0 -> 11989 bytes 4 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/text-after-closing-each.odt diff --git a/scripts/odf/templating/fillOdtElementTemplate.js b/scripts/odf/templating/fillOdtElementTemplate.js index fd6995f..723398e 100644 --- a/scripts/odf/templating/fillOdtElementTemplate.js +++ b/scripts/odf/templating/fillOdtElementTemplate.js @@ -85,6 +85,8 @@ function findPlacesToFillInString(str, compartment) { * @returns {{startChild: Node, endChild:Node, content: DocumentFragment}} */ function extractBlockContent(blockStartNode, blockEndNode) { + //console.log('[extractBlockContent] blockEndNode', blockEndNode.textContent) + // find common ancestor of blockStartNode and blockEndNode let commonAncestor @@ -118,7 +120,10 @@ function extractBlockContent(blockStartNode, blockEndNode) { const startChild = startAncestryToCommonAncestor.at(-1) const endChild = endAncestryToCommonAncestor.at(-1) + //console.log('[extractBlockContent] endChild', endChild.textContent) + // Extract DOM content in a documentFragment + /** @type {DocumentFragment} */ const contentFragment = blockStartNode.ownerDocument.createDocumentFragment() /** @type {Element[]} */ @@ -135,6 +140,8 @@ function extractBlockContent(blockStartNode, blockEndNode) { contentFragment.appendChild(sibling) } + //console.log('extractBlockContent contentFragment', contentFragment.textContent) + return { startChild, endChild, @@ -231,8 +238,6 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, */ 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) @@ -244,9 +249,13 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c iterable = [] } + let firstItemFirstChild + let lastItemLastChild + // 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) @@ -262,17 +271,67 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c insideCompartment ) + if(!firstItemFirstChild){ + firstItemFirstChild = itemFragment.firstChild + } + + // eventually, will be set to the last item's last child + lastItemLastChild = itemFragment.lastChild + endChild.parentNode.insertBefore(itemFragment, endChild) } + // add before-text if any + const startNodePreviousSiblings = [] + let startNodePreviousSibling = startNode.previousSibling + while(startNodePreviousSibling){ + startNodePreviousSiblings.push(startNodePreviousSibling) + startNodePreviousSibling = startNodePreviousSibling.previousSibling + } + + // set the array back to tree order + startNodePreviousSiblings.reverse() + + if(startNodePreviousSiblings.length >= 1){ + let firstItemFirstestDescendant = firstItemFirstChild + while(firstItemFirstestDescendant?.firstChild){ + firstItemFirstestDescendant = firstItemFirstestDescendant.firstChild + } + + for(const beforeFirstNodeElement of startNodePreviousSiblings){ + firstItemFirstestDescendant?.parentNode?.insertBefore(beforeFirstNodeElement, firstItemFirstestDescendant) + } + } + + // add after-text if any + const endNodeNextSiblings = [] + let endNodeNextSibling = endNode.nextSibling + while(endNodeNextSibling){ + endNodeNextSiblings.push(endNodeNextSibling) + endNodeNextSibling = endNodeNextSibling.nextSibling + } + + if(endNodeNextSiblings.length >= 1){ + let lastItemLatestDescendant = lastItemLastChild + while(lastItemLatestDescendant?.lastChild){ + lastItemLatestDescendant = lastItemLatestDescendant.lastChild + } + + for(const afterEndNodeElement of endNodeNextSiblings){ + lastItemLatestDescendant?.parentNode?.appendChild(afterEndNodeElement) + } + } + + // remove block marker elements startChild.parentNode.removeChild(startChild) endChild.parentNode.removeChild(endChild) + } -const IF = 'IF' -const EACH = 'EACH' +const IF = ifStartMarkerRegex.source +const EACH = eachStartMarkerRegex.source /** * diff --git a/scripts/odf/templating/prepareTemplateDOMTree.js b/scripts/odf/templating/prepareTemplateDOMTree.js index b631ca8..698e823 100644 --- a/scripts/odf/templating/prepareTemplateDOMTree.js +++ b/scripts/odf/templating/prepareTemplateDOMTree.js @@ -1,3 +1,5 @@ +//@ts-check + import {traverse, Node} from "../../DOMUtils.js"; import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js' @@ -39,7 +41,7 @@ function findAllMatches(text, pattern) { * * @param {Node} node1 * @param {Node} node2 - * @returns {Node | undefined} + * @returns {Node} */ function findCommonAncestor(node1, node2) { const ancestors1 = getAncestors(node1); @@ -51,7 +53,7 @@ function findCommonAncestor(node1, node2) { } } - return undefined; + throw new Error(`node1 and node2 do not have a common ancestor`) } /** @@ -281,6 +283,8 @@ function consolidateMarkers(document){ newStartNode.parentNode?.removeChild(newStartNode) commonAncestor.insertBefore(newStartNode, commonAncestorStartChild.nextSibling) + + //console.log('commonAncestor after before-text split', commonAncestor.textContent ) } @@ -299,11 +303,15 @@ function consolidateMarkers(document){ endNode.parentNode?.removeChild(endNode) commonAncestor.insertBefore(endNode, commonAncestorEndChild) } + + //console.log('commonAncestor after after-text split', commonAncestor.textContent ) } // then, replace all nodes between (new)startNode and (new)endNode with a single textNode in commonAncestor replaceBetweenNodesWithText(newStartNode, endNode, positionedMarker.marker) + //console.log('commonAncestor after replaceBetweenNodesWithText', commonAncestor.textContent ) + // After consolidation, break as the DOM structure has changed // and containerTextNodesInTreeOrder needs to be refreshed consolidatedMarkers.push(positionedMarker) @@ -316,13 +324,29 @@ function consolidateMarkers(document){ } } +/** + * @typedef {typeof closingIfMarker | typeof eachClosingMarker | typeof eachStartMarkerRegex.source | typeof elseMarker | typeof ifStartMarkerRegex.source | typeof variableRegex.source} MarkerType + */ + +/** + * @typedef {Object} MarkerNode + * @prop {Node} node + * @prop {MarkerType} markerType + */ + /** * isolate markers which are in Text nodes with other texts * * @param {Document} document + * @returns {Map} */ -function isolateMarkers(document){ +function isolateMarkerText(document){ + /** @type {ReturnType} */ + const markerNodes = new Map() + traverse(document, currentNode => { + //console.log('isolateMarkers', currentNode.nodeName, currentNode.textContent) + if(currentNode.nodeType === Node.TEXT_NODE) { // find all marker starts and ends and split textNode let remainingText = currentNode.textContent || '' @@ -330,6 +354,8 @@ function isolateMarkers(document){ while(remainingText.length >= 1) { let matchText; let matchIndex; + /** @type {MarkerType} */ + let markerType; // looking for a block marker for(const marker of [ifStartMarkerRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingMarker]) { @@ -339,6 +365,7 @@ function isolateMarkers(document){ if(index !== -1) { matchText = marker matchIndex = index + markerType = marker // found the first match break; // get out of loop @@ -351,6 +378,7 @@ function isolateMarkers(document){ if(match) { matchText = match[0] matchIndex = match.index + markerType = marker.source // found the first match break; // get out of loop @@ -373,11 +401,21 @@ function isolateMarkers(document){ // per spec, currentNode now contains before-match and match text + /** @type {Node} */ + let matchTextNode + // @ts-ignore if(matchIndex > 0) { // @ts-ignore - currentNode.splitText(matchIndex) + matchTextNode = currentNode.splitText(matchIndex) } + else{ + matchTextNode = currentNode + } + + markerNodes.set(matchTextNode, markerType) + + // per spec, currentNode now contains only before-match text if(afterMatchTextNode) { currentNode = afterMatchTextNode @@ -397,8 +435,100 @@ function isolateMarkers(document){ // skip } }) + + return markerNodes } + + +/** + * after isolateMatchingMarkersStructure, matching markers (opening/closing each, if/then/closing if) + * are put in isolated branches within their common ancestors + * + * UNFINISHED - maybe another day if relevant + * + * @param {Document} document + * @param {Map} markerNodes + */ +//function isolateMatchingMarkersStructure(document, markerNodes){ + /** @type {MarkerNode[]} */ +/* let currentlyOpenBlocks = [] + + traverse(document, currentNode => { + + const markerType = markerNodes.get(currentNode) + + if(markerType){ + switch(markerType){ + case eachStartMarkerRegex.source: + case ifStartMarkerRegex.source: { + currentlyOpenBlocks.push({ + node: currentNode, + markerType + }) + break; + } + case eachClosingMarker: { + const lastOpenedBlockMarkerNode = currentlyOpenBlocks.pop() + + if(!lastOpenedBlockMarkerNode) + throw new Error(`{/each} found without corresponding opening {#each x as y}`) + + if(lastOpenedBlockMarkerNode.markerType !== eachStartMarkerRegex.source) + throw new Error(`{/each} found while the last opened block was not an opening {#each x as y} (it was a ${lastOpenedBlockMarkerNode.markerType})`) + + const openingEachNode = lastOpenedBlockMarkerNode.node + const closingEachNode = currentNode + + const commonAncestor = findCommonAncestor(openingEachNode, closingEachNode) + + if(openingEachNode.parentNode !== commonAncestor && openingEachNode.parentNode.childNodes.length >= 2){ + if(openingEachNode.previousSibling){ + // create branch for previousSiblings + let previousSibling = openingEachNode.previousSibling + const previousSiblings = [] + while(previousSibling){ + previousSiblings.push(previousSibling.previousSibling) + previousSibling = previousSibling.previousSibling + } + + // put previous siblings in tree order + previousSiblings.reverse() + + const parent = openingEachNode.parentNode + const parentClone = parent.cloneNode(false) + for(const previousSibling of previousSiblings){ + previousSibling.parentNode.removeChild(previousSibling) + parentClone.appendChild(previousSibling) + } + + let openingEachNodeBranch = openingEachNode.parentNode + let branchForPreviousSiblings = parentClone + + while(openingEachNodeBranch.parentNode !== commonAncestor){ + const newParentClone = openingEachNodeBranch.parentNode.cloneNode(false) + branchForPreviousSiblings.parentNode.removeChild(branchForPreviousSiblings) + newParentClone.appendChild(branchForPreviousSiblings) + } + } + } + + + + + break; + } + + default: + throw new TypeError(`MarkerType not recognized: '${markerType}`) + } + } + + }) + +}*/ + + /** * This function prepares the template DOM tree in a way that makes it easily processed by the template execution * Specifically, after the call to this function, the document is altered to respect the following property: @@ -415,5 +545,11 @@ function isolateMarkers(document){ */ export default function prepareTemplateDOMTree(document){ consolidateMarkers(document) - isolateMarkers(document) + // after consolidateMarkers, each marker is in at most one text node + // (formatting with markers is removed) + + isolateMarkerText(document) + // after isolateMarkerText, each marker is in exactly one text node + // (markers are separated from text that was before or after in the same text node) + } \ No newline at end of file diff --git a/tests/fill-odt-template/each.js b/tests/fill-odt-template/each.js index 8c9cc43..f0ede37 100644 --- a/tests/fill-odt-template/each.js +++ b/tests/fill-odt-template/each.js @@ -247,6 +247,42 @@ Hiver }); +test('template filling with text after {/each} in same text node', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/text-after-closing-each.odt') + const templateContent = `Légumes de saison + +{#each légumes as légume} +{légume}, +{/each} en {saison} +` + + const data = { + saison: 'Printemps', + légumes: [ + 'Asperge', + 'Betterave', + 'Blette' + ] + } + + const odtTemplate = await getOdtTemplate(templatePath) + + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template') + + const odtResult = await fillOdtTemplate(odtTemplate, data) + + const odtResultTextContent = await getOdtTextContent(odtResult) + t.deepEqual(odtResultTextContent, `Légumes de saison + +Asperge, +Betterave, +Blette, en Printemps +`) + +}); + + test('template filling of a table', async t => { const templatePath = join(import.meta.dirname, '../fixtures/tableau-simple.odt') const templateContent = `Évolution énergie en kWh par personne en France diff --git a/tests/fixtures/text-after-closing-each.odt b/tests/fixtures/text-after-closing-each.odt new file mode 100644 index 0000000000000000000000000000000000000000..b452f154e79a16c49fe75feac7945a10957ae29d GIT binary patch literal 11989 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