From c9284343e806e4ea489e96ff4393bb79e84a6796 Mon Sep 17 00:00:00 2001 From: David Bruant Date: Wed, 7 May 2025 09:15:29 +0200 Subject: [PATCH] If blocks (#4) * Adding {#if} test case * Expression evaluation based on ses Compartments * New Compartment usage * split template filling tests * passing tests except the if one * in progress * Refactoring: extracting extractBlockContent method * test if qui passe * Move tree preparation to its own function so it's done ony once * if in a single text node works --- package-lock.json | 35 +- package.json | 3 +- scripts/odf/fillOdtTemplate.js | 593 ++++++++++++------ tests/fill-odt-template/basic.js | 36 ++ .../each.js} | 105 +--- tests/fill-odt-template/if.js | 42 ++ tests/fill-odt-template/image.js | 38 ++ tests/fill-odt-template/in-text-node.js | 65 ++ tests/fixtures/description-nombre.odt | Bin 0 -> 13290 bytes tests/fixtures/inline-if-nombres.odt | Bin 0 -> 12489 bytes 10 files changed, 633 insertions(+), 284 deletions(-) create mode 100644 tests/fill-odt-template/basic.js rename tests/{fill-odt-template.js => fill-odt-template/each.js} (66%) create mode 100644 tests/fill-odt-template/if.js create mode 100644 tests/fill-odt-template/image.js create mode 100644 tests/fill-odt-template/in-text-node.js create mode 100644 tests/fixtures/description-nombre.odt create mode 100644 tests/fixtures/inline-if-nombres.odt diff --git a/package-lock.json b/package-lock.json index 20ef818..65f25c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "ods-xlsx", + "name": "@odfjs/odfjs", "version": "0.16.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "ods-xlsx", + "name": "@odfjs/odfjs", "version": "0.16.0", "dependencies": { "@xmldom/xmldom": "^0.9.8", - "@zip.js/zip.js": "^2.7.57" + "@zip.js/zip.js": "^2.7.57", + "ses": "^1.12.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", @@ -40,6 +41,12 @@ "node": ">=6.0.0" } }, + "node_modules/@endo/env-options": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.8.tgz", + "integrity": "sha512-Xtxw9n33I4guo8q0sDyZiRuxlfaopM454AKiELgU7l3tqsylCut6IBZ0fPy4ltSHsBib7M3yF7OEMoIuLwzWVg==", + "license": "Apache-2.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", @@ -3607,6 +3614,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ses": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/ses/-/ses-1.12.0.tgz", + "integrity": "sha512-jvmwXE2lFxIIY1j76hFjewIIhYMR9Slo3ynWZGtGl5M7VUCw3EA0wetS+JCIbl2UcSQjAT0yGAHkyxPJreuC9w==", + "license": "Apache-2.0", + "dependencies": { + "@endo/env-options": "^1.1.8" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -4555,6 +4571,11 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@endo/env-options": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.8.tgz", + "integrity": "sha512-Xtxw9n33I4guo8q0sDyZiRuxlfaopM454AKiELgU7l3tqsylCut6IBZ0fPy4ltSHsBib7M3yF7OEMoIuLwzWVg==" + }, "@jridgewell/gen-mapping": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", @@ -7116,6 +7137,14 @@ "type-fest": "^0.13.1" } }, + "ses": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/ses/-/ses-1.12.0.tgz", + "integrity": "sha512-jvmwXE2lFxIIY1j76hFjewIIhYMR9Slo3ynWZGtGl5M7VUCw3EA0wetS+JCIbl2UcSQjAT0yGAHkyxPJreuC9w==", + "requires": { + "@endo/env-options": "^1.1.8" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", diff --git a/package.json b/package.json index 460dba7..1b9fec5 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@xmldom/xmldom": "^0.9.8", - "@zip.js/zip.js": "^2.7.57" + "@zip.js/zip.js": "^2.7.57", + "ses": "^1.12.0" } } diff --git a/scripts/odf/fillOdtTemplate.js b/scripts/odf/fillOdtTemplate.js index 0e09c51..0c4a744 100644 --- a/scripts/odf/fillOdtTemplate.js +++ b/scripts/odf/fillOdtTemplate.js @@ -3,6 +3,11 @@ import { ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayRea 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' */ @@ -18,41 +23,17 @@ const ODTMimetype = 'application/vnd.oasis.opendocument.text' /** * @typedef TextPlaceToFill * @property { {expression: string, replacedString:string}[] } expressions - * @property {(values: any) => void} fill + * @property {() => void} fill */ -/** - * PPP : for now, expression is expected to be only an object property name or a dot-path - * in the future, it will certainly be a JavaScript expression - * securely evaluated within an hardernedJS Compartment https://hardenedjs.org/#compartment - * @param {string} expression - * @param {any} context - data / global object - * @return {any} - */ -function evaluateTemplateExpression(expression, context){ - const parts = expression.trim().split('.') - - let value = context; - - for(const part of parts){ - if(!value){ - return undefined - } - else{ - value = value[part] - } - } - - return value -} - /** * @param {string} str + * @param {Compartment} compartment * @returns {TextPlaceToFill | undefined} */ -function findPlacesToFillInString(str) { +function findPlacesToFillInString(str, compartment) { const matches = str.matchAll(/\{([^{#\/]+?)\}/g) /** @type {TextPlaceToFill['expressions']} */ @@ -75,8 +56,7 @@ function findPlacesToFillInString(str) { if (fixedPart.length >= 1) parts.push(fixedPart) - - parts.push(data => evaluateTemplateExpression(expression, data)) + parts.push(() => compartment.evaluate(expression)) remaining = newRemaining } @@ -110,27 +90,26 @@ function findPlacesToFillInString(str) { } - /** + * 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 * - * @param {Node} startNode - * @param {string} iterableExpression - * @param {string} itemExpression - * @param {Node} endNode - * @param {any} data - * @param {typeof Node} Node + * 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 fillEachBlock(startNode, iterableExpression, itemExpression, endNode, data, Node){ - //console.log('fillEachBlock', iterableExpression, itemExpression) - //console.log('startNode', startNode.nodeType, startNode.nodeName) - //console.log('endNode', endNode.nodeType, endNode.nodeName) - - // find common ancestor +function extractBlockContent(blockStartNode, blockEndNode){ + // find common ancestor of blockStartNode and blockEndNode let commonAncestor - let startAncestor = startNode - let endAncestor = endNode - + let startAncestor = blockStartNode + let endAncestor = blockEndNode + const startAncestry = new Set([startAncestor]) const endAncestry = new Set([endAncestor]) @@ -152,23 +131,14 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d commonAncestor = startAncestor } - - //console.log('commonAncestor', commonAncestor.tagName) - //console.log('startAncestry', startAncestry.size, [...startAncestry].indexOf(commonAncestor)) - //console.log('endAncestry', endAncestry.size, [...endAncestry].indexOf(commonAncestor)) - 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) - //console.log('startChild', startChild.tagName) - //console.log('endChild', endChild.tagName) - - // Find repeatable pattern and extract it in a documentFragment - // @ts-ignore - const repeatedFragment = startNode.ownerDocument.createDocumentFragment() + // Extract DOM content in a documentFragment + const contentFragment = blockStartNode.ownerDocument.createDocumentFragment() /** @type {Element[]} */ const repeatedPatternArray = [] @@ -179,17 +149,115 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d sibling = sibling.nextSibling; } - - //console.log('repeatedPatternArray', repeatedPatternArray.length) - for(const sibling of repeatedPatternArray){ sibling.parentNode?.removeChild(sibling) - repeatedFragment.appendChild(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 = evaluateTemplateExpression(iterableExpression, data) + let iterable = compartment.evaluate(iterableExpression) if(!iterable || typeof iterable[Symbol.iterator] !== 'function'){ // when there is no iterable, silently replace with empty array iterable = [] @@ -202,60 +270,305 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d // @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, - Object.assign({}, data, {[itemExpression]: item}), - Node + insideCompartment ) - // @ts-ignore - commonAncestor.insertBefore(itemFragment, endChild) + + 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 ifStartRegex = /{#if\s+([^}]+?)\s*}/; +const elseMarker = '{:else}' +const closingIfMarker = '{/if}' + +const eachStartMarkerRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/; +const eachClosingBlockString = '{/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(eachClosingBlockString) + + 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(ifStartRegex); + + 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 + } + } + }) +} + + /** * - * @param {Element | DocumentFragment} rootElement - * @param {any} data - * @param {typeof Node} Node + * @param {Document} document + * @param {Compartment} compartment * @returns {void} */ -function fillTemplatedOdtElement(rootElement, data, Node){ - //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) +function fillTemplatedOdtDocument(document, compartment){ + // prepare tree to be used as template // Perform a first traverse to split textnodes when they contain several block markers - traverse(rootElement, currentNode => { + traverse(document, currentNode => { if(currentNode.nodeType === Node.TEXT_NODE){ // trouver tous les débuts et fin de each et découper le textNode let remainingText = currentNode.textContent || '' while(remainingText.length >= 1){ - let match; + let matchText; + let matchIndex; - // looking for opening {#each ...} block - const eachBlockOpeningRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/; - const eachBlockClosingRegex = /{\/each}/; + // looking for a block marker + for(const marker of [ifStartRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingBlockString]){ + if(typeof marker === 'string'){ + const index = remainingText.indexOf(marker) + + if(index !== -1){ + matchText = marker + matchIndex = index - for(const regexp of [eachBlockOpeningRegex, eachBlockClosingRegex]){ - let thisMatch = remainingText.match(regexp) + // found the first match + break; // get out of loop + } + } + else{ + // marker is a RegExp + const match = remainingText.match(marker) - // trying to find only the first match in remainingText string - if(thisMatch && (!match || match.index > thisMatch.index)){ - match = thisMatch + if(match){ + matchText = match[0] + matchIndex = match.index + + // found the first match + break; // get out of loop + } } } - if(match){ + if(matchText){ // split 3-way : before-match, match and after-match - if(match[0].length < remainingText.length){ - let afterMatchTextNode = currentNode.splitText(match.index + match[0].length) + if(matchText.length < remainingText.length){ + // @ts-ignore + let afterMatchTextNode = currentNode.splitText(matchIndex + matchText.length) if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1){ remainingText = afterMatchTextNode.textContent } @@ -265,8 +578,10 @@ function fillTemplatedOdtElement(rootElement, data, Node){ // per spec, currentNode now contains before-match and match text - if(match.index > 0){ - currentNode.splitText(match.index) + // @ts-ignore + if(matchIndex > 0){ + // @ts-ignore + currentNode.splitText(matchIndex) } if(afterMatchTextNode){ @@ -290,107 +605,12 @@ function fillTemplatedOdtElement(rootElement, data, Node){ // now, each Node contains at most one block marker - - - /** @type {Node | undefined} */ - let eachBlockStartNode - /** @type {Node | undefined} */ - let eachBlockEndNode - - let nestedEach = 0 - - let iterableExpression, itemExpression; - - // Traverse "in document order" - - // @ts-ignore - traverse(rootElement, currentNode => { - const insideAnEachBlock = !!eachBlockStartNode - - if(currentNode.nodeType === Node.TEXT_NODE){ - const text = currentNode.textContent || '' - - // looking for {#each x as y} - const eachStartRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/; - const startMatch = text.match(eachStartRegex); - - if(startMatch){ - if(insideAnEachBlock){ - nestedEach = nestedEach + 1 - } - else{ - let [_, _iterableExpression, _itemExpression] = startMatch - - iterableExpression = _iterableExpression - itemExpression = _itemExpression - eachBlockStartNode = currentNode - } - } - - // trying to find an {/each} - const eachEndRegex = /{\/each}/ - const endMatch = text.match(eachEndRegex) - - if(endMatch){ - if(!eachBlockStartNode) - throw new TypeError(`{/each} found without corresponding opening {#each x as y}`) - - if(nestedEach >= 1){ - // ignore because it will be treated as part of the outer {#each} - nestedEach = nestedEach - 1 - } - else{ - eachBlockEndNode = currentNode - - // found an #each and its corresponding /each - // execute replacement loop - fillEachBlock(eachBlockStartNode, iterableExpression, itemExpression, eachBlockEndNode, data, Node) - - eachBlockStartNode = undefined - iterableExpression = undefined - itemExpression = undefined - eachBlockEndNode = undefined - } - } - - - // Looking for variables for substitutions - if(!insideAnEachBlock){ - if (currentNode.data) { - const placesToFill = findPlacesToFillInString(currentNode.data) - - if(placesToFill){ - const newText = placesToFill.fill(data) - const newTextNode = currentNode.ownerDocument?.createTextNode(newText) - currentNode.parentNode?.replaceChild(newTextNode, currentNode) - } - } - } - else{ - // ignore because it will be treated as part of the {#each} block - } - } - - if(currentNode.nodeType === Node.ATTRIBUTE_NODE){ - // Looking for variables for substitutions - if(!insideAnEachBlock){ - if (currentNode.value) { - const placesToFill = findPlacesToFillInString(currentNode.value) - if(placesToFill){ - currentNode.value = placesToFill.fill(data) - } - } - } - else{ - // ignore because it will be treated as part of the {#each} block - } - } - }) + fillTemplatedOdtElement(document, compartment) } -const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml']) +const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml']) /** * @@ -454,7 +674,14 @@ export default async function fillOdtTemplate(odtTemplate, data) { // @ts-ignore const contentXml = await entry.getData(new TextWriter()); const contentDocument = parseXML(contentXml); - fillTemplatedOdtElement(contentDocument, data, Node) + + const compartment = new Compartment({ + globals: data, + __options__: true + }) + + fillTemplatedOdtDocument(contentDocument, compartment) + const updatedContentXml = serializeToString(contentDocument) content = new TextReader(updatedContentXml) diff --git a/tests/fill-odt-template/basic.js b/tests/fill-odt-template/basic.js new file mode 100644 index 0000000..704cb3a --- /dev/null +++ b/tests/fill-odt-template/basic.js @@ -0,0 +1,36 @@ +import test from 'ava'; +import {join} from 'node:path'; + +import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' + +import {fillOdtTemplate, getOdtTextContent} from '../../exports.js' + + +test('basic template filling with variable substitution', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/template-anniversaire.odt') + const templateContent = `Yo {nom} ! +Tu es né.e le {dateNaissance} + +Bonjoir ☀️ +` + + const data = { + nom: 'David Bruant', + dateNaissance: '8 mars 1987' + } + + 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, `Yo David Bruant ! +Tu es né.e le 8 mars 1987 + +Bonjoir ☀️ +`) + +}); + diff --git a/tests/fill-odt-template.js b/tests/fill-odt-template/each.js similarity index 66% rename from tests/fill-odt-template.js rename to tests/fill-odt-template/each.js index ec06b9a..8c9cc43 100644 --- a/tests/fill-odt-template.js +++ b/tests/fill-odt-template/each.js @@ -1,43 +1,13 @@ import test from 'ava'; import {join} from 'node:path'; -import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js' +import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' -import {fillOdtTemplate, getOdtTextContent} from '../exports.js' -import { listZipEntries } from './helpers/zip-analysis.js'; - - -test('basic template filling with variable substitution', async t => { - const templatePath = join(import.meta.dirname, './fixtures/template-anniversaire.odt') - const templateContent = `Yo {nom} ! -Tu es né.e le {dateNaissance} - -Bonjoir ☀️ -` - - const data = { - nom: 'David Bruant', - dateNaissance: '8 mars 1987' - } - - 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, `Yo David Bruant ! -Tu es né.e le 8 mars 1987 - -Bonjoir ☀️ -`) - -}); +import {fillOdtTemplate, getOdtTextContent} from '../../exports.js' test('basic template filling with {#each}', async t => { - const templatePath = join(import.meta.dirname, './fixtures/enum-courses.odt') + const templatePath = join(import.meta.dirname, '../fixtures/enum-courses.odt') const templateContent = `🧺 La liste de courses incroyable 🧺 {#each listeCourses as élément} @@ -74,7 +44,7 @@ Pâtes à lasagne (fraîches !) test('Filling with {#each} and non-iterable value results in no error and empty result', async t => { - const templatePath = join(import.meta.dirname, './fixtures/enum-courses.odt') + const templatePath = join(import.meta.dirname, '../fixtures/enum-courses.odt') const templateContent = `🧺 La liste de courses incroyable 🧺 {#each listeCourses as élément} @@ -104,7 +74,7 @@ test('Filling with {#each} and non-iterable value results in no error and empty test('template filling with {#each} generating a list', async t => { - const templatePath = join(import.meta.dirname, './fixtures/liste-courses.odt') + const templatePath = join(import.meta.dirname, '../fixtures/liste-courses.odt') const templateContent = `🧺 La liste de courses incroyable 🧺 - {#each listeCourses as élément} @@ -141,7 +111,7 @@ test('template filling with {#each} generating a list', async t => { test('template filling with 2 sequential {#each}', async t => { - const templatePath = join(import.meta.dirname, './fixtures/liste-fruits-et-légumes.odt') + const templatePath = join(import.meta.dirname, '../fixtures/liste-fruits-et-légumes.odt') const templateContent = `Liste de fruits et légumes Fruits @@ -193,7 +163,7 @@ Poivron 🫑 test('template filling with nested {#each}s', async t => { - const templatePath = join(import.meta.dirname, './fixtures/légumes-de-saison.odt') + const templatePath = join(import.meta.dirname, '../fixtures/légumes-de-saison.odt') const templateContent = `Légumes de saison {#each légumesSaison as saisonLégumes} @@ -277,36 +247,8 @@ Hiver }); -test('template filling {#each ...}{/each} within a single text node', async t => { - const templatePath = join(import.meta.dirname, './fixtures/liste-nombres.odt') - const templateContent = `Liste de nombres - -Les nombres : {#each nombres as n}{n} {/each} !! -` - - const data = { - nombres : [1,1,2,3,5,8,13,21] - } - - 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, `Liste de nombres - -Les nombres : 1 1 2 3 5 8 13 21  !! -`) - -}); - - - test('template filling of a table', async t => { - const templatePath = join(import.meta.dirname, './fixtures/tableau-simple.odt') + const templatePath = join(import.meta.dirname, '../fixtures/tableau-simple.odt') const templateContent = `Évolution énergie en kWh par personne en France Année @@ -364,34 +306,3 @@ Année `.trim()) }); - - -test('template filling preserves images', async t => { - const templatePath = join(import.meta.dirname, './fixtures/template-avec-image.odt') - - const data = { - commentaire : `J'adooooooore 🤩 West covinaaaaaaaaaaa 🎶` - } - - const odtTemplate = await getOdtTemplate(templatePath) - const templateEntries = await listZipEntries(odtTemplate) - - //console.log('templateEntries', templateEntries.map(({filename, directory}) => ({filename, directory}))) - - t.assert( - templateEntries.find(entry => entry.filename.startsWith('Pictures/')), - `One zip entry of the template is expected to have a name that starts with 'Pictures/'` - ) - - const odtResult = await fillOdtTemplate(odtTemplate, data) - const resultEntries = await listZipEntries(odtResult) - - //console.log('resultEntries', resultEntries.map(({filename, directory}) => ({filename, directory}))) - - - t.assert( - resultEntries.find(entry => entry.filename.startsWith('Pictures/')), - `One zip entry of the result is expected to have a name that starts with 'Pictures/'` - ) - -}) \ No newline at end of file diff --git a/tests/fill-odt-template/if.js b/tests/fill-odt-template/if.js new file mode 100644 index 0000000..729f0ca --- /dev/null +++ b/tests/fill-odt-template/if.js @@ -0,0 +1,42 @@ +import test from 'ava'; +import {join} from 'node:path'; + +import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' + +import {fillOdtTemplate, getOdtTextContent} from '../../exports.js' + + +test('basic template filling with {#if}', 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') + + // then branch + const odtResult3 = await fillOdtTemplate(odtTemplate, {n: 3}) + 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 +`) + + +}); + diff --git a/tests/fill-odt-template/image.js b/tests/fill-odt-template/image.js new file mode 100644 index 0000000..9162feb --- /dev/null +++ b/tests/fill-odt-template/image.js @@ -0,0 +1,38 @@ +import test from 'ava'; +import {join} from 'node:path'; + +import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' + +import {fillOdtTemplate} from '../../exports.js' +import { listZipEntries } from '../helpers/zip-analysis.js'; + + +test('template filling preserves images', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/template-avec-image.odt') + + const data = { + commentaire : `J'adooooooore 🤩 West covinaaaaaaaaaaa 🎶` + } + + const odtTemplate = await getOdtTemplate(templatePath) + const templateEntries = await listZipEntries(odtTemplate) + + //console.log('templateEntries', templateEntries.map(({filename, directory}) => ({filename, directory}))) + + t.assert( + templateEntries.find(entry => entry.filename.startsWith('Pictures/')), + `One zip entry of the template is expected to have a name that starts with 'Pictures/'` + ) + + const odtResult = await fillOdtTemplate(odtTemplate, data) + const resultEntries = await listZipEntries(odtResult) + + //console.log('resultEntries', resultEntries.map(({filename, directory}) => ({filename, directory}))) + + + t.assert( + resultEntries.find(entry => entry.filename.startsWith('Pictures/')), + `One zip entry of the result is expected to have a name that starts with 'Pictures/'` + ) + +}) \ No newline at end of file diff --git a/tests/fill-odt-template/in-text-node.js b/tests/fill-odt-template/in-text-node.js new file mode 100644 index 0000000..1928d3f --- /dev/null +++ b/tests/fill-odt-template/in-text-node.js @@ -0,0 +1,65 @@ +import test from 'ava'; +import {join} from 'node:path'; + +import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js' + +import {fillOdtTemplate, getOdtTextContent} from '../../exports.js' + + +test('template filling {#if ...}{/if} within a single text node', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/inline-if-nombres.odt') + const templateContent = `Taille de nombre + +Le nombre {n} est {#if n<5}petit{:else}grand{/if}. +` + + const odtTemplate = await getOdtTemplate(templatePath) + + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template') + + const odtResult3 = await fillOdtTemplate(odtTemplate, {n : 3}) + + const odtResult3TextContent = await getOdtTextContent(odtResult3) + t.deepEqual(odtResult3TextContent, `Taille de nombre + +Le nombre 3 est petit. +`) + + const odtResult9 = await fillOdtTemplate(odtTemplate, {n : 9}) + + const odtResult9TextContent = await getOdtTextContent(odtResult9) + t.deepEqual(odtResult9TextContent, `Taille de nombre + +Le nombre 9 est grand. +`) + +}); + + +test('template filling {#each ...}{/each} within a single text node', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/liste-nombres.odt') + const templateContent = `Liste de nombres + +Les nombres : {#each nombres as n}{n} {/each} !! +` + + const data = { + nombres : [1,1,2,3,5,8,13,21] + } + + 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, `Liste de nombres + +Les nombres : 1 1 2 3 5 8 13 21  !! +`) + +}); + diff --git a/tests/fixtures/description-nombre.odt b/tests/fixtures/description-nombre.odt new file mode 100644 index 0000000000000000000000000000000000000000..e44bd4f56bfaed15e2dd7a291dbee8d42af863db GIT binary patch literal 13290 zcmc(G1yr5MvMz)WT!VWcxVw9>;O_2j8@J%@4ncyuyGw9~ja!1VvET%KF!#>M%sDf2 z-&^mk^@{bc)xGKZx_0$Xb#?WJoFv3+G%zq|Ffg%?85%nMOz`AjU|=uL^G9IT=GMjl zH#=i}J3A|LLw$g`tqr}ijS-!#zN5J#ovod*jghUPleMu8fDT~n3XuB~*t7n>1napa zY-?j;ZtCRl8;m0p{j-{rfxZL%-)rIhg_fOzt*L{tqvJno;{27St(}wI%cTD><6qfn zVr8ojF#aby|7tczfIh(Kf3WheW&_yTTK%7Yw-+rteH&w|fB0^Mf8i5zYkgB=M|vT1 zfVIAz<3A|>)l%pi8X8*}Kd-0l@6LgShW_n6dYHbUa) z`n+xzbS{F}*LJsGor8�@nj-rD&DPh11$CBf31MyS?U=Rjg_& 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