diff --git a/package-lock.json b/package-lock.json index 0982f14..495bd81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "ods-xlsx", "version": "0.11.0", "dependencies": { - "@xmldom/xmldom": "^0.8.10", + "@xmldom/xmldom": "^0.9.8", "@zip.js/zip.js": "^2.7.57" }, "devDependencies": { @@ -604,11 +604,12 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", + "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==", + "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=14.6" } }, "node_modules/@zip.js/zip.js": { @@ -4921,9 +4922,9 @@ } }, "@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==" + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", + "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==" }, "@zip.js/zip.js": { "version": "2.7.57", diff --git a/package.json b/package.json index f2de6aa..c52e9ae 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "svelte-preprocess": "^5.1.3" }, "dependencies": { - "@xmldom/xmldom": "^0.8.10", + "@xmldom/xmldom": "^0.9.8", "@zip.js/zip.js": "^2.7.57" } } diff --git a/readme.md b/readme.md index 3db1cb2..2b3b29e 100644 --- a/readme.md +++ b/readme.md @@ -3,6 +3,16 @@ Small lib to parse/understand .ods and .xsls files in the browser and node.js +## Rough roadmap + +- [ ] add odt templating +- [ ] remove support for xlsx +- [ ] add a .ods minifyer +- [ ] add a generic .ods visualizer +- [ ] move to a dedicated odf docs org +- [ ] add a quick .odt visualiser (maybe converting to markdown first?) + + ## Usage ### Install @@ -12,19 +22,17 @@ npm i https://github.com/DavidBruant/ods-xlsx.git#v0.11.0 ``` -### Usage - -#### Basic - reading an ods/xlsx file +### Basic - reading an ods/xlsx file ```js import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent} from 'ods-xlsx' /** - * @param {File} file - an .ods file like the ones you get from an + * @param {ArrayBuffer} odsFile - content of an .ods file * @return {Promise} */ -async function getFileData(file){ - return tableRawContent +async function getFileData(odsFile){ + return getODSTableRawContent(odsFile) .then(tableWithoutEmptyRows) .then(tableRawContentToObjects) } @@ -36,7 +44,7 @@ the **values** are automatically converted from the .ods or .xlsx files (which t to the appropriate JavaScript value -#### Basic - creating an ods file +### Basic - creating an ods file ```js import {createOdsFile} from 'ods-xlsx' @@ -72,9 +80,51 @@ const ods = await createOdsFile(content) (and there is a tool to test file creation: `node tools/create-an-ods-file.js > yo.ods`) -#### Low-level -See exports +### Filling an .odt template + +odf.js proposes a template syntax + +In an .odt file, write the following: + +```txt +Hey {nom}! + +Your birthdate is {dateNaissance} +``` + +And then run the code: + + +```js +import {join} from 'node:path'; + +import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js' +import {fillOdtTemplate} from '../scripts/node.js' + +// replace with your template path +const templatePath = join(import.meta.dirname, './tests/data/template-anniversaire.odt') +const data = { + nom: 'David Bruant', + dateNaissance: '8 mars 1987' +} + +const odtTemplate = await getOdtTemplate(templatePath) +const odtResult = await fillOdtTemplate(odtTemplate, data) + +process.stdout.write(new Uint8Array(odtResult)) +``` + +There are also loops in the form: + +```txt +- {#each listeCourses as élément} +- {élément} +- {/each} +``` + +They can be used to generate lists or tables in .odt files from data and a template using this syntax + ### Demo @@ -89,6 +139,8 @@ npm run dev ``` + + ## Expectations and licence I hope to be credited for the work on this repo diff --git a/scripts/DOMUtils.js b/scripts/DOMUtils.js new file mode 100644 index 0000000..ff0b1d6 --- /dev/null +++ b/scripts/DOMUtils.js @@ -0,0 +1,23 @@ +/* + Since we're using xmldom in Node.js context, the entire DOM API is not implemented + Functions here are helpers whild xmldom becomes more complete +*/ + +/** + * Traverses a DOM tree starting from the given element 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 + * + * @param {Node} node + * @param {(n : Node) => void} visit + */ +export function traverse(node, visit) { + //console.log('traverse', node.nodeType, node.nodeName) + + for (const child of Array.from(node.childNodes)) { + traverse(child, visit); + } + + visit(node); +} diff --git a/scripts/browser.js b/scripts/browser.js index b2e5a77..dc6e03e 100644 --- a/scripts/browser.js +++ b/scripts/browser.js @@ -7,7 +7,11 @@ import { import {_createOdsFile} from './createOdsFile.js' +import _fillOdtTemplate from './odf/fillOdtTemplate.js' + + /** @import {SheetCellRawContent, SheetName, SheetRawContent} from './types.js' */ +/** @import {ODTFile} from './odf/fillOdtTemplate.js' */ function parseXML(str){ @@ -44,6 +48,16 @@ const serializeToString = function serializeToString(node){ return serializer.serializeToString(node) } +/** + * @param {ODTFile} odtTemplate + * @param {any} data + * @returns {Promise} + */ +export function fillOdtTemplate(odtTemplate, data){ + return _fillOdtTemplate(odtTemplate, data, parseXML, serializeToString, Node) +} + + /** * @param {Map} sheetsData */ diff --git a/scripts/createOdsFile.js b/scripts/createOdsFile.js index 5d8a96c..cbd9170 100644 --- a/scripts/createOdsFile.js +++ b/scripts/createOdsFile.js @@ -30,6 +30,9 @@ export async function _createOdsFile(sheetsData, createDocument, serializeToStri // Create a new zip writer const zipWriter = new ZipWriter(new BlobWriter('application/vnd.oasis.opendocument.spreadsheet')); + // The “mimetype” file shall be the first file of the zip file. + // It shall not be compressed, and it shall not use an 'extra field' in its header. + // https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part2-packages/OpenDocument-v1.3-os-part2-packages.html#__RefHeading__752809_826425813 zipWriter.add( "mimetype", new TextReader("application/vnd.oasis.opendocument.spreadsheet"), diff --git a/scripts/node.js b/scripts/node.js index 5096c40..ea0dce6 100644 --- a/scripts/node.js +++ b/scripts/node.js @@ -1,6 +1,6 @@ //@ts-check -import {DOMParser, DOMImplementation, XMLSerializer} from '@xmldom/xmldom' +import {DOMParser, DOMImplementation, XMLSerializer, Node} from '@xmldom/xmldom' import { _getODSTableRawContent, @@ -8,9 +8,17 @@ import { } from './shared.js' import { _createOdsFile } from './createOdsFile.js' +import _fillOdtTemplate from './odf/fillOdtTemplate.js' + /** @import {SheetCellRawContent, SheetName, SheetRawContent} from './types.js' */ +/** @import {ODTFile} from './odf/fillOdtTemplate.js' */ +/** + * + * @param {string} str + * @returns {Document} + */ function parseXML(str){ return (new DOMParser()).parseFromString(str, 'application/xml'); } @@ -47,6 +55,16 @@ const serializeToString = function serializeToString(node){ return serializer.serializeToString(node) } +/** + * @param {ODTFile} odtTemplate + * @param {any} data + * @returns {Promise} + */ +export function fillOdtTemplate(odtTemplate, data){ + return _fillOdtTemplate(odtTemplate, data, parseXML, serializeToString, Node) +} + + /** * @param {Map} sheetsData */ diff --git a/scripts/odf/fillOdtTemplate.js b/scripts/odf/fillOdtTemplate.js new file mode 100644 index 0000000..a861d22 --- /dev/null +++ b/scripts/odf/fillOdtTemplate.js @@ -0,0 +1,439 @@ +import { ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter } from '@zip.js/zip.js'; + +import {traverse} from '../DOMUtils.js' +import makeManifestFile from './makeManifestFile.js'; + +// fillOdtTemplate, getOdtTemplate, getOdtTextContent + +/** @import {ODFManifest} from './makeManifestFile.js' */ + +/** @typedef {ArrayBuffer} ODTFile */ + +const ODTMimetype = 'application/vnd.oasis.opendocument.text' + + + + +// For a given string, split it into fixed parts and parts to replace + +/** + * @typedef TextPlaceToFill + * @property { {expression: string, replacedString:string}[] } expressions + * @property {(values: any) => 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 evaludateTemplateExpression(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 + * @returns {TextPlaceToFill | undefined} + */ +function findPlacesToFillInString(str) { + const matches = str.matchAll(/\{([^{#\/]+?)\}/g) + + /** @type {TextPlaceToFill['expressions']} */ + const expressions = [] + + /** @type {(string | ((data:any) => void))[]} */ + const parts = [] + let remaining = str; + + for (const match of matches) { + //console.log('match', match) + const [matched, group1] = match + + const replacedString = matched + const expression = group1.trim() + expressions.push({ expression, replacedString }) + + const [fixedPart, newRemaining] = remaining.split(replacedString, 2) + + if (fixedPart.length >= 1) + parts.push(fixedPart) + + + parts.push(data => evaludateTemplateExpression(expression, data)) + + remaining = newRemaining + } + + if (remaining.length >= 1) + parts.push(remaining) + + //console.log('parts', parts) + + + if (remaining === str) { + // no match found + return undefined + } + else { + return { + expressions, + fill: (data) => { + return parts.map(p => { + if (typeof p === 'string') + return p + else + return p(data) + }) + .join('') + } + } + } + + +} + + + +/** + * + * @param {Node} startNode + * @param {string} iterableExpression + * @param {string} itemExpression + * @param {Node} endNode + * @param {any} data + * @param {typeof Node} Node + */ +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 + let commonAncestor + + let startAncestor = startNode + let endAncestor = endNode + + const startAncestry = new Set([startAncestor]) + const endAncestry = new Set([endAncestor]) + + while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)){ + if(startAncestor.parentNode){ + startAncestor = startAncestor.parentNode + startAncestry.add(startAncestor) + } + if(endAncestor.parentNode){ + endAncestor = endAncestor.parentNode + endAncestry.add(endAncestor) + } + } + + if(startAncestry.has(endAncestor)){ + commonAncestor = endAncestor + } + else{ + 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() + + /** @type {Element[]} */ + const repeatedPatternArray = [] + let sibling = startChild.nextSibling + + while(sibling !== endChild){ + repeatedPatternArray.push(sibling) + sibling = sibling.nextSibling; + } + + + //console.log('repeatedPatternArray', repeatedPatternArray.length) + + for(const sibling of repeatedPatternArray){ + sibling.parentNode?.removeChild(sibling) + repeatedFragment.appendChild(sibling) + } + + // Find the iterable in the data + // PPP eventually, evaluate the expression as a JS expression + const iterable = evaludateTemplateExpression(iterableExpression, data) + if(!iterable){ + throw new TypeError(`Missing iterable (${iterableExpression})`) + } + if(typeof iterable[Symbol.iterator] !== 'function'){ + throw new TypeError(`'${iterableExpression}' is not iterable`) + } + + // create each loop result + // using a for-of loop to accept all iterable values + for(const item of iterable){ + /** @type {DocumentFragment} */ + // @ts-ignore + const itemFragment = repeatedFragment.cloneNode(true) + + // recursive call to fillTemplatedOdtElement on itemFragment + fillTemplatedOdtElement( + itemFragment, + Object.assign({}, data, {[itemExpression]: item}), + Node + ) + // @ts-ignore + commonAncestor.insertBefore(itemFragment, endChild) + } + + startChild.parentNode.removeChild(startChild) + endChild.parentNode.removeChild(endChild) +} + + +/** + * + * @param {Element | DocumentFragment} rootElement + * @param {any} data + * @param {typeof Node} Node + * @returns {void} + */ +function fillTemplatedOdtElement(rootElement, data, Node){ + //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) + + /** @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*}/g; + const startMatches = [...text.matchAll(eachStartRegex)]; + + if(startMatches && startMatches.length >= 1){ + if(insideAnEachBlock){ + nestedEach = nestedEach + 1 + } + else{ + // PPP for now, consider only the first set of matches + // eventually, consider all of them for in-text-node {#each}...{/each} + let [_, _iterableExpression, _itemExpression] = startMatches[0] + + iterableExpression = _iterableExpression + itemExpression = _itemExpression + eachBlockStartNode = currentNode + } + } + + // trying to find an {/each} + const eachEndRegex = /{\/each}/g + const endMatches = [...text.matchAll(eachEndRegex)]; + + if(endMatches && endMatches.length >= 1){ + 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 + } + } + }) +} + + + +/** + * @param {ODTFile} odtTemplate + * @param {any} data + * @param {Function} parseXML + * @param {typeof XMLSerializer.prototype.serializeToString} serializeToString + * @param {typeof Node} Node + * @returns {Promise} + */ +export default async function _fillOdtTemplate(odtTemplate, data, parseXML, serializeToString, Node) { + + 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} */ + const manifestFileData = { + mediaType: ODTMimetype, + version: '1.3', // default, but may be changed + fileEntries: [] + } + + const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype']) + + + // Parcourir chaque entrée du fichier ODT + for await (const entry of entries) { + const filename = entry.filename + + // remove other files + if(!keptFiles.has(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, + } + break; + case 'content.xml': + const contentXml = await entry.getData(new TextWriter()); + const contentDocument = parseXML(contentXml); + fillTemplatedOdtElement(contentDocument, data, Node) + const updatedContentXml = serializeToString(contentDocument) + + const docContentElement = contentDocument.getElementsByTagName('office:document-content')[0] + const version = docContentElement.getAttribute('office:version') + + //console.log('version', version) + manifestFileData.version = version + manifestFileData.fileEntries.push({ + fullPath: filename, + mediaType: 'text/xml' + }) + + content = new TextReader(updatedContentXml) + options = { + lastModDate: entry.lastModDate, + level: 9 + }; + + break; + case 'styles.xml': + const blobWriter = new BlobWriter(); + await entry.getData(blobWriter); + const blob = await blobWriter.getData(); + + manifestFileData.fileEntries.push({ + fullPath: filename, + mediaType: 'text/xml' + }) + + content = new BlobReader(blob) + break; + default: + throw new Error(`Unexpected file (${filename})`) + } + + await writer.add(filename, content, options); + } + + } + + 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/makeManifestFile.js b/scripts/odf/makeManifestFile.js new file mode 100644 index 0000000..bac5bdc --- /dev/null +++ b/scripts/odf/makeManifestFile.js @@ -0,0 +1,44 @@ + +/* + As specified by https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part2-packages/OpenDocument-v1.3-os-part2-packages.html#__RefHeading__752825_826425813 +*/ + +/** @typedef {'application/vnd.oasis.opendocument.text' | 'application/vnd.oasis.opendocument.spreadsheet'} ODFMediaType */ + +/** @typedef {'1.2' | '1.3' | '1.4'} ODFVersion */ + +/** + * @typedef ODFManifestFileEntry + * @prop {string} fullPath + * @prop {string} mediaType + * @prop {string} [version] + */ + +/** + * @typedef ODFManifest + * @prop {ODFMediaType} mediaType + * @prop {ODFVersion} version + * @prop {ODFManifestFileEntry[]} fileEntries + */ + +/** + * + * @param {ODFManifestFileEntry} fileEntry + * @returns {string} + */ +function makeFileEntry({fullPath, mediaType}){ + return `` +} + +/** + * + * @param {ODFManifest} odfManifest + * @returns {string} + */ +export default function makeManifestFile({fileEntries, mediaType, version}){ + return ` + + + ${fileEntries.map(makeFileEntry).join('\n')} +` +} \ No newline at end of file diff --git a/scripts/odf/odtTemplate-forNode.js b/scripts/odf/odtTemplate-forNode.js new file mode 100644 index 0000000..e1f19f2 --- /dev/null +++ b/scripts/odf/odtTemplate-forNode.js @@ -0,0 +1,87 @@ +import { readFile } from 'node:fs/promises' + +import { ZipReader, Uint8ArrayReader, TextWriter } from '@zip.js/zip.js'; +import {DOMParser, Node} from '@xmldom/xmldom' + + +/** @import {ODTFile} from './fillOdtTemplate.js' */ + + +/** + * + * @param {Document} odtDocument + * @returns {Element} + */ +function getODTTextElement(odtDocument) { + return odtDocument.getElementsByTagName('office:body')[0] + .getElementsByTagName('office:text')[0] +} + + +/** + * + * @param {string} path + * @returns {Promise} + */ +export async function getOdtTemplate(path) { + const fileBuffer = await readFile(path) + return fileBuffer.buffer +} + +/** + * Extracts plain text content from an ODT file, preserving line breaks + * @param {ArrayBuffer} odtFile - The ODT file as an ArrayBuffer + * @returns {Promise} Extracted text content + */ +export async function getOdtTextContent(odtFile) { + const contentDocument = await getContentDocument(odtFile) + const odtTextElement = getODTTextElement(contentDocument) + + /** + * + * @param {Element} element + * @returns {string} + */ + function getElementTextContent(element){ + //console.log('tagName', element.tagName) + if(element.tagName === 'text:h' || element.tagName === 'text:p') + return element.textContent + '\n' + else{ + const descendantTexts = Array.from(element.childNodes) + .filter(n => n.nodeType === Node.ELEMENT_NODE) + .map(getElementTextContent) + + if(element.tagName === 'text:list-item') + return `- ${descendantTexts.join('')}` + + return descendantTexts.join('') + } + } + + return getElementTextContent(odtTextElement) +} + + +/** + * @param {ODTFile} odtFile + * @returns {Promise} + */ +async function getContentDocument(odtFile) { + const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtFile))); + + const entries = await reader.getEntries(); + + const contentEntry = entries.find(entry => entry.filename === 'content.xml'); + + if (!contentEntry) { + throw new Error('No content.xml found in the ODT file'); + } + + // @ts-ignore + const contentText = await contentEntry.getData(new TextWriter()); + await reader.close(); + + const parser = new DOMParser(); + + return parser.parseFromString(contentText, 'text/xml'); +} \ No newline at end of file diff --git a/tests/data/enum-courses.odt b/tests/data/enum-courses.odt new file mode 100644 index 0000000..f218dff Binary files /dev/null and b/tests/data/enum-courses.odt differ diff --git a/tests/data/liste-courses.odt b/tests/data/liste-courses.odt new file mode 100644 index 0000000..a3365c3 Binary files /dev/null and b/tests/data/liste-courses.odt differ diff --git a/tests/data/liste-fruits-et-légumes.odt b/tests/data/liste-fruits-et-légumes.odt new file mode 100644 index 0000000..ac5bdeb Binary files /dev/null and b/tests/data/liste-fruits-et-légumes.odt differ diff --git a/tests/data/légumes-de-saison.odt b/tests/data/légumes-de-saison.odt new file mode 100644 index 0000000..d56d2cc Binary files /dev/null and b/tests/data/légumes-de-saison.odt differ diff --git a/tests/data/tableau-simple.odt b/tests/data/tableau-simple.odt new file mode 100644 index 0000000..e7d5d76 Binary files /dev/null and b/tests/data/tableau-simple.odt differ diff --git a/tests/data/template-anniversaire.odt b/tests/data/template-anniversaire.odt new file mode 100644 index 0000000..3f0b824 Binary files /dev/null and b/tests/data/template-anniversaire.odt differ diff --git a/tests/fill-odt-template.js b/tests/fill-odt-template.js new file mode 100644 index 0000000..f2bce51 --- /dev/null +++ b/tests/fill-odt-template.js @@ -0,0 +1,314 @@ +import test from 'ava'; +import {join} from 'node:path'; + +import {getOdtTemplate, getOdtTextContent} from '../scripts/odf/odtTemplate-forNode.js' + +import {fillOdtTemplate} from '../scripts/node.js' + + +test('basic template filling with variable substitution', async t => { + const templatePath = join(import.meta.dirname, './data/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 ☀️ +`) + +}); + + + +test('basic template filling with {#each}', async t => { + const templatePath = join(import.meta.dirname, './data/enum-courses.odt') + const templateContent = `🧺 La liste de courses incroyable 🧺 + +{#each listeCourses as élément} +{élément} +{/each} +` + + const data = { + listeCourses : [ + 'Radis', + `Jus d'orange`, + 'Pâtes à lasagne (fraîches !)' + ] + } + + 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, `🧺 La liste de courses incroyable 🧺 + +Radis +Jus d'orange +Pâtes à lasagne (fraîches !) +`) + + +}); + + + +test('template filling with {#each} generating a list', async t => { + const templatePath = join(import.meta.dirname, './data/liste-courses.odt') + const templateContent = `🧺 La liste de courses incroyable 🧺 + +- {#each listeCourses as élément} +- {élément} +- {/each} +` + + const data = { + listeCourses : [ + 'Radis', + `Jus d'orange`, + 'Pâtes à lasagne (fraîches !)' + ] + } + + 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, `🧺 La liste de courses incroyable 🧺 + +- Radis +- Jus d'orange +- Pâtes à lasagne (fraîches !) +`) + + +}); + + +test('template filling with 2 sequential {#each}', async t => { + const templatePath = join(import.meta.dirname, './data/liste-fruits-et-légumes.odt') + const templateContent = `Liste de fruits et légumes + +Fruits +{#each fruits as fruit} +{fruit} +{/each} + +Légumes +{#each légumes as légume} +{légume} +{/each} +` + + const data = { + fruits : [ + 'Pastèque 🍉', + `Kiwi 🥝`, + 'Banane 🍌' + ], + légumes: [ + 'Champignon 🍄‍🟫', + 'Avocat 🥑', + 'Poivron 🫑' + ] + } + + 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 fruits et légumes + +Fruits +Pastèque 🍉 +Kiwi 🥝 +Banane 🍌 + +Légumes +Champignon 🍄‍🟫 +Avocat 🥑 +Poivron 🫑 +`) + +}); + + + +test('template filling with nested {#each}s', async t => { + const templatePath = join(import.meta.dirname, './data/légumes-de-saison.odt') + const templateContent = `Légumes de saison + +{#each légumesSaison as saisonLégumes} +{saisonLégumes.saison} +- {#each saisonLégumes.légumes as légume} +- {légume} +- {/each} + +{/each} +` + + const data = { + légumesSaison : [ + { + saison: 'Printemps', + légumes: [ + 'Asperge', + 'Betterave', + 'Blette' + ] + }, + { + saison: 'Été', + légumes: [ + 'Courgette', + 'Poivron', + 'Laitue' + ] + }, + { + saison: 'Automne', + légumes: [ + 'Poireau', + 'Potiron', + 'Brocoli' + ] + }, + { + saison: 'Hiver', + légumes: [ + 'Radis', + 'Chou de Bruxelles', + 'Frisée' + ] + } + ] + } + + const odtTemplate = await getOdtTemplate(templatePath) + + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template') + + const odtResult = await fillOdtTemplate(odtTemplate, data) + + const odtResultTextContent = await getOdtTextContent(odtResult) + t.deepEqual(odtResultTextContent, `Légumes de saison + +Printemps +- Asperge +- Betterave +- Blette + +Été +- Courgette +- Poivron +- Laitue + +Automne +- Poireau +- Potiron +- Brocoli + +Hiver +- Radis +- Chou de Bruxelles +- Frisée + +`) + +}); + + + +test('template filling of a table', async t => { + const templatePath = join(import.meta.dirname, './data/tableau-simple.odt') + const templateContent = `Évolution énergie en kWh par personne en France + +Année +Énergie par personne +{#each annéeConsos as annéeConso} + +{annéeConso.année} +{annéeConso.conso} +{/each} +` + + /* + Data sources: + + U.S. Energy Information Administration (2023)Energy Institute - + Statistical Review of World Energy (2024)Population based on various sources (2023) + + – with major processing by Our World in Data + */ + const data = { + annéeConsos : [ + { année: 1970, conso: 36252.637}, + { année: 1980, conso: 43328.78}, + { année: 1990, conso: 46971.94}, + { année: 2000, conso: 53147.277}, + { année: 2010, conso: 48062.32}, + { année: 2020, conso: 37859.246}, + ] + } + + const odtTemplate = await getOdtTemplate(templatePath) + + const templateTextContent = await getOdtTextContent(odtTemplate) + t.deepEqual(templateTextContent.trim(), templateContent.trim(), 'reconnaissance du template') + + const odtResult = await fillOdtTemplate(odtTemplate, data) + + const odtResultTextContent = await getOdtTextContent(odtResult) + t.deepEqual(odtResultTextContent.trim(), `Évolution énergie en kWh par personne en France + +Année +Énergie par personne +1970 +36252.637 +1980 +43328.78 +1990 +46971.94 +2000 +53147.277 +2010 +48062.32 +2020 +37859.246 +`.trim()) + +}); + + + diff --git a/tools/create-odt-file-from-template.js b/tools/create-odt-file-from-template.js new file mode 100644 index 0000000..89e474d --- /dev/null +++ b/tools/create-odt-file-from-template.js @@ -0,0 +1,57 @@ +import {join} from 'node:path'; + +import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js' +import {fillOdtTemplate} from '../scripts/node.js' + +/* +const templatePath = join(import.meta.dirname, '../tests/data/template-anniversaire.odt') +const data = { + nom: 'David Bruant', + dateNaissance: '8 mars 1987' +} +*/ + + +/* +const templatePath = join(import.meta.dirname, '../tests/data/liste-courses.odt') +const data = { + listeCourses : [ + 'Radis', + `Jus d'orange`, + 'Pâtes à lasagne (fraîches !)' + ] +} +*/ + +/* +const templatePath = join(import.meta.dirname, '../tests/data/liste-fruits-et-légumes.odt') +const data = { + fruits : [ + 'Pastèque 🍉', + `Kiwi 🥝`, + 'Banane 🍌' + ], + légumes: [ + 'Champignon 🍄‍🟫', + 'Avocat 🥑', + 'Poivron 🫑' + ] +}*/ + +const templatePath = join(import.meta.dirname, '../tests/data/tableau-simple.odt') +const data = { + annéeConsos : [ + { année: 1970, conso: 36252.637}, + { année: 1980, conso: 43328.78}, + { année: 1990, conso: 46971.94}, + { année: 2000, conso: 53147.277}, + { année: 2010, conso: 48062.32}, + { année: 2020, conso: 37859.246}, + ] +} + + +const odtTemplate = await getOdtTemplate(templatePath) +const odtResult = await fillOdtTemplate(odtTemplate, data) + +process.stdout.write(new Uint8Array(odtResult)) \ No newline at end of file