Templ odt (#8)

* add rough roadmap

* adding test of odt template filling

* progress

* avec les titres

* premier jet de remplacement

* progress i guess

* Bimz ! Remplissage de template et passage de tests !

* nettoyages

* zip entries async generator

* un fichier d'exemple pour la génération d'une liste de courses

* Test avec le each et amélioration de getOdtTextContent

* yep

* Le test du each passe mais pas encore la création du fichier .odt

* Meilleur packaging du zip

* Création d'un fichier à partir d'un template - le fichier de sortie s'ouvre avec LibreOffice !!

* Génération d'une liste dans un .odt

* 2 sibling each in a document

* add nested each test

* Génération d'un tableau avec un {#each}

* Refacto API for Node.js

* add fillOdtTemplate to browser exports

* Mention template filling in  readme
This commit is contained in:
David Bruant 2025-04-07 11:00:18 +02:00 committed by GitHub
parent 86a0b8ea49
commit 57dfb4f050
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1071 additions and 19 deletions

17
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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 <input type=file>
* @param {ArrayBuffer} odsFile - content of an .ods file
* @return {Promise<any[]>}
*/
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

23
scripts/DOMUtils.js Normal file
View File

@ -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);
}

View File

@ -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<ODTFile>}
*/
export function fillOdtTemplate(odtTemplate, data){
return _fillOdtTemplate(odtTemplate, data, parseXML, serializeToString, Node)
}
/**
* @param {Map<SheetName, SheetRawContent>} sheetsData
*/

View File

@ -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"),

View File

@ -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<ODTFile>}
*/
export function fillOdtTemplate(odtTemplate, data){
return _fillOdtTemplate(odtTemplate, data, parseXML, serializeToString, Node)
}
/**
* @param {Map<SheetName, SheetRawContent>} sheetsData
*/

View File

@ -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<ODTFile>}
*/
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();
}

View File

@ -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 `<manifest:file-entry manifest:full-path="${fullPath}" manifest:media-type="${mediaType}"/>`
}
/**
*
* @param {ODFManifest} odfManifest
* @returns {string}
*/
export default function makeManifestFile({fileEntries, mediaType, version}){
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="${version}">
<manifest:file-entry manifest:full-path="/" manifest:version="${version}" manifest:media-type="${mediaType}"/>
${fileEntries.map(makeFileEntry).join('\n')}
</manifest:manifest>`
}

View File

@ -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<ODTFile>}
*/
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<string>} 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<Document>}
*/
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');
}

BIN
tests/data/enum-courses.odt Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

314
tests/fill-odt-template.js Normal file
View File

@ -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 .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 .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())
});

View File

@ -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))