From 0bbc57afb730bc4b0a723fedc841931b21afef7b Mon Sep 17 00:00:00 2001 From: David Bruant Date: Thu, 8 May 2025 17:13:51 +0200 Subject: [PATCH] Formatted markers (#5) * Adding failing test case * test runs and fails * passing formatting test * passing test * refactoring moving different parts to their own files * Refactoring - doc in prepareTemplateDOMTree * Test of 2 formatted markers within same Text node passes * passing test with {#each ...} and text before partially formatted * Test with {/each} and text after partially formatted passing * woops with proper test case * test with partially formatted variable passes --- exports.js | 2 +- scripts/DOMUtils.js | 2 +- scripts/odf/odt/getOdtTextContent.js | 2 +- .../fillOdtElementTemplate.js} | 423 ++++-------------- scripts/odf/templating/fillOdtTemplate.js | 166 +++++++ scripts/odf/templating/markers.js | 9 + .../odf/templating/prepareTemplateDOMTree.js | 413 +++++++++++++++++ tests/fill-odt-template/formatting.js | 141 ++++++ ...ing-liste-nombres-2-markeurs-formatted.odt | Bin 0 -> 12565 bytes ...e-nombres-each-end-and-after-formatted.odt | Bin 0 -> 12578 bytes ...ombres-each-start-and-before-formatted.odt | Bin 0 -> 12536 bytes ...atting-liste-nombres-plusieurs-couches.odt | Bin 0 -> 12594 bytes .../fixtures/partially-formatted-variable.odt | Bin 0 -> 11685 bytes 13 files changed, 812 insertions(+), 346 deletions(-) rename scripts/odf/{fillOdtTemplate.js => templating/fillOdtElementTemplate.js} (53%) create mode 100644 scripts/odf/templating/fillOdtTemplate.js create mode 100644 scripts/odf/templating/markers.js create mode 100644 scripts/odf/templating/prepareTemplateDOMTree.js create mode 100644 tests/fill-odt-template/formatting.js create mode 100644 tests/fixtures/formatting-liste-nombres-2-markeurs-formatted.odt create mode 100644 tests/fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt create mode 100644 tests/fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt create mode 100644 tests/fixtures/formatting-liste-nombres-plusieurs-couches.odt create mode 100644 tests/fixtures/partially-formatted-variable.odt 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/DOMUtils.js b/scripts/DOMUtils.js index fea84dd..5df3e65 100644 --- a/scripts/DOMUtils.js +++ b/scripts/DOMUtils.js @@ -24,7 +24,7 @@ export function serializeToString(node){ /** - * Traverses a DOM tree starting from the given element and applies the visit function + * Traverses a DOM tree starting from the given node and applies the visit function * to each Element node encountered in tree order (depth-first). * * This should probably be replace by the TreeWalker API when implemented by xmldom 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/fillOdtTemplate.js b/scripts/odf/templating/fillOdtElementTemplate.js similarity index 53% rename from scripts/odf/fillOdtTemplate.js rename to scripts/odf/templating/fillOdtElementTemplate.js index 0c4a744..fd6995f 100644 --- a/scripts/odf/fillOdtTemplate.js +++ b/scripts/odf/templating/fillOdtElementTemplate.js @@ -1,24 +1,5 @@ -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 +import {traverse, Node} from '../../DOMUtils.js' +import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js' /** * @typedef TextPlaceToFill @@ -27,14 +8,14 @@ const ODTMimetype = 'application/vnd.oasis.opendocument.text' */ - /** * @param {string} str * @param {Compartment} compartment * @returns {TextPlaceToFill | undefined} */ function findPlacesToFillInString(str, compartment) { - const matches = str.matchAll(/\{([^{#\/]+?)\}/g) + const varRexExp = new RegExp(variableRegex.source, 'g'); + const matches = str.matchAll(varRexExp) /** @type {TextPlaceToFill['expressions']} */ const expressions = [] @@ -43,17 +24,17 @@ function findPlacesToFillInString(str, compartment) { const parts = [] let remaining = str; - for (const match of matches) { + for(const match of matches) { //console.log('match', match) const [matched, group1] = match const replacedString = matched const expression = group1.trim() - expressions.push({ expression, replacedString }) + expressions.push({expression, replacedString}) const [fixedPart, newRemaining] = remaining.split(replacedString, 2) - if (fixedPart.length >= 1) + if(fixedPart.length >= 1) parts.push(fixedPart) parts.push(() => compartment.evaluate(expression)) @@ -61,13 +42,13 @@ function findPlacesToFillInString(str, compartment) { remaining = newRemaining } - if (remaining.length >= 1) + if(remaining.length >= 1) parts.push(remaining) //console.log('parts', parts) - if (remaining === str) { + if(remaining === str) { // no match found return undefined } @@ -76,7 +57,7 @@ function findPlacesToFillInString(str, compartment) { expressions, fill: (data) => { return parts.map(p => { - if (typeof p === 'string') + if(typeof p === 'string') return p else return p(data) @@ -103,7 +84,7 @@ function findPlacesToFillInString(str, compartment) { * @param {Node} blockEndNode * @returns {{startChild: Node, endChild:Node, content: DocumentFragment}} */ -function extractBlockContent(blockStartNode, blockEndNode){ +function extractBlockContent(blockStartNode, blockEndNode) { // find common ancestor of blockStartNode and blockEndNode let commonAncestor @@ -111,23 +92,23 @@ function extractBlockContent(blockStartNode, blockEndNode){ let endAncestor = blockEndNode const startAncestry = new Set([startAncestor]) - const endAncestry = new Set([endAncestor]) + const endAncestry = new Set([endAncestor]) - while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)){ - if(startAncestor.parentNode){ + while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)) { + if(startAncestor.parentNode) { startAncestor = startAncestor.parentNode startAncestry.add(startAncestor) } - if(endAncestor.parentNode){ + if(endAncestor.parentNode) { endAncestor = endAncestor.parentNode endAncestry.add(endAncestor) } } - if(startAncestry.has(endAncestor)){ + if(startAncestry.has(endAncestor)) { commonAncestor = endAncestor } - else{ + else { commonAncestor = startAncestor } @@ -144,12 +125,12 @@ function extractBlockContent(blockStartNode, blockEndNode){ const repeatedPatternArray = [] let sibling = startChild.nextSibling - while(sibling !== endChild){ + while(sibling !== endChild) { repeatedPatternArray.push(sibling) sibling = sibling.nextSibling; } - for(const sibling of repeatedPatternArray){ + for(const sibling of repeatedPatternArray) { sibling.parentNode?.removeChild(sibling) contentFragment.appendChild(sibling) } @@ -172,7 +153,7 @@ function extractBlockContent(blockStartNode, blockEndNode){ * @param {string} ifBlockConditionExpression * @param {Compartment} compartment */ -function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment){ +function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) { const conditionValue = compartment.evaluate(ifBlockConditionExpression) let startChild @@ -182,16 +163,16 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, let chosenFragment - if(ifElseMarkerNode){ + if(ifElseMarkerNode) { const { - startChild: startIfThenChild, - endChild: endIfThenChild, + startChild: startIfThenChild, + endChild: endIfThenChild, content: thenFragment } = extractBlockContent(ifOpeningMarkerNode, ifElseMarkerNode) const { - startChild: startIfElseChild, - endChild: endIfElseChild, + startChild: startIfElseChild, + endChild: endIfElseChild, content: elseFragment } = extractBlockContent(ifElseMarkerNode, ifClosingMarkerNode) @@ -203,10 +184,10 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, .add(startIfThenChild).add(endIfThenChild) .add(startIfElseChild).add(endIfElseChild) } - else{ + else { const { - startChild: startIfThenChild, - endChild: endIfThenChild, + startChild: startIfThenChild, + endChild: endIfThenChild, content: thenFragment } = extractBlockContent(ifOpeningMarkerNode, ifClosingMarkerNode) @@ -219,22 +200,22 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, } - if(chosenFragment){ - fillTemplatedOdtElement( - chosenFragment, + if(chosenFragment) { + fillOdtElementTemplate( + chosenFragment, compartment ) endChild.parentNode.insertBefore(chosenFragment, endChild) } - for(const markerNode of markerNodes){ - try{ + for(const markerNode of markerNodes) { + try { // may throw if node already out of tree // might happen if markerNode.parentNode.removeChild(markerNode) } - catch(e){} + catch(e) {} } } @@ -248,24 +229,24 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, * @param {Node} endNode * @param {Compartment} compartment */ -function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, 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'){ + 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){ + for(const item of iterable) { /** @type {DocumentFragment} */ // @ts-ignore const itemFragment = repeatedFragment.cloneNode(true) @@ -276,11 +257,11 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c }) // recursive call to fillTemplatedOdtElement on itemFragment - fillTemplatedOdtElement( - itemFragment, + fillOdtElementTemplate( + itemFragment, insideCompartment ) - + endChild.parentNode.insertBefore(itemFragment, endChild) } @@ -293,23 +274,13 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c 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){ +export default function fillOdtElementTemplate(rootElement, compartment) { //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) let currentlyOpenBlocks = [] @@ -337,25 +308,25 @@ function fillTemplatedOdtElement(rootElement, compartment){ //console.log('currentlyUnclosedBlocks', currentlyUnclosedBlocks) const insideAnOpenBlock = currentlyOpenBlocks.length >= 1 - if(currentNode.nodeType === Node.TEXT_NODE){ + if(currentNode.nodeType === Node.TEXT_NODE) { const text = currentNode.textContent || '' /** * looking for {#each x as y} - */ + */ const eachStartMatch = text.match(eachStartMarkerRegex); - if(eachStartMatch){ + if(eachStartMatch) { //console.log('startMatch', startMatch) currentlyOpenBlocks.push(EACH) - - if(insideAnOpenBlock){ + + if(insideAnOpenBlock) { // do nothing } - else{ + else { let [_, _iterableExpression, _itemExpression] = eachStartMatch - + eachBlockIterableExpression = _iterableExpression eachBlockItemExpression = _itemExpression eachOpeningMarkerNode = currentNode @@ -366,31 +337,31 @@ function fillTemplatedOdtElement(rootElement, compartment){ /** * Looking for {/each} */ - const isEachClosingBlock = text.includes(eachClosingBlockString) + const isEachClosingBlock = text.includes(eachClosingMarker) - if(isEachClosingBlock){ + 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){ + 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 + eachBlockItemExpression = undefined eachClosingMarkerNode = undefined } - else{ + else { // ignore because it will be treated as part of the outer {#each} } @@ -401,17 +372,17 @@ function fillTemplatedOdtElement(rootElement, compartment){ /** * Looking for {#if ...} */ - const ifStartMatch = text.match(ifStartRegex); + const ifStartMatch = text.match(ifStartMarkerRegex); - if(ifStartMatch){ + if(ifStartMatch) { currentlyOpenBlocks.push(IF) - - if(insideAnOpenBlock){ + + if(insideAnOpenBlock) { // do nothing because the marker is too deep } - else{ + else { let [_, _ifBlockConditionExpression] = ifStartMatch - + ifBlockConditionExpression = _ifBlockConditionExpression ifOpeningMarkerNode = currentNode } @@ -423,18 +394,18 @@ function fillTemplatedOdtElement(rootElement, compartment){ */ const hasElseMarker = text.includes(elseMarker); - if(hasElseMarker){ + if(hasElseMarker) { if(!insideAnOpenBlock) throw new Error('{:else} without a corresponding {#if}') - - if(currentlyOpenBlocks.length === 1){ - if(currentlyOpenBlocks[0] === 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{ + else { // do nothing because the marker is too deep } } @@ -445,12 +416,12 @@ function fillTemplatedOdtElement(rootElement, compartment){ */ const hasClosingMarker = text.includes(closingIfMarker); - if(hasClosingMarker){ + if(hasClosingMarker) { if(!insideAnOpenBlock) throw new Error('{/if} without a corresponding {#if}') - if(currentlyOpenBlocks.length === 1){ - if(currentlyOpenBlocks[0] === IF){ + if(currentlyOpenBlocks.length === 1) { + if(currentlyOpenBlocks[0] === IF) { ifClosingMarkerNode = currentNode // found an {#if} and its corresponding {/if} @@ -465,7 +436,7 @@ function fillTemplatedOdtElement(rootElement, compartment){ else throw new Error('{/if} inside an {#each} but without a corresponding {#if}') } - else{ + else { // do nothing because the marker is too deep } } @@ -474,13 +445,13 @@ function fillTemplatedOdtElement(rootElement, compartment){ /** * Looking for variables for substitutions */ - if(!insideAnOpenBlock){ + if(!insideAnOpenBlock) { // @ts-ignore - if (currentNode.data) { + if(currentNode.data) { // @ts-ignore const placesToFill = findPlacesToFillInString(currentNode.data, compartment) - if(placesToFill){ + if(placesToFill) { const newText = placesToFill.fill() // @ts-ignore const newTextNode = currentNode.ownerDocument?.createTextNode(newText) @@ -489,261 +460,27 @@ function fillTemplatedOdtElement(rootElement, compartment){ } } } - else{ + else { // ignore because it will be treated as part of the outer {#each} block } } - if(currentNode.nodeType === Node.ATTRIBUTE_NODE){ + if(currentNode.nodeType === Node.ATTRIBUTE_NODE) { // Looking for variables for substitutions - if(!insideAnOpenBlock){ + if(!insideAnOpenBlock) { // @ts-ignore - if (currentNode.value) { + if(currentNode.value) { // @ts-ignore const placesToFill = findPlacesToFillInString(currentNode.value, compartment) - if(placesToFill){ + if(placesToFill) { // @ts-ignore currentNode.value = placesToFill.fill() } } } - else{ + else { // ignore because it will be treated as part of the {#each} block } } }) } - - - -/** - * - * @param {Document} document - * @param {Compartment} compartment - * @returns {void} - */ -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(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 matchText; - let matchIndex; - - // 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 - - // 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/templating/fillOdtTemplate.js b/scripts/odf/templating/fillOdtTemplate.js new file mode 100644 index 0000000..6ca8047 --- /dev/null +++ b/scripts/odf/templating/fillOdtTemplate.js @@ -0,0 +1,166 @@ +import {ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter} from '@zip.js/zip.js'; + +import {parseXML, serializeToString} 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..1a09f1e --- /dev/null +++ b/scripts/odf/templating/markers.js @@ -0,0 +1,9 @@ +// the regexps below are shared, so they shoudn't have state (no 'g' flag) +export const variableRegex = /\{([^{#\/]+?)\}/ + +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..da3af78 --- /dev/null +++ b/scripts/odf/templating/prepareTemplateDOMTree.js @@ -0,0 +1,413 @@ +import {traverse, Node} from "../../DOMUtils.js"; +import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} 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; +} + +/** + * 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 */ + +/** + * remove nodes between startNode and endNode + * but keep startNode and endNode + * + * returns the common ancestor child in start branch + * for the purpose for inserting something between startNode and endNode + * with insertionPoint.parentNode.insertBefore(newBetweenContent, insertionPoint) + * + * @param {Node} startNode + * @param {Node} endNode + * @returns {Node} + */ +function removeNodesBetween(startNode, endNode) { + let nodesToRemove = new Set(); + + // find both ancestry branch + const startNodeAncestors = new Set(getAncestors(startNode)) + const endNodeAncestors = new Set(getAncestors(endNode)) + + // find common ancestor + const commonAncestor = findCommonAncestor(startNode, endNode) + + // remove everything "on the right" of start branch + let currentAncestor = startNode + let commonAncestorChildInEndNodeBranch + + while(currentAncestor !== commonAncestor){ + let siblingToRemove = currentAncestor.nextSibling + + while(siblingToRemove && !endNodeAncestors.has(siblingToRemove)){ + nodesToRemove.add(siblingToRemove) + siblingToRemove = siblingToRemove.nextSibling + } + if(endNodeAncestors.has(siblingToRemove)){ + commonAncestorChildInEndNodeBranch = siblingToRemove + } + + currentAncestor = currentAncestor.parentNode; + } + + // remove everything "on the left" of end branch + currentAncestor = endNode + + while(currentAncestor !== commonAncestor){ + let siblingToRemove = currentAncestor.previousSibling + + while(siblingToRemove && !startNodeAncestors.has(siblingToRemove)){ + nodesToRemove.add(siblingToRemove) + siblingToRemove = siblingToRemove.previousSibling + } + + currentAncestor = currentAncestor.parentNode; + } + + for(const node of nodesToRemove){ + node.parentNode.removeChild(node) + } + + return commonAncestorChildInEndNodeBranch +} + +/** + * Consolidate markers which are split among several Text nodes + * + * @param {Document} document + */ +function consolidateMarkers(document){ + // Perform a first pass to detect templating markers with formatting to remove it + const potentialMarkersContainers = [ + ...Array.from(document.getElementsByTagName('text:p')), + ...Array.from(document.getElementsByTagName('text:h')) + ] + + for(const potentialMarkersContainer of potentialMarkersContainers) { + const consolidatedMarkers = [] + + /** @type {Text[]} */ + let containerTextNodesInTreeOrder = []; + + function refreshContainerTextNodes(){ + containerTextNodesInTreeOrder = [] + + traverse(potentialMarkersContainer, node => { + if(node.nodeType === Node.TEXT_NODE) { + containerTextNodesInTreeOrder.push(/** @type {Text} */(node)) + } + }) + } + + refreshContainerTextNodes() + + let fullText = '' + for(const node of containerTextNodesInTreeOrder){ + 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), + ...findAllMatches(fullText, variableRegex) + ]; + + /*if(positionedMarkers.length >= 1) + console.log('positionedMarkers', positionedMarkers)*/ + + while(consolidatedMarkers.length < positionedMarkers.length) { + refreshContainerTextNodes() + + // For each marker, check if it's contained within a single text node + for(const positionedMarker of positionedMarkers.slice(consolidatedMarkers.length)) { + //console.log('positionedMarker', positionedMarker) + + let currentPos = 0; + let startNode; + let endNode; + + // 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(!startNode && positionedMarker.index >= nodeStart && positionedMarker.index < nodeEnd) { + startNode = textNode; + } + + // If end of marker is in this node + if(startNode && positionedMarker.index + positionedMarker.marker.length > nodeStart && + positionedMarker.index + positionedMarker.marker.length <= nodeEnd) { + endNode = textNode; + break; + } + + currentPos = nodeEnd; + } + + if(!startNode){ + throw new Error(`Could not find startNode for marker '${positionedMarker.marker}'`) + } + + if(!endNode){ + throw new Error(`Could not find endNode for marker '${positionedMarker.marker}'`) + } + + // Check if marker spans multiple nodes + if(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); + + /** @type {Node} */ + let beforeStartNode = startNode + + // if there is before-text, split + if(posInStartNode > 0) { + // Text exists before the marker - preserve it + + // set newStartNode to a Text node containing only the marker beginning + const newStartNode = startNode.splitText(posInStartNode) + // startNode/beforeStartNode now contains only non-marker text + + // then, by definition of .splitText(posInStartNode): + posInStartNode = 0 + + // remove the marker beginning part from the tree (since the marker will be inserted in full later) + newStartNode.parentNode?.removeChild(newStartNode) + } + + /** @type {Node} */ + let afterEndNode + + // if there is after-text, split + if(posInEndNode < endNodeTextContent.length) { + // Text exists after the marker - preserve it + + // set afterEndNode to a Text node containing only non-marker text + afterEndNode = endNode.splitText(posInEndNode); + // endNode now contains only the end of marker text + + // then, by definition of .splitText(posInEndNode): + posInEndNode = endNodeTextContent.length + + // remove the marker ending part from the tree (since the marker will be inserted in full later) + endNode.parentNode?.removeChild(endNode) + } + + // then, replace all nodes between (new)startNode and (new)endNode with a single textNode in commonAncestor + const insertionPoint = removeNodesBetween(beforeStartNode, afterEndNode) + const markerTextNode = insertionPoint.ownerDocument.createTextNode(positionedMarker.marker) + + insertionPoint.parentNode.insertBefore(markerTextNode, insertionPoint) + + // After consolidation, break as the DOM structure has changed + // and containerTextNodesInTreeOrder needs to be refreshed + consolidatedMarkers.push(positionedMarker) + break; + } + + consolidatedMarkers.push(positionedMarker) + } + } + } +} + +/** + * isolate markers which are in Text nodes with other texts + * + * @param {Document} document + */ +function isolateMarkers(document){ + 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 + } + }) +} + +/** + * 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: + * + * each template marker ({#each ... as ...}, {/if}, etc.) placed within a single Text node + * + * If the template marker was partially formatted in the original document, the formatting is removed so the + * marker can be within a single Text node + * + * If the template marker was in a Text node with other text, the Text node is split in a way to isolate the marker + * from the rest of the text + * + * @param {Document} document + */ +export default function prepareTemplateDOMTree(document){ + consolidateMarkers(document) + isolateMarkers(document) +} \ No newline at end of file diff --git a/tests/fill-odt-template/formatting.js b/tests/fill-odt-template/formatting.js new file mode 100644 index 0000000..fda17fc --- /dev/null +++ b/tests/fill-odt-template/formatting.js @@ -0,0 +1,141 @@ +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 with several layers of formatting in {#each ...} start marker', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-plusieurs-couches.odt') + const templateContent = `Liste de nombres + +Les nombres : {#each nombres as n}{n} {/each} !! +` + + const data = { + nombres : [1,2,3,5] + } + + 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 2 3 5  !! +`) + +}); + + +test('template filling - both {#each ...} and {/each} within the same Text node are formatted', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-2-markeurs-formatted.odt') + const templateContent = `Liste de nombres + +Les nombres : {#each nombres as n}{n} {/each} !! +` + + const data = { + nombres : [2,3,5,8] + } + + 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 : 2 3 5 8  !! +`) + +}); + + +test('template filling - {#each ...} and text before partially formatted', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt') + const templateContent = `Liste de nombres + +Les nombres : {#each nombres as n}{n} {/each} !! +` + + const data = { + nombres : [3,5,8, 13] + } + + 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 : 3 5 8 13  !! +`) + +}); + + + +test('template filling - {/each} and text after partially formatted', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt') + const templateContent = `Liste de nombres + +Les nombres : {#each nombres as n}{n} {/each} !! +` + + const data = { + nombres : [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 : 5 8 13 21  !! +`) + +}); + + + + +test('template filling - partially formatted variable', async t => { + const templatePath = join(import.meta.dirname, '../fixtures/partially-formatted-variable.odt') + const templateContent = `Nombre + +Voici le nombre : {nombre} !!! +` + + const data = {nombre : 37} + + 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, `Nombre + +Voici le nombre : 37 !!! +`) + +}); \ No newline at end of file diff --git a/tests/fixtures/formatting-liste-nombres-2-markeurs-formatted.odt b/tests/fixtures/formatting-liste-nombres-2-markeurs-formatted.odt new file mode 100644 index 0000000000000000000000000000000000000000..0ff9cceab9bae91c3844a05fcce1d2350c5eacd9 GIT binary patch literal 12565 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 jsJ}*!rQz4K;f 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 UfqC}S?d9VK^yqY|DWAUm54n6dtpET3 literal 0 HcmV?d00001 diff --git a/tests/fixtures/partially-formatted-variable.odt b/tests/fixtures/partially-formatted-variable.odt new file mode 100644 index 0000000000000000000000000000000000000000..cedf874ce8738de6acb03581a9d1c75f5ad901eb GIT binary patch literal 11685 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 literal 0 HcmV?d00001