From 1181356953933b127015995a3c908423e171e3ea Mon Sep 17 00:00:00 2001 From: David Bruant Date: Mon, 26 May 2025 11:41:09 +0200 Subject: [PATCH] beginning of refactoring - if tests passing --- scripts/DOMUtils.js | 45 +++ .../odf/templating/fillOdtElementTemplate.js | 382 ++++++++++++++---- .../odf/templating/prepareTemplateDOMTree.js | 40 +- tests/fill-odt-template/formatting.js | 32 +- tests/fill-odt-template/if.js | 46 ++- tests/fixtures/reducing.odt | Bin 0 -> 12283 bytes 6 files changed, 421 insertions(+), 124 deletions(-) create mode 100644 tests/fixtures/reducing.odt diff --git a/scripts/DOMUtils.js b/scripts/DOMUtils.js index 5df3e65..14dfb3b 100644 --- a/scripts/DOMUtils.js +++ b/scripts/DOMUtils.js @@ -42,6 +42,51 @@ export function traverse(node, visit) { visit(node); } + +/** + * + * @param {Node} node1 + * @param {Node} node2 + * @returns {Node} + */ +export function findCommonAncestor(node1, node2) { + const ancestors1 = getAncestors(node1); + const ancestors2 = new Set(getAncestors(node2)); + + for(const ancestor of ancestors1) { + if(ancestors2.has(ancestor)) { + return ancestor; + } + } + + throw new Error(`node1 and node2 do not have a common ancestor`) +} + +/** + * returns ancestors youngest first, oldest last + * + * @param {Node} node + * @param {Node} [until] + * @returns {Node[]} + */ +export function getAncestors(node, until = undefined) { + const ancestors = []; + let current = node; + + while(current && current !== until) { + ancestors.push(current); + current = current.parentNode; + } + + if(current === until){ + ancestors.push(until); + } + + return ancestors; +} + + + export { DOMParser, XMLSerializer, diff --git a/scripts/odf/templating/fillOdtElementTemplate.js b/scripts/odf/templating/fillOdtElementTemplate.js index a23043c..f8d9101 100644 --- a/scripts/odf/templating/fillOdtElementTemplate.js +++ b/scripts/odf/templating/fillOdtElementTemplate.js @@ -1,4 +1,4 @@ -import {traverse, Node} from '../../DOMUtils.js' +import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js"; import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js' /** @@ -7,6 +7,181 @@ import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, if * @property {() => void} fill */ +class TemplateDOMBranch{ + /** @type {Node} */ + #startNode + + /** @type {Node} */ + #leafNode + + // ancestors with this.#ancestors[0] === this.#startNode and this.#ancestors.at(-1) === this.#leafNode + /** @type {Node[]} */ + #ancestors + + /** + * + * @param {Node} startNode + * @param {Node} leafNode + */ + constructor(startNode, leafNode){ + this.#startNode = startNode + this.#leafNode = leafNode + + this.#ancestors = getAncestors(this.#leafNode, this.#startNode).reverse() + } + + /** + * + * @param {number} n + * @returns {Node | undefined} + */ + at(n){ + return this.#ancestors.at(n) + } + + removeLeafAndEmptyAncestors(){ + // 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) + this.#leafNode = this.#ancestors.at(-1) + return false; + } + + return true // continue + }) + + //console.log('removeLeafAndEmptyAncestors', this.#startNode.textContent) + let nextLeaf = this.#leafNode.parentNode + //console.log('nextLeaf', !!nextLeaf) + nextLeaf.removeChild(this.#leafNode) + this.#leafNode = nextLeaf + + while(this.#leafNode !== this.#startNode && + this.#leafNode.textContent && this.#leafNode.textContent.trim() === '') + { + nextLeaf = this.#leafNode.parentNode + this.#leafNode.parentNode.removeChild(this.#leafNode) + this.#leafNode = nextLeaf + } + + this.#ancestors = getAncestors(this.#startNode, this.#leafNode).reverse() + } + + /** + * + * @param {number} [startIndex] + */ + removeRightContent(startIndex = 0){ + for(const branchNode of this.#ancestors.slice(startIndex)){ + 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 + } + } + } + + +} + + +class TemplateBlock{ + /** @type {Element | Document | DocumentFragment} */ + #commonAncestor; + /** @type {TemplateDOMBranch} */ + #startBranch; + /** @type {TemplateDOMBranch} */ + #endBranch; + + /** @type {Node[]} */ + #middleContent; + + /** + * + * @param {Node} startMarkerNode + * @param {Node} endMarkerNode + */ + constructor(startMarkerNode, endMarkerNode){ + // @ts-expect-error xmldom.Node + this.#commonAncestor = findCommonAncestor(startMarkerNode, endMarkerNode) + + this.#startBranch = new TemplateDOMBranch(this.#commonAncestor, startMarkerNode) + this.#endBranch = new TemplateDOMBranch(this.#commonAncestor, endMarkerNode) + + this.#middleContent = [] + + let content = this.#startBranch.at(1).nextSibling + while(content && content !== this.#endBranch.at(1)){ + this.#middleContent.push(content) + content = content.nextSibling + } + + //console.log('TemplateBlock') + //console.log('startBranch', this.#startBranch.at(1).textContent) + //console.log('middleContent', this.#middleContent.map(n => n.textContent).join('')) + //console.log('endBranch', this.#endBranch.at(1).textContent) + } + + removeMarkersAndEmptyAncestors(){ + //console.log('removeMarkersAndEmptyAncestors startBranch') + this.#startBranch.removeLeafAndEmptyAncestors() + //console.log('removeMarkersAndEmptyAncestors endBranch') + this.#endBranch.removeLeafAndEmptyAncestors() + } + + /** + * + * @param {Compartment} compartement + */ + fillBlockContentTemplate(compartement){ + const startChild = this.#startBranch.at(1) + if(startChild){ + fillOdtElementTemplate(startChild, compartement) + } + + for(const content of this.#middleContent){ + fillOdtElementTemplate(content, compartement) + } + + const endChild = this.#endBranch.at(1) + if(endChild){ + fillOdtElementTemplate(endChild, compartement) + } + } + + removeContent(){ + this.#startBranch.removeRightContent(2) + + for(const content of this.#middleContent){ + content.parentNode.removeChild(content) + } + + this.#endBranch.removeLeftContent(2) + } +} + /** * @param {string} str @@ -82,7 +257,7 @@ function findPlacesToFillInString(str, compartment) { * * @param {Node} blockStartNode * @param {Node} blockEndNode - * @returns {{startChild: Node, endChild:Node, content: DocumentFragment}} + * @returns {{removeMarkers: () => void, insertContent: (n : Node) => void, content: DocumentFragment}} */ function extractBlockContent(blockStartNode, blockEndNode) { //console.log('[extractBlockContent] blockStartNode', blockStartNode.textContent) @@ -116,6 +291,8 @@ function extractBlockContent(blockStartNode, blockEndNode) { commonAncestor = startAncestor } + //console.log('extractBlockContent', commonAncestor.textContent) + const startAncestryToCommonAncestor = [...startAncestry].slice(0, [...startAncestry].indexOf(commonAncestor)) const endAncestryToCommonAncestor = [...endAncestry].slice(0, [...endAncestry].indexOf(commonAncestor)) @@ -123,8 +300,8 @@ function extractBlockContent(blockStartNode, blockEndNode) { const startChild = startAncestryToCommonAncestor.at(-1) const endChild = endAncestryToCommonAncestor.at(-1) - //console.log('[extractBlockContent] startChild', startChild.textContent) - //console.log('[extractBlockContent] endChild', endChild.textContent) + //console.log('[extractBlockContent] startChild', startChild.childNodes.length, startChild.textContent) + //console.log('[extractBlockContent] endChild', endChild.childNodes.length,endChild.textContent) // Extract DOM content in a documentFragment /** @type {DocumentFragment} */ @@ -188,9 +365,68 @@ function extractBlockContent(blockStartNode, blockEndNode) { //console.log('extractBlockContent contentFragment', contentFragment.textContent) + let insertionParent; + + if(startAncestryToCommonAncestor.length >= endAncestryToCommonAncestor.length){ + insertionParent = blockStartNode.parentNode + } + else{ + insertionParent = blockEndNode.parentNode + } + + let insertionBeforeNodeCandidates + if(blockEndNode.nextSibling){ + insertionBeforeNodeCandidates = [blockEndNode.nextSibling] + while(insertionBeforeNodeCandidates.at(-1).nextSibling){ + insertionBeforeNodeCandidates.push(insertionBeforeNodeCandidates.at(-1).nextSibling) + } + } + + /** + * @param {Node} content + */ + function insertContent(content){ + //console.log('insertContent', node.textContent, insertionBeforeNodeCandidates.map(n => `${n.nodeName} - ${n.textContent}`)) + let insertionBeforeNode + + if(insertionBeforeNodeCandidates){ + insertionBeforeNode = insertionBeforeNodeCandidates.find(node => node.parentNode === insertionParent) + } + + console.log('insertContent insertionBeforeNode', insertionBeforeNode && insertionBeforeNode.textContent) + + + if(insertionBeforeNode){ + insertionParent.insertBefore(content, insertionBeforeNode) + } + else{ + console.log('insertionParent', insertionParent.nodeName) + console.log('insertionParent content before append', insertionParent.textContent) + //console.log('insertionParent owner doc', insertionParent.ownerDocument) + + insertionParent.appendChild(content) + console.log('insertionParent content after append', insertionParent.textContent) + } + } + + console.log('contentFragment', + contentFragment.childNodes.length, + contentFragment.childNodes[0].nodeName, + contentFragment.textContent + ) + return { - startChild, - endChild, + removeMarkers(){ + for(const marker of [blockStartNode, blockEndNode]){ + console.log('removing marker', marker.nodeName, marker.textContent) + + try{ + marker.parentNode.removeChild(marker) + } + catch(e){} + } + }, + insertContent, content: contentFragment } } @@ -209,65 +445,43 @@ function extractBlockContent(blockStartNode, blockEndNode) { function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) { const conditionValue = compartment.evaluate(ifBlockConditionExpression) - let startChild - let endChild - - let markerNodes = new Set() - - let chosenFragment + /** @type {TemplateBlock | undefined} */ + let thenTemplateBlock + /** @type {TemplateBlock | undefined} */ + let elseTemplateBlock if(ifElseMarkerNode) { - const { - startChild: startIfThenChild, - endChild: endIfThenChild, - content: thenFragment - } = extractBlockContent(ifOpeningMarkerNode, ifElseMarkerNode) + /*console.log('before first extract', + ifOpeningMarkerNode.childNodes.length, ifOpeningMarkerNode.textContent, + ifElseMarkerNode.childNodes.length, ifElseMarkerNode.textContent + )*/ - 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) + thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode) + elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode) } else { - const { - startChild: startIfThenChild, - endChild: endIfThenChild, - content: thenFragment - } = extractBlockContent(ifOpeningMarkerNode, ifClosingMarkerNode) - - chosenFragment = conditionValue ? thenFragment : undefined - startChild = startIfThenChild - endChild = endIfThenChild - - markerNodes - .add(startIfThenChild).add(endIfThenChild) + thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode) } - if(chosenFragment) { - fillOdtElementTemplate( - chosenFragment, - compartment - ) - - endChild.parentNode.insertBefore(chosenFragment, endChild) + thenTemplateBlock.removeMarkersAndEmptyAncestors() + if(elseTemplateBlock){ + elseTemplateBlock.removeMarkersAndEmptyAncestors() } - for(const markerNode of markerNodes) { - try { - // may throw if node already out of tree - // might happen if - markerNode.parentNode.removeChild(markerNode) + + if(conditionValue) { + thenTemplateBlock.fillBlockContentTemplate(compartment) + + if(elseTemplateBlock){ + elseTemplateBlock.removeContent() + } + } + else{ + thenTemplateBlock.removeContent() + + if(elseTemplateBlock){ + elseTemplateBlock.fillBlockContentTemplate(compartment) } - catch(e) {} } } @@ -284,7 +498,9 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment) { //console.log('fillEachBlock', iterableExpression, itemExpression) - const {startChild, endChild, content: repeatedFragment} = extractBlockContent(startNode, endNode) + const docEl = startNode.ownerDocument.documentElement + + const {removeMarkers, insertContent, content: repeatedFragment} = extractBlockContent(startNode, endNode) // Find the iterable in the data // PPP eventually, evaluate the expression as a JS expression @@ -297,6 +513,26 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c let firstItemFirstChild let lastItemLastChild + // store before-text in startNodePreviousSiblings + const startNodePreviousSiblings = [] + let startNodePreviousSibling = startNode.previousSibling + while(startNodePreviousSibling){ + startNodePreviousSiblings.push(startNodePreviousSibling) + startNodePreviousSibling = startNodePreviousSibling.previousSibling + } + + // set the array back to tree order + startNodePreviousSiblings.reverse() + + + // store after-text in endNodeNextSiblings + const endNodeNextSiblings = [] + let endNodeNextSibling = endNode.nextSibling + while(endNodeNextSibling){ + endNodeNextSiblings.push(endNodeNextSibling) + endNodeNextSibling = endNodeNextSibling.nextSibling + } + // create each loop result // using a for-of loop to accept all iterable values for(const item of iterable) { @@ -316,6 +552,9 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c insideCompartment ) + console.log('itemFragment', itemFragment.textContent) + + if(!firstItemFirstChild){ firstItemFirstChild = itemFragment.firstChild } @@ -323,20 +562,11 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c // eventually, will be set to the last item's last child lastItemLastChild = itemFragment.lastChild - endChild.parentNode.insertBefore(itemFragment, endChild) - } + insertContent(itemFragment) - // add before-text if any - const startNodePreviousSiblings = [] - let startNodePreviousSibling = startNode.previousSibling - while(startNodePreviousSibling){ - startNodePreviousSiblings.push(startNodePreviousSibling) - startNodePreviousSibling = startNodePreviousSibling.previousSibling + console.log('doc', docEl.textContent) } - // set the array back to tree order - startNodePreviousSiblings.reverse() - if(startNodePreviousSiblings.length >= 1){ let firstItemFirstestDescendant = firstItemFirstChild while(firstItemFirstestDescendant?.firstChild){ @@ -348,13 +578,8 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c } } - // add after-text if any - const endNodeNextSiblings = [] - let endNodeNextSibling = endNode.nextSibling - while(endNodeNextSibling){ - endNodeNextSiblings.push(endNodeNextSibling) - endNodeNextSibling = endNodeNextSibling.nextSibling - } + console.log('doc after add before-text if any', docEl.textContent) + if(endNodeNextSiblings.length >= 1){ let lastItemLatestDescendant = lastItemLastChild @@ -363,15 +588,16 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c } for(const afterEndNodeElement of endNodeNextSiblings){ + console.log('doc in for-of', docEl.textContent) + console.log('afterEndNodeElement', afterEndNodeElement.textContent) lastItemLatestDescendant?.parentNode?.appendChild(afterEndNodeElement) } } - + console.log('doc before removeMarkers', docEl.textContent) // remove block marker elements - startChild.parentNode.removeChild(startChild) - endChild.parentNode.removeChild(endChild) - + removeMarkers() + console.log('doc after removeMarkers', docEl.textContent) } diff --git a/scripts/odf/templating/prepareTemplateDOMTree.js b/scripts/odf/templating/prepareTemplateDOMTree.js index 8c75d9e..3068ab0 100644 --- a/scripts/odf/templating/prepareTemplateDOMTree.js +++ b/scripts/odf/templating/prepareTemplateDOMTree.js @@ -1,6 +1,6 @@ //@ts-check -import {traverse, Node} from "../../DOMUtils.js"; +import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js"; import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js' @@ -38,41 +38,7 @@ function findAllMatches(text, pattern) { return results; } -/** - * - * @param {Node} node1 - * @param {Node} node2 - * @returns {Node} - */ -function findCommonAncestor(node1, node2) { - const ancestors1 = getAncestors(node1); - const ancestors2 = new Set(getAncestors(node2)); - for(const ancestor of ancestors1) { - if(ancestors2.has(ancestor)) { - return ancestor; - } - } - - throw new Error(`node1 and node2 do not have a common ancestor`) -} - -/** - * - * @param {Node} node - * @returns {Node[]} - */ -function getAncestors(node) { - const ancestors = []; - let current = node; - - while(current) { - ancestors.push(current); - current = current.parentNode; - } - - return ancestors; -} /** * text position of a node relative to a text nodes within a container @@ -326,6 +292,8 @@ function consolidateMarkers(document){ consolidatedMarkers.push(positionedMarker) } } + + //console.log('consolidatedMarkers', consolidatedMarkers) } } @@ -442,6 +410,8 @@ function isolateMarkerText(document){ } }) + //console.log('markerNodes', [...markerNodes].map(([node, markerType]) => [node.textContent, markerType])) + return markerNodes } diff --git a/tests/fill-odt-template/formatting.js b/tests/fill-odt-template/formatting.js index 4a6aaf1..8ed8022 100644 --- a/tests/fill-odt-template/formatting.js +++ b/tests/fill-odt-template/formatting.js @@ -157,10 +157,40 @@ test('template filling - formatted-start-each-single-paragraph', async t => { const odtResult = await fillOdtTemplate(odtTemplate, data) const odtResultTextContent = await getOdtTextContent(odtResult) - t.deepEqual(odtResultTextContent.trim(), ` + console.log('odtResultTextContent', odtResultTextContent) + t.deepEqual(odtResultTextContent, ` 37 38 39 +`) + +}); + + +test('template filling - formatted ghost if', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/reducing.odt') + const templateContent = ` + Utilisation de sources lumineuses : {#if scientifique.source_lumineuses}Oui{:else}Non{/if} +` + + const data = {scientifique: {source_lumineuses: true}} + + const odtTemplate = await getOdtTemplate(templatePath) + + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent.trim(), templateContent.trim(), 'reconnaissance du template') + let odtResult + try{ + odtResult = await fillOdtTemplate(odtTemplate, data) + } + catch(e){ + console.error('e', e) + } + + + const odtResultTextContent = await getOdtTextContent(odtResult) + t.deepEqual(odtResultTextContent.trim(), ` + Utilisation de sources lumineuses : Oui `.trim()) }); diff --git a/tests/fill-odt-template/if.js b/tests/fill-odt-template/if.js index c934313..be8aa44 100644 --- a/tests/fill-odt-template/if.js +++ b/tests/fill-odt-template/if.js @@ -6,7 +6,7 @@ import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' import {fillOdtTemplate, getOdtTextContent} from '../../exports.js' -test('basic template filling with {#if}', async t => { +test('basic template filling with {#if}{:else} - then branch', async t => { const templatePath = join(import.meta.dirname, '../fixtures/description-nombre.odt') const templateContent = `Description du nombre {n} @@ -26,22 +26,48 @@ n est un grand nombre const odtResult3TextContent = await getOdtTextContent(odtResult3) t.deepEqual(odtResult3TextContent, `Description du nombre 3 -n est un petit nombre -`) - - // else branch - const odtResult8 = await fillOdtTemplate(odtTemplate, {n: 8}) - const odtResult8TextContent = await getOdtTextContent(odtResult8) - t.deepEqual(odtResult8TextContent, `Description du nombre 8 -n est un grand nombre +n est un petit nombre + `) }); -test('weird bug', async t => { +test('basic template filling with {#if}{:else} - else branch', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/description-nombre.odt') + const templateContent = `Description du nombre {n} + +{#if n<5} +n est un petit nombre +{:else} +n est un grand nombre +{/if} +` + + const odtTemplate = await getOdtTemplate(templatePath) + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template') + + try{ + // else branch + const odtResult8 = await fillOdtTemplate(odtTemplate, {n: 8}) + const odtResult8TextContent = await getOdtTextContent(odtResult8) + t.deepEqual(odtResult8TextContent, `Description du nombre 8 + + +n est un grand nombre + +`) + } + catch(e){console.error(e); throw e} + + +}); + + +test('complex structured if', async t => { const templatePath = join(import.meta.dirname, '../fixtures/left-branch-content-and-two-consecutive-ifs.odt') const templateContent = `Utilisation de sources lumineuses : {#if scientifique.source_lumineuses}Oui{:else}Non{/if} {#if scientifique.source_lumineuses && scientifique.modalités_source_lumineuses } diff --git a/tests/fixtures/reducing.odt b/tests/fixtures/reducing.odt new file mode 100644 index 0000000000000000000000000000000000000000..604ff083de34ea82604dc64542b5d123ad3c469e GIT binary patch literal 12283 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