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