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 0000000..0ff9cce Binary files /dev/null and b/tests/fixtures/formatting-liste-nombres-2-markeurs-formatted.odt differ diff --git a/tests/fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt b/tests/fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt new file mode 100644 index 0000000..98878a9 Binary files /dev/null and b/tests/fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt differ diff --git a/tests/fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt b/tests/fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt new file mode 100644 index 0000000..43a5e9b Binary files /dev/null and b/tests/fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt differ diff --git a/tests/fixtures/formatting-liste-nombres-plusieurs-couches.odt b/tests/fixtures/formatting-liste-nombres-plusieurs-couches.odt new file mode 100644 index 0000000..9b96798 Binary files /dev/null and b/tests/fixtures/formatting-liste-nombres-plusieurs-couches.odt differ diff --git a/tests/fixtures/partially-formatted-variable.odt b/tests/fixtures/partially-formatted-variable.odt new file mode 100644 index 0000000..cedf874 Binary files /dev/null and b/tests/fixtures/partially-formatted-variable.odt differ