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:
parent
86a0b8ea49
commit
57dfb4f050
17
package-lock.json
generated
17
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
70
readme.md
70
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 <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
23
scripts/DOMUtils.js
Normal 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);
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
439
scripts/odf/fillOdtTemplate.js
Normal file
439
scripts/odf/fillOdtTemplate.js
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
44
scripts/odf/makeManifestFile.js
Normal file
44
scripts/odf/makeManifestFile.js
Normal 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>`
|
||||
}
|
||||
87
scripts/odf/odtTemplate-forNode.js
Normal file
87
scripts/odf/odtTemplate-forNode.js
Normal 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
BIN
tests/data/enum-courses.odt
Normal file
Binary file not shown.
BIN
tests/data/liste-courses.odt
Normal file
BIN
tests/data/liste-courses.odt
Normal file
Binary file not shown.
BIN
tests/data/liste-fruits-et-légumes.odt
Normal file
BIN
tests/data/liste-fruits-et-légumes.odt
Normal file
Binary file not shown.
BIN
tests/data/légumes-de-saison.odt
Normal file
BIN
tests/data/légumes-de-saison.odt
Normal file
Binary file not shown.
BIN
tests/data/tableau-simple.odt
Normal file
BIN
tests/data/tableau-simple.odt
Normal file
Binary file not shown.
BIN
tests/data/template-anniversaire.odt
Normal file
BIN
tests/data/template-anniversaire.odt
Normal file
Binary file not shown.
314
tests/fill-odt-template.js
Normal file
314
tests/fill-odt-template.js
Normal 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 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())
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
57
tools/create-odt-file-from-template.js
Normal file
57
tools/create-odt-file-from-template.js
Normal 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))
|
||||
Loading…
x
Reference in New Issue
Block a user