Compare commits

..

No commits in common. "main" and "v0.14.0" have entirely different histories.

65 changed files with 975 additions and 2780 deletions

2
.gitignore vendored
View File

@ -3,7 +3,5 @@ node_modules/
build/*
.~lock*
**/*(Copie)*
**/*(Copy)*
stats.html

View File

@ -1,29 +0,0 @@
//@ts-check
export {default as fillOdtTemplate} from './scripts/odf/templating/fillOdtTemplate.js'
export {getOdtTextContent} from './scripts/odf/odt/getOdtTextContent.js'
export { createOdsFile } from './scripts/createOdsFile.js'
export {
getODSTableRawContent,
// table-level exports
tableWithoutEmptyRows,
tableRawContentToValues,
tableRawContentToStrings,
tableRawContentToObjects,
// sheet-level exports
sheetRawContentToObjects,
sheetRawContentToStrings,
// row-level exports
rowRawContentToStrings,
isRowNotEmpty,
// cell-level exports
cellRawContentToStrings,
convertCellValue
} from './scripts/shared.js'

View File

@ -5,7 +5,7 @@
<meta name="referrer" content="no-referrer">
<link rel="icon" href="data:,">
<title>Upload ods file</title>
<title>Upload ods/xlsx</title>
<meta name="description" content=" ">
<meta name="viewport" content="width=device-width, initial-scale=1">

83
package-lock.json generated
View File

@ -1,17 +1,15 @@
{
"name": "@odfjs/odfjs",
"version": "0.30.0",
"name": "ods-xlsx",
"version": "0.14.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@odfjs/odfjs",
"version": "0.30.0",
"name": "ods-xlsx",
"version": "0.14.0",
"dependencies": {
"@xmldom/xmldom": "^0.9.8",
"@zip.js/zip.js": "^2.7.57",
"image-size": "^2.0.2",
"ses": "^1.14.0"
"@zip.js/zip.js": "^2.7.57"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
@ -42,24 +40,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@endo/cache-map": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@endo/cache-map/-/cache-map-1.1.0.tgz",
"integrity": "sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==",
"license": "Apache-2.0"
},
"node_modules/@endo/env-options": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.11.tgz",
"integrity": "sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==",
"license": "Apache-2.0"
},
"node_modules/@endo/immutable-arraybuffer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@endo/immutable-arraybuffer/-/immutable-arraybuffer-1.1.2.tgz",
"integrity": "sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==",
"license": "Apache-2.0"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
@ -2198,18 +2178,6 @@
"node": ">=10 <11 || >=12 <13 || >=14"
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immutable": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.4.tgz",
@ -3639,17 +3607,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ses": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/ses/-/ses-1.14.0.tgz",
"integrity": "sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==",
"license": "Apache-2.0",
"dependencies": {
"@endo/cache-map": "^1.1.0",
"@endo/env-options": "^1.1.11",
"@endo/immutable-arraybuffer": "^1.1.2"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -4598,21 +4555,6 @@
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@endo/cache-map": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@endo/cache-map/-/cache-map-1.1.0.tgz",
"integrity": "sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw=="
},
"@endo/env-options": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.11.tgz",
"integrity": "sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA=="
},
"@endo/immutable-arraybuffer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@endo/immutable-arraybuffer/-/immutable-arraybuffer-1.1.2.tgz",
"integrity": "sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ=="
},
"@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
@ -6166,11 +6108,6 @@
"integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==",
"dev": true
},
"image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="
},
"immutable": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.4.tgz",
@ -7179,16 +7116,6 @@
"type-fest": "^0.13.1"
}
},
"ses": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/ses/-/ses-1.14.0.tgz",
"integrity": "sha512-T07hNgOfVRTLZGwSS50RnhqrG3foWP+rM+Q5Du4KUQyMLFI3A8YA4RKl0jjZzhihC1ZvDGrWi/JMn4vqbgr/Jg==",
"requires": {
"@endo/cache-map": "^1.1.0",
"@endo/env-options": "^1.1.11",
"@endo/immutable-arraybuffer": "^1.1.2"
}
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",

View File

@ -1,18 +1,9 @@
{
"name": "@odfjs/odfjs",
"version": "0.30.0",
"version": "0.14.0",
"type": "module",
"exports": "./exports.js",
"files": [
"exports.js",
"scripts"
],
"imports": {
"#DOM": {
"node": "./scripts/DOM/node.js",
"browser": "./scripts/DOM/browser.js"
}
},
"main": "./scripts/node.js",
"browser": "./scripts/browser.js",
"scripts": {
"build": "rollup -c",
"dev": "npm-run-all --parallel dev:* start",
@ -21,7 +12,7 @@
"test": "ava"
},
"repository": {
"url": "https://source.netsyms.com/PostalPortal/odfjs.git"
"url": "https://github.com/odfjs/odfjs.git"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
@ -40,8 +31,6 @@
},
"dependencies": {
"@xmldom/xmldom": "^0.9.8",
"@zip.js/zip.js": "^2.7.57",
"image-size": "^2.0.2",
"ses": "^1.14.0"
"@zip.js/zip.js": "^2.7.57"
}
}

View File

@ -6,7 +6,7 @@ Small lib to parse/understand .odf files (.odt, .ods) in the browser and node.js
## Rough roadmap
- [x] add odt templating
- [x] remove support for xlsx
- [ ] remove support for xlsx
- [ ] add a .ods minifyer
- [ ] add a generic .ods visualizer
- [ ] move to a dedicated odf docs org
@ -18,19 +18,19 @@ Small lib to parse/understand .odf files (.odt, .ods) in the browser and node.js
### Install
```sh
npm i https://github.com/odfjs/odfjs.git#v0.30.0
npm i https://github.com/odfjs/odfjs.git#v0.13.0
```
### Basic - reading an ods file
### Basic - reading an ods/xlsx file
```js
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent} from '@odfjs/odfjs'
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent} from 'ods-xlsx'
/**
* @param {ArrayBuffer} odsFile - content of an .ods file
* @return {Promise<any[]>}
*/
*/
async function getFileData(odsFile){
return getODSTableRawContent(odsFile)
.then(tableWithoutEmptyRows)
@ -38,16 +38,16 @@ async function getFileData(odsFile){
}
```
The return value is an array of objects where
the **keys** are the column names in the first row and
the **values** are automatically converted from the .ods files (which type numbers, strings, booleans and dates)
The return value is an array of objects where
the **keys** are the column names in the first row and
the **values** are automatically converted from the .ods or .xlsx files (which type numbers, strings, booleans and dates)
to the appropriate JavaScript value
### Basic - creating an ods file
```js
import {createOdsFile} from '@odfjs/odfjs'
import {createOdsFile} from 'ods-xlsx'
const content = new Map([
[
@ -88,7 +88,7 @@ odf.js proposes a template syntax
In an .odt file, write the following:
```txt
Hey {nom}!
Hey {nom}!
Your birthdate is {dateNaissance}
```
@ -99,7 +99,8 @@ And then run the code:
```js
import {join} from 'node:path';
import {getOdtTemplate, fillOdtTemplate} from '@odfjs/odfjs'
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')
@ -125,22 +126,9 @@ There are also loops in the form:
They can be used to generate lists or tables in .odt files from data and a template using this syntax
#### Securing calls to fillOdtTemplate
`fillOdtTemplate` evaluate arbitrary JavaScript code in `{#each <collection> as élément}` and `{#if <condition>}` and in `{<expression>}`
By default, `fillOdtTemplate` limits access to global functions to only ECMAScript defaults via the use of [ses' Compartment](https://www.npmjs.com/package/ses#compartment), this prevents naïve data exfiltration
However, `fillOdtTemplate` is vulnerable to [prototype pollution](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html) inside template code. Two main ways to be secure are:
- control the set of possible templates
- call ses' `lockdown` which freezes Javascript intrinsics before calling `fillOdtTemplate` (this may lead to incompatibilities)
### Demo
https://odfjs.github.io/odfjs/
https://davidbruant.github.io/ods-xlsx/
## Local dev
@ -158,3 +146,4 @@ npm run dev
I hope to be credited for the work on this repo
Everything written by me and contributors to this repo is licenced under **CC0 1.0 (Public Domain)**

View File

@ -1,11 +1,10 @@
<script>
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent, createOdsFile} from '../exports.js'
//@ts-check
/** @import {SheetName, SheetRawContent} from './types.js' */
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent, getXLSXTableRawContent, createOdsFile} from './browser.js'
const ODS_TYPE = "application/vnd.oasis.opendocument.spreadsheet";
const XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/**
*
@ -16,6 +15,9 @@
if(file.type === ODS_TYPE)
return getODSTableRawContent(await file.arrayBuffer())
if(file.type === XLSX_TYPE)
return getXLSXTableRawContent(await file.arrayBuffer())
throw new TypeError(`Unsupported file type: ${file.type} (${file.name})`)
}
@ -34,13 +36,13 @@
</script>
<h1>Import fichier .ods</h1>
<h1>Import fichier .ods et .xslx</h1>
<section>
<h2>Import</h2>
<label>
Fichier à importer:
<input bind:files type="file" id="file-input" accept="{ ['.ods', ODS_TYPE].join(',') }" />
<input bind:files type="file" id="file-input" accept="{ ['.ods', '.xlsx', ODS_TYPE, XLSX_TYPE].join(',') }" />
</label>
</section>

View File

@ -1,12 +0,0 @@
//console.info('DOM implementation in browser')
/** @type { typeof DOMImplementation.prototype.createDocument } */
export function createDocument(...args){
// @ts-ignore
return document.implementation.createDocument(...args)
}
export const DOMParser = window.DOMParser
export const XMLSerializer = window.XMLSerializer
export const Node = window.Node

View File

@ -1,17 +0,0 @@
import { DOMImplementation } from "@xmldom/xmldom"
//console.info('DOM implementation in Node.js based on xmldom')
const implementation = new DOMImplementation()
/** @type { typeof DOMImplementation.prototype.createDocument } */
export function createDocument(...args){
// @ts-ignore
return implementation.createDocument(...args)
}
export {
DOMParser,
XMLSerializer,
Node
} from "@xmldom/xmldom"

View File

@ -1,30 +1,10 @@
import {DOMParser, XMLSerializer} from '#DOM'
/*
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
*/
/**
*
* @param {string} str
* @returns {Document}
*/
export function parseXML(str){
return (new DOMParser()).parseFromString(str, 'application/xml');
}
const serializer = new XMLSerializer()
/** @type { typeof XMLSerializer.prototype.serializeToString } */
export function serializeToString(node){
return serializer.serializeToString(node)
}
/**
* Traverses a DOM tree starting from the given node and applies the visit function
* 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
@ -41,55 +21,3 @@ export function traverse(node, visit) {
visit(node);
}
/**
*
* @param {Node} node1
* @param {Node} node2
* @returns {Node}
*/
export function findCommonAncestor(node1, node2) {
const ancestors1 = getAncestors(node1);
const ancestors2 = new Set(getAncestors(node2));
for(const ancestor of ancestors1) {
if(ancestors2.has(ancestor)) {
return ancestor;
}
}
throw new Error(`node1 and node2 do not have a common ancestor`)
}
/**
* returns ancestors youngest first, oldest last
*
* @param {Node} node
* @param {Node} [until]
* @returns {Node[]}
*/
export function getAncestors(node, until = undefined) {
const ancestors = [];
let current = node;
while(current && current !== until) {
ancestors.push(current);
current = current.parentNode;
}
if(current === until){
ancestors.push(until);
}
return ancestors;
}
export {
DOMParser,
XMLSerializer,
createDocument,
Node
} from '#DOM'

88
scripts/browser.js Normal file
View File

@ -0,0 +1,88 @@
//@ts-check
import {
_getODSTableRawContent,
_getXLSXTableRawContent
} 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' */
function parseXML(str){
return (new DOMParser()).parseFromString(str, 'application/xml');
}
/**
* @param {ArrayBuffer} odsArrBuff
* @returns {ReturnType<_getODSTableRawContent>}
*/
export function getODSTableRawContent(odsArrBuff){
return _getODSTableRawContent(odsArrBuff, parseXML)
}
/**
* @param {ArrayBuffer} xlsxArrBuff
* @returns {ReturnType<_getXLSXTableRawContent>}
*/
export function getXLSXTableRawContent(xlsxArrBuff){
return _getXLSXTableRawContent(xlsxArrBuff, parseXML)
}
/** @type { typeof DOMImplementation.prototype.createDocument } */
const createDocument = function createDocument(...args){
// @ts-ignore
return document.implementation.createDocument(...args)
}
const serializer = new XMLSerializer()
/** @type { typeof XMLSerializer.prototype.serializeToString } */
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
*/
export function createOdsFile(sheetsData){
return _createOdsFile(sheetsData, createDocument, serializeToString)
}
export {
// table-level exports
tableWithoutEmptyRows,
tableRawContentToValues,
tableRawContentToStrings,
tableRawContentToObjects,
// sheet-level exports
sheetRawContentToObjects,
sheetRawContentToStrings,
// row-level exports
rowRawContentToStrings,
isRowNotEmpty,
// cell-level exports
cellRawContentToStrings,
convertCellValue
} from './shared.js'

View File

@ -1,20 +1,13 @@
import { ZipWriter, BlobWriter, TextReader } from '@zip.js/zip.js';
import {serializeToString, createDocument} from './DOMUtils.js'
/** @import {SheetCellRawContent, SheetName, SheetRawContent} from './types.js' */
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
office:version="1.2">
<office:styles>
<style:style style:name="boldcell" style:family="table-cell">
<style:text-properties fo:font-weight="bold"/>
</style:style>
</office:styles>
<office:styles/>
<office:automatic-styles/>
<office:master-styles/>
</office:document-styles>`;
@ -29,13 +22,15 @@ const manifestXml = `<?xml version="1.0" encoding="UTF-8"?>
/**
* Crée un fichier .ods à partir d'un Map de feuilles de calcul
* @param {Map<SheetName, SheetRawContent>} sheetsData
* @param {typeof DOMImplementation.prototype.createDocument} createDocument
* @param {typeof XMLSerializer.prototype.serializeToString} serializeToString
* @returns {Promise<ArrayBuffer>}
*/
export async function createOdsFile(sheetsData, currencyData = null) {
export async function _createOdsFile(sheetsData, createDocument, serializeToString) {
// 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.
// 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(
@ -49,7 +44,7 @@ export async function createOdsFile(sheetsData, currencyData = null) {
}
);
const contentXml = generateContentFileXMLString(sheetsData, currencyData);
const contentXml = generateContentFileXMLString(sheetsData, createDocument, serializeToString);
zipWriter.add("content.xml", new TextReader(contentXml), {level: 9});
zipWriter.add("styles.xml", new TextReader(stylesXml));
@ -64,10 +59,12 @@ export async function createOdsFile(sheetsData, currencyData = null) {
/**
* Generate the content.xml file with spreadsheet data
* @param {Map<SheetName, SheetRawContent>} sheetsData
* @param {Map<SheetName, SheetRawContent>} sheetsData
* @param {typeof DOMImplementation.prototype.createDocument} createDocument
* @param {typeof XMLSerializer.prototype.serializeToString} serializeToString
* @returns {string}
*/
function generateContentFileXMLString(sheetsData, currencyData) {
function generateContentFileXMLString(sheetsData, createDocument, serializeToString) {
const doc = createDocument('urn:oasis:names:tc:opendocument:xmlns:office:1.0', 'office:document-content');
const root = doc.documentElement;
@ -79,52 +76,6 @@ function generateContentFileXMLString(sheetsData, currencyData) {
root.setAttribute('xmlns:fo', 'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0');
root.setAttribute('office:version', '1.2');
const styleNode = doc.createElement("office:automatic-styles");
var currencyStyleName = "currencyStyle";
if (currencyData != null) {
currencyStyleName = `currency${currencyData.currencyCode.toUpperCase()}`;
const numberStyle = doc.createElement("number:currency-style");
numberStyle.setAttribute("style:name", currencyStyleName);
const numberCurrencySymbolStyle = doc.createElement("number:currency-symbol");
numberCurrencySymbolStyle.setAttribute("number:language", "en");
numberCurrencySymbolStyle.setAttribute("number:country", currencyData.countryCode.toUpperCase());
numberCurrencySymbolStyle.textContent = currencyData.currencySymbol;
numberStyle.appendChild(numberCurrencySymbolStyle);
const numberCurrencyStyle = doc.createElement("number:number");
numberCurrencyStyle.setAttribute("number:min-integer-digits", "1");
numberCurrencyStyle.setAttribute("number:decimal-places", `${currencyData.decimalPlaces}`);
numberCurrencyStyle.setAttribute("number:min-decimal-places", `${currencyData.decimalPlaces}`);
numberCurrencyStyle.setAttribute("number:grouping", "true");
numberStyle.appendChild(numberCurrencyStyle);
styleNode.appendChild(numberStyle);
const currencyCellStyleNode = doc.createElement("style:style");
currencyCellStyleNode.setAttribute("style:name", "currencycell");
currencyCellStyleNode.setAttribute("style:family", "table-cell");
currencyCellStyleNode.setAttribute("style:data-style-name", currencyStyleName);
const currencyCellTableCellProperties = doc.createElement("style:table-cell-properties");
currencyCellStyleNode.appendChild(currencyCellTableCellProperties);
styleNode.appendChild(currencyCellStyleNode);
}
const boldCellStyleNode = doc.createElement("style:style");
boldCellStyleNode.setAttribute("style:name", "boldcell");
boldCellStyleNode.setAttribute("style:family", "table-cell");
const boldCellTextPropsNode = doc.createElement("style:text-properties");
boldCellTextPropsNode.setAttribute("fo:font-weight", "bold");
boldCellStyleNode.appendChild(boldCellTextPropsNode);
styleNode.appendChild(boldCellStyleNode);
root.appendChild(styleNode);
const bodyNode = doc.createElement('office:body');
root.appendChild(bodyNode);
@ -137,30 +88,8 @@ function generateContentFileXMLString(sheetsData, currencyData) {
tableNode.setAttribute('table:name', sheetName);
spreadsheetNode.appendChild(tableNode);
var columnsWidthChars = {};
for (let r = 0; r < sheetData.length; r++) {
for (let c = 0; c < sheetData[r].length; c++) {
var len = ((sheetData[r][c].display ?? sheetData[r][c].value) + "").length;
if (typeof columnsWidthChars[c] == "undefined") {
columnsWidthChars[c] = len;
}
columnsWidthChars[c] = Math.max(columnsWidthChars[c], len);
}
}
for (var prop in columnsWidthChars) {
var columnNode = doc.createElement('table:table-column');
columnNode.setAttribute("table:style-name", "colwidth" + columnsWidthChars[prop]);
tableNode.appendChild(columnNode);
var columnWidthNode = doc.createElement("style:style");
columnWidthNode.setAttribute("style:name", "colwidth" + columnsWidthChars[prop]);
columnWidthNode.setAttribute("style:family", "table-column");
const columnWidthPropsNode = doc.createElement("style:table-column-properties");
columnWidthPropsNode.setAttribute("style:column-width", `${columnsWidthChars[prop] * 0.26}cm`);
columnWidthNode.appendChild(columnWidthPropsNode);
styleNode.appendChild(columnWidthNode);
}
const columnNode = doc.createElement('table:table-column');
tableNode.appendChild(columnNode);
// Iterate through rows
sheetData.forEach((row) => {
@ -173,10 +102,6 @@ function generateContentFileXMLString(sheetsData, currencyData) {
const cellType = convertCellType(cell.type);
cellNode.setAttribute('office:value-type', cellType);
if (cell.style && cell.style == "bold") {
cellNode.setAttribute('table:style-name', "boldcell");
}
// Add value attribute based on type
if (cell.value !== null && cell.value !== undefined) {
switch (cellType) {
@ -187,14 +112,6 @@ function generateContentFileXMLString(sheetsData, currencyData) {
cellNode.setAttribute('office:value', cell.value.toString());
cellNode.setAttribute('office:value-type', 'percentage');
break;
case 'currency':
cellNode.setAttribute('office:value', cell.value.toString());
cellNode.setAttribute('office:value-type', 'currency');
if (currencyData != null) {
cellNode.setAttribute("table:style-name", "currencycell");
cellNode.setAttribute('office:currency', currencyData.currencyCode.toUpperCase());
}
break;
case 'date':
cellNode.setAttribute('office:date-value', cell.value.toString());
break;
@ -210,11 +127,7 @@ function generateContentFileXMLString(sheetsData, currencyData) {
if (cellType !== 'string') {
const textNode = doc.createElement('text:p');
if (typeof cell.display != "undefined") {
textNode.textContent = cell.display.toString();
} else {
textNode.textContent = cell.value.toString();
}
textNode.textContent = cell.value.toString();
cellNode.appendChild(textNode);
}
}
@ -229,7 +142,7 @@ function generateContentFileXMLString(sheetsData, currencyData) {
/**
* Convert cell type to OpenDocument format type
* @param {SheetCellRawContent['type']} type
* @param {SheetCellRawContent['type']} type
* @returns {SheetCellRawContent['type']}
*/
function convertCellType(type) {

94
scripts/node.js Normal file
View File

@ -0,0 +1,94 @@
//@ts-check
import {DOMParser, DOMImplementation, XMLSerializer, Node} from '@xmldom/xmldom'
import {
_getODSTableRawContent,
_getXLSXTableRawContent
} 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');
}
/**
* @param {ArrayBuffer} odsArrBuff
* @returns {ReturnType<_getODSTableRawContent>}
*/
export function getODSTableRawContent(odsArrBuff){
return _getODSTableRawContent(odsArrBuff, parseXML)
}
/**
* @param {ArrayBuffer} xlsxArrBuff
* @returns {ReturnType<_getXLSXTableRawContent>}
*/
export function getXLSXTableRawContent(xlsxArrBuff){
return _getXLSXTableRawContent(xlsxArrBuff, parseXML)
}
const implementation = new DOMImplementation()
/** @type { typeof DOMImplementation.prototype.createDocument } */
const createDocument = function createDocument(...args){
// @ts-ignore
return implementation.createDocument(...args)
}
const serializer = new XMLSerializer()
/** @type { typeof XMLSerializer.prototype.serializeToString } */
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
*/
export function createOdsFile(sheetsData){
return _createOdsFile(sheetsData, createDocument, serializeToString)
}
export {
// table-level exports
tableWithoutEmptyRows,
tableRawContentToValues,
tableRawContentToStrings,
tableRawContentToObjects,
// sheet-level exports
sheetRawContentToObjects,
sheetRawContentToStrings,
// row-level exports
rowRawContentToStrings,
isRowNotEmpty,
// cell-level exports
cellRawContentToStrings,
convertCellValue
} from './shared.js'

View File

@ -0,0 +1,464 @@
import { ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter } from '@zip.js/zip.js';
import {traverse} from '../DOMUtils.js'
import {makeManifestFile, getManifestFileData} from './manifest.js';
/** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */
/** @import {ODFManifest} from './manifest.js' */
/** @typedef {ArrayBuffer} ODTFile */
const ODTMimetype = 'application/vnd.oasis.opendocument.text'
// For a given string, split it into fixed parts and parts to replace
/**
* @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
}
}
})
}
const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml'])
/**
*
* @param {string} filename
* @returns {boolean}
*/
function keepFile(filename){
return keptFiles.has(filename) || filename.startsWith('Pictures/')
}
/**
* @param {ODTFile} odtTemplate
* @param {any} data
* @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} */
let manifestFileData;
/** @type {{filename: string, content: Reader, options?: ZipWriterAddDataOptions}[]} */
const zipEntriesToAdd = []
// Parcourir chaque entrée du fichier ODT
for await (const entry of entries) {
const filename = entry.filename
//console.log('entry', filename, entry.directory)
// remove other files
if(!keepFile(filename)){
// ignore, do not create a corresponding entry in the new zip
}
else{
let content
let options
switch(filename){
case 'mimetype':
content = new TextReader(ODTMimetype)
options = {
level: 0,
compressionMethod: 0,
dataDescriptor: false,
extendedTimestamp: false,
}
zipEntriesToAdd.push({filename, content, options})
break;
case 'content.xml':
// @ts-ignore
const contentXml = await entry.getData(new TextWriter());
const contentDocument = parseXML(contentXml);
fillTemplatedOdtElement(contentDocument, data, Node)
const updatedContentXml = serializeToString(contentDocument)
content = new TextReader(updatedContentXml)
options = {
lastModDate: entry.lastModDate,
level: 9
};
zipEntriesToAdd.push({filename, content, options})
break;
case 'META-INF/manifest.xml':
// @ts-ignore
const manifestXml = await entry.getData(new TextWriter());
const manifestDocument = parseXML(manifestXml);
manifestFileData = getManifestFileData(manifestDocument)
break;
case 'styles.xml':
default:
const blobWriter = new BlobWriter();
// @ts-ignore
await entry.getData(blobWriter);
const blob = await blobWriter.getData();
content = new BlobReader(blob)
zipEntriesToAdd.push({filename, content})
break;
}
}
}
for(const {filename, content, options} of zipEntriesToAdd){
await writer.add(filename, content, options);
}
const newZipFilenames = new Set(zipEntriesToAdd.map(ze => ze.filename))
if(!manifestFileData){
throw new Error(`'META-INF/manifest.xml' zip entry missing`)
}
// remove ignored files from manifest.xml
for(const filename of manifestFileData.fileEntries.keys()){
if(!newZipFilenames.has(filename)){
manifestFileData.fileEntries.delete(filename)
}
}
const manifestFileXml = makeManifestFile(manifestFileData)
await writer.add('META-INF/manifest.xml', new TextReader(manifestFileXml));
await reader.close();
return writer.close();
}

View File

@ -1,69 +0,0 @@
import { ZipReader, Uint8ArrayReader, TextWriter } from '@zip.js/zip.js';
import {parseXML, Node} from '../../DOMUtils.js'
/** @import {ODTFile} from '../templating/fillOdtTemplate.js' */
/**
* @param {ODTFile} odtFile
* @returns {Promise<Document>}
*/
export 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();
return parseXML(contentText)
}
/**
*
* @param {Document} odtDocument
* @returns {Element}
*/
function getODTTextElement(odtDocument) {
return odtDocument.getElementsByTagName('office:body')[0]
.getElementsByTagName('office:text')[0]
}
/**
* 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)
}

View File

@ -1,5 +1,23 @@
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
@ -9,3 +27,61 @@ 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');
}

View File

@ -1,875 +0,0 @@
import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js";
import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, imageMarkerRegex, variableRegex} from './markers.js'
import {isOdfjsImage} from "../../shared.js"
import imageSize from "image-size";
/** @import {OdfjsImage} from "../../types.js" */
/**
* @typedef TextPlaceToFill
* @property { {expression: string, replacedString:string}[] } expressions
* @property {() => void} fill
*/
class TemplateDOMBranch{
/** @type {Node} */
#branchBaseNode
/** @type {Node} */
#leafNode
// ancestors with this.#ancestors[0] === this.#branchBaseNode and this.#ancestors.at(-1) === this.#leafNode
/** @type {Node[]} */
#ancestors
/**
*
* @param {Node} branchBaseNode
* @param {Node} leafNode
*/
constructor(branchBaseNode, leafNode){
this.#branchBaseNode = branchBaseNode
this.#leafNode = leafNode
this.#ancestors = getAncestors(this.#leafNode, this.#branchBaseNode).reverse()
}
/**
*
* @param {number} n
* @returns {Node | undefined}
*/
at(n){
return this.#ancestors.at(n)
}
removeLeafAndEmptyAncestors(){
//this.logBranch('[removeLeafAndEmptyAncestors] branch at the start')
// it may happen (else marker of if/else/endif) that the leaf was already removed as part of another block
// so before removing anything, let's update #ancestors and #leaf
this.#ancestors.every((ancestor, i) => {
if(!ancestor.parentNode){
// ancestor already removed from tree
this.#ancestors = this.#ancestors.slice(0, i)
return false;
}
return true // continue
})
this.#leafNode = this.#ancestors.at(-1)
//this.logBranch('[removeLeafAndEmptyAncestors] after adjusting this.#ancestors')
//console.log('removeLeafAndEmptyAncestors', this.#startNode.textContent)
let nextLeaf
if(this.#leafNode !== this.#branchBaseNode){
nextLeaf = this.#leafNode.parentNode
//console.log('nextLeaf', !!nextLeaf)
nextLeaf.removeChild(this.#leafNode)
this.#leafNode = nextLeaf
}
while(this.#leafNode !== this.#branchBaseNode &&
(this.#leafNode.textContent === null || this.#leafNode.textContent.trim() === ''))
{
nextLeaf = this.#leafNode.parentNode
this.#leafNode.parentNode.removeChild(this.#leafNode)
this.#leafNode = nextLeaf
}
this.#ancestors = getAncestors(this.#leafNode, this.#branchBaseNode).reverse()
}
/**
*
* @param {number} [startIndex]
*/
removeRightContent(startIndex = 0){
//console.log('[removeRightContent]', startIndex, this.#ancestors.slice(startIndex).length)
for(const branchNode of this.#ancestors.slice(startIndex)){
//console.log('[removeRightContent]', branchNode.nodeType, branchNode.nodeName)
let toRemove = branchNode.nextSibling
while(toRemove){
const toRemoveNext = toRemove.nextSibling
toRemove.parentNode.removeChild(toRemove)
toRemove = toRemoveNext
}
}
}
/**
*
* @param {number} [startIndex]
*/
removeLeftContent(startIndex = 0){
for(const branchNode of this.#ancestors.slice(startIndex)){
let toRemove = branchNode.previousSibling
while(toRemove){
const toRemoveNext = toRemove.previousSibling
toRemove.parentNode.removeChild(toRemove)
toRemove = toRemoveNext
}
}
}
/**
*
* @returns {number[]}
*/
getBranchPath(){
//console.log('[getBranchPath]', this.#branchBaseNode.nodeName, this.#branchBaseNode.textContent)
//console.log('[getBranchPath] leaf', this.#leafNode.nodeName, this.#leafNode.textContent)
/** @type {ReturnType<typeof TemplateDOMBranch.prototype.getBranchPath>} */
const pathFromLeafToBase = [];
let currentNode = this.#leafNode
let currentNodeParent = currentNode.parentNode
while(currentNodeParent){
//console.log('[getBranchPath] currentNodeParent', currentNodeParent.nodeName)
//console.log('[getBranchPath] looking for currentNode', currentNode.nodeName, currentNode.textContent)
//console.log('[getBranchPath] currentNodeParent.childNodes.length', currentNodeParent.childNodes.length)
/*console.log('[getBranchPath] currentNodeParent.childNodes', Array.from(currentNodeParent.childNodes)
.map(n => `${n.nodeName} - ${n.textContent}`)
)*/
const index = Array.from(currentNodeParent.childNodes).indexOf(currentNode)
//console.log('[getBranchPath] indexOf', index)
if(index === -1){
throw new Error(`Could not find currentNode in currentNodeParent's childNodes`)
}
pathFromLeafToBase.push(index)
//console.log('[getBranchPath] currentNodeParent and index', currentNodeParent.nodeName, index)
if(currentNodeParent === this.#ancestors[0]){
break; // path is fnished
}
else{
currentNode = currentNodeParent
currentNodeParent = currentNode.parentNode
}
}
//@ts-expect-error ES2023
return pathFromLeafToBase.toReversed()
}
logBranch(message){
console.group('[TemplateDOMBranch] Showing branch')
console.log(message)
for(const node of this.#ancestors){
console.log('branch node', node.nodeType, node.nodeName, node.nodeType === node.TEXT_NODE ? node.textContent : '')
}
console.groupEnd()
}
}
class TemplateBlock{
/** @type {Element | Document | DocumentFragment} */
#commonAncestor;
/** @type {TemplateDOMBranch} */
startBranch;
/** @type {TemplateDOMBranch} */
endBranch;
/** @type {Node[]} */
#middleContent;
/**@type {any} */
#addImageToOdtFile;
/**
*
* @param {Node} startNode
* @param {Node} endNode
* @param {(OdfjsImage) => string} addImageToOdtFile
*/
constructor(startNode, endNode, addImageToOdtFile){
this.#addImageToOdtFile = addImageToOdtFile
// @ts-expect-error xmldom.Node
this.#commonAncestor = findCommonAncestor(startNode, endNode)
//console.log('create start branch')
this.startBranch = new TemplateDOMBranch(this.#commonAncestor, startNode)
//console.log('create end branch')
this.endBranch = new TemplateDOMBranch(this.#commonAncestor, endNode)
this.#middleContent = []
let content = this.startBranch.at(1).nextSibling
while(content && content !== this.endBranch.at(1)){
this.#middleContent.push(content)
content = content.nextSibling
}
//console.group('\n== TemplateBlock ==')
//this.startBranch.logBranch('startBranch')
//console.log('middleContent', this.#middleContent.map(n => n.textContent).join(''))
//this.endBranch.logBranch('endBranch')
//console.log('common ancestor', this.#commonAncestor.nodeName, '\n')
//console.groupEnd()
}
removeMarkersAndEmptyAncestors(){
//console.log('[removeMarkersAndEmptyAncestors]', this.#commonAncestor.textContent)
this.startBranch.removeLeafAndEmptyAncestors()
this.endBranch.removeLeafAndEmptyAncestors()
//console.log('[removeMarkersAndEmptyAncestors] after', this.#commonAncestor.textContent)
}
/**
*
* @param {Compartment} compartement
*/
fillBlockContentTemplate(compartement){
//console.log('[fillBlockContentTemplate] start')
const startChild = this.startBranch.at(1)
if(startChild /*&& startChild !== */){
//console.log('[fillBlockContentTemplate] startChild', startChild.nodeName, startChild.textContent)
fillOdtElementTemplate(startChild, compartement, this.#addImageToOdtFile)
}
//console.log('[fillBlockContentTemplate] after startChild')
// if content consists of several parts of an {#each}{/each}
// when arriving to the {/each}, it will be alone (and imbalanced)
// and will trigger an error
fillOdtElementTemplate(Array.from(this.#middleContent), compartement, this.#addImageToOdtFile)
//console.log('[fillBlockContentTemplate] after middleContent')
const endChild = this.endBranch.at(1)
//console.log('fillBlockContentTemplate] [endBranch]')
//this.endBranch.logBranch('endBranch')
if(endChild){
//console.log('[fillBlockContentTemplate] endChild', endChild.nodeName, endChild.textContent)
fillOdtElementTemplate(endChild, compartement, this.#addImageToOdtFile)
}
//console.log('[fillBlockContentTemplate] after endChild')
//console.log('[fillBlockContentTemplate] end')
}
removeContent(){
this.startBranch.removeRightContent(2)
for(const content of this.#middleContent){
content.parentNode.removeChild(content)
}
this.endBranch.removeLeftContent(2)
}
/**
* @returns {TemplateBlock}
*/
cloneAndAppendAfter(){
//console.log('[cloneAndAppendAfter]')
const clonedPieces = []
let startBranchClone;
let endBranchClone;
for(const sibling of [this.startBranch.at(1), ...this.#middleContent, this.endBranch.at(1)]){
if(sibling){
const siblingClone = sibling.cloneNode(true)
clonedPieces.push(siblingClone)
if(sibling === this.startBranch.at(1))
startBranchClone = siblingClone
if(sibling === this.endBranch.at(1))
endBranchClone = siblingClone
}
}
let startChildPreviousSiblingsCount = 0
let previousSibling = this.startBranch.at(1).previousSibling
while(previousSibling){
startChildPreviousSiblingsCount = startChildPreviousSiblingsCount + 1
previousSibling = previousSibling.previousSibling
}
const startBranchPathFromBaseToLeaf = this.startBranch.getBranchPath().slice(1)
const endBranchPathFromBaseToLeaf = this.endBranch.getBranchPath().slice(1)
//console.log('startBranchClone', !!startBranchClone)
//console.log('startBranchPathFromBaseToLeaf', startBranchPathFromBaseToLeaf)
let startLeafCloneNode
{
let node = startBranchClone
for(let pathIndex of startBranchPathFromBaseToLeaf){
//console.log('[startLeafCloneNode] node.childNodes.length', node.childNodes.length)
//console.log('[startLeafCloneNode] pathIndex', pathIndex)
node = node.childNodes[pathIndex]
}
startLeafCloneNode = node
}
//console.log('endBranchClone', !!endBranchClone)
//console.log('endBranchPathFromBaseToLeaf', endBranchPathFromBaseToLeaf)
let endLeafCloneNode
{
let node = endBranchClone
for(let pathIndex of endBranchPathFromBaseToLeaf){
//console.log('[endLeafCloneNode] node.childNodes.length', node.childNodes.length)
//console.log('[endLeafCloneNode] pathIndex', pathIndex)
node = node.childNodes[pathIndex]
}
endLeafCloneNode = node
}
let insertBeforePoint = this.endBranch.at(1) && this.endBranch.at(1).nextSibling
if(insertBeforePoint){
for(const node of clonedPieces){
this.#commonAncestor.insertBefore(node, insertBeforePoint)
}
}
else{
for(const node of clonedPieces){
this.#commonAncestor.appendChild(node)
}
}
return new TemplateBlock(startLeafCloneNode, endLeafCloneNode, this.#addImageToOdtFile)
}
}
/**
* @param {string} str
* @param {Compartment} compartment
* @returns {TextPlaceToFill | undefined}
*/
function findPlacesToFillInString(str, compartment) {
const varRexExp = new RegExp(variableRegex.source, 'g');
const matches = str.matchAll(varRexExp)
/** @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(() => compartment.evaluate(expression))
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} ifOpeningMarkerNode
* @param {Node | undefined} ifElseMarkerNode
* @param {Node} ifClosingMarkerNode
* @param {string} ifBlockConditionExpression
* @param {Compartment} compartment
* // TODO type,addImageToOdtFile
*/
function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile) {
//const docEl = ifOpeningMarkerNode.ownerDocument.documentElement
const conditionValue = compartment.evaluate(ifBlockConditionExpression)
/** @type {TemplateBlock | undefined} */
let thenTemplateBlock
/** @type {TemplateBlock | undefined} */
let elseTemplateBlock
if(ifElseMarkerNode) {
/*console.log('before first extract',
ifOpeningMarkerNode.childNodes.length, ifOpeningMarkerNode.textContent,
ifElseMarkerNode.childNodes.length, ifElseMarkerNode.textContent
)*/
thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode, addImageToOdtFile)
elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode, addImageToOdtFile)
}
else {
thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode, addImageToOdtFile)
}
if(conditionValue) {
if(elseTemplateBlock){
elseTemplateBlock.removeContent()
}
thenTemplateBlock.removeMarkersAndEmptyAncestors()
if(elseTemplateBlock){
elseTemplateBlock.removeMarkersAndEmptyAncestors()
}
thenTemplateBlock.fillBlockContentTemplate(compartment)
}
else{
// remove content before removing markers so that right and left content are fully removed
thenTemplateBlock.removeContent()
thenTemplateBlock.removeMarkersAndEmptyAncestors()
if(elseTemplateBlock){
elseTemplateBlock.removeMarkersAndEmptyAncestors()
}
if(elseTemplateBlock){
elseTemplateBlock.fillBlockContentTemplate(compartment)
}
}
}
/**
*
* @param {Node} startNode
* @param {string} iterableExpression
* @param {string} itemExpression
* @param {Node} endNode
* @param {Compartment} compartment
* // TODO type addImageToOdtFile
*/
function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment, addImageToOdtFile) {
//console.log('fillEachBlock', iterableExpression, itemExpression)
const docEl = startNode.ownerDocument.documentElement
//console.log('[fillEachBlock] docEl', docEl.textContent)
const repeatedTemplateBlock = new TemplateBlock(startNode, endNode, addImageToOdtFile)
// Find the iterable in the data
let iterable = compartment.evaluate(iterableExpression)
if(!iterable || typeof iterable[Symbol.iterator] !== 'function') {
// when there is no iterable, silently replace with empty array
iterable = []
}
// convert to array to know the size and know which element is last
if(!Array.isArray(iterable))
iterable = [...iterable]
if(iterable.length === 0){
repeatedTemplateBlock.removeMarkersAndEmptyAncestors()
repeatedTemplateBlock.removeContent()
}
else{
let nextTemplateBlock = repeatedTemplateBlock
iterable.forEach((item, i) => {
//console.log('[fillEachBlock] loop i', i, docEl.textContent)
const firstItem = i === 0
const lastItem = i === iterable.length - 1
let currentTemplateBlock = nextTemplateBlock;
//console.log('currentTemplateBlock', currentTemplateBlock.startBranch.at(0).textContent)
if(!lastItem){
nextTemplateBlock = currentTemplateBlock.cloneAndAppendAfter()
}
let insideCompartment = new Compartment({
globals: Object.assign({}, compartment.globalThis, {[itemExpression]: item}),
__options__: true
})
if(!firstItem){
currentTemplateBlock.startBranch.removeLeftContent(2)
}
if(!lastItem){
//console.log('[fillEachBlock] removeRightContent')
currentTemplateBlock.endBranch.removeRightContent(2)
}
//console.log('[fillEachBlock] docEl i before removeMarkers', i, docEl.textContent)
currentTemplateBlock.removeMarkersAndEmptyAncestors()
//console.log('[fillEachBlock] docEl i after removeMarkers', i, docEl.textContent)
//console.log('\nrecursive call to fillBlockContentTemplate')
currentTemplateBlock.fillBlockContentTemplate(insideCompartment)
//console.log('[fillEachBlock] docEl i after remove contents', i, docEl.textContent)
})
}
}
/**
* @param {string} str
* @param {Compartement} compartment
* @returns { {expression: string, odfjsImage: OdfjsImage | undefined} | undefined}
*/
function findImageMarker(str, compartment) {
const imageRexExp = new RegExp(imageMarkerRegex.source, 'g');
const match = imageRexExp.exec(str)
if (match===null){
return;
}
const expression = match[1]
const value = compartment.evaluate(expression)
if (isOdfjsImage(value)) {
return { expression, odfjsImage: value}
} else {
return { expression }
}
}
const IF = ifStartMarkerRegex.source
const EACH = eachStartMarkerRegex.source
/** @typedef {Element | DocumentFragment | Document} RootElementArgument */
/**
*
* @param {RootElementArgument | RootElementArgument[]} rootElements
* @param {Compartment} compartment
* @param {(OdfjsImage) => string} addImageToOdtFile
* @returns {void}
*/
export default function fillOdtElementTemplate(rootElements, compartment, addImageToOdtFile) {
if(!Array.isArray(rootElements)){
rootElements = [rootElements]
}
//console.log('[fillTemplatedOdtElement]', rootElements.length, rootElements[0].nodeType, rootElements[0].nodeName, rootElements[0].textContent)
//console.log('[fillTemplatedOdtElement]', rootElement.documentElement && rootElement.documentElement.textContent)
let currentlyOpenBlocks = []
/** @type {Node | undefined} */
let eachOpeningMarkerNode
/** @type {Node | undefined} */
let eachClosingMarkerNode
let eachBlockIterableExpression, eachBlockItemExpression;
/** @type {Node | undefined} */
let ifOpeningMarkerNode
/** @type {Node | undefined} */
let ifElseMarkerNode
/** @type {Node | undefined} */
let ifClosingMarkerNode
let ifBlockConditionExpression
// Traverse "in document order"
for(const rootElement of rootElements){
// @ts-ignore
traverse(rootElement, currentNode => {
//console.log('currentlyOpenBlocks', currentlyOpenBlocks)
//console.log('eachOpeningMarkerNode', eachOpeningMarkerNode)
const insideAnOpenBlock = currentlyOpenBlocks.length >= 1
if(currentNode.nodeType === Node.TEXT_NODE) {
const text = currentNode.textContent || ''
/**
* looking for {#each x as y}
*/
const eachStartMatch = text.match(eachStartMarkerRegex);
if(eachStartMatch) {
//console.log('startMatch', startMatch)
currentlyOpenBlocks.push(EACH)
if(insideAnOpenBlock) {
// do nothing
}
else {
let [_, _iterableExpression, _itemExpression] = eachStartMatch
eachBlockIterableExpression = _iterableExpression
eachBlockItemExpression = _itemExpression
eachOpeningMarkerNode = currentNode
}
}
/**
* Looking for {/each}
*/
const isEachClosingBlock = text.includes(eachClosingMarker)
if(isEachClosingBlock) {
//console.log('isEachClosingBlock', isEachClosingBlock, currentlyOpenBlocks)
if(!insideAnOpenBlock)
throw new Error('{/each} found without corresponding opening {#each x as y}')
if(currentlyOpenBlocks.at(-1) !== EACH)
throw new Error(`{/each} found while the last opened block was not an opening {#each x as y}`)
if(currentlyOpenBlocks.length === 1) {
eachClosingMarkerNode = currentNode
// found an {#each} and its corresponding {/each}
// execute replacement loop
//console.log('start of fillEachBlock')
fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment, addImageToOdtFile)
//console.log('end of fillEachBlock')
eachOpeningMarkerNode = undefined
eachBlockIterableExpression = undefined
eachBlockItemExpression = undefined
eachClosingMarkerNode = undefined
}
else {
// ignore because it will be treated as part of the outer {#each}
}
//console.log('popping currentlyOpenBlocks')
currentlyOpenBlocks.pop()
}
/**
* Looking for {#if ...}
*/
const ifStartMatch = text.match(ifStartMarkerRegex);
if(ifStartMatch) {
currentlyOpenBlocks.push(IF)
if(insideAnOpenBlock) {
// do nothing because the marker is too deep
}
else {
let [_, _ifBlockConditionExpression] = ifStartMatch
ifBlockConditionExpression = _ifBlockConditionExpression
ifOpeningMarkerNode = currentNode
}
}
/**
* Looking for {:else}
*/
const hasElseMarker = text.includes(elseMarker);
if(hasElseMarker) {
if(!insideAnOpenBlock)
throw new Error('{:else} without a corresponding {#if}')
if(currentlyOpenBlocks.length === 1) {
if(currentlyOpenBlocks[0] === IF) {
ifElseMarkerNode = currentNode
}
else
throw new Error('{:else} inside an {#each} but without a corresponding {#if}')
}
else {
// do nothing because the marker is too deep
}
}
/**
* Looking for {/if}
*/
const ifClosingMarker = text.includes(closingIfMarker);
if(ifClosingMarker) {
if(!insideAnOpenBlock)
throw new Error('{/if} without a corresponding {#if}')
if(currentlyOpenBlocks.length === 1) {
if(currentlyOpenBlocks[0] === IF) {
ifClosingMarkerNode = currentNode
// found an {#if} and its corresponding {/if}
// execute replacement loop
fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile)
ifOpeningMarkerNode = undefined
ifElseMarkerNode = undefined
ifClosingMarkerNode = undefined
ifBlockConditionExpression = undefined
}
else
throw new Error('{/if} inside an {#each} but without a corresponding {#if}')
}
else {
// do nothing because the marker is too deep
}
currentlyOpenBlocks.pop()
}
/**
* Looking for variables for substitutions
*/
if(!insideAnOpenBlock) {
// @ts-ignore
if(currentNode.data) {
// @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.data, compartment)
if(placesToFill) {
const newText = placesToFill.fill()
// @ts-ignore
const newTextNode = currentNode.ownerDocument?.createTextNode(newText)
// @ts-ignore
currentNode.parentNode?.replaceChild(newTextNode, currentNode)
} else {
const imageMarker = findImageMarker(currentNode.data, compartment)
if (imageMarker){
//console.log({imageMarker}, "dans le if imageMarker")
if (imageMarker.odfjsImage) {
const href = addImageToOdtFile(imageMarker.odfjsImage)
const newImageNode = currentNode.ownerDocument?.createElement("draw:image")
newImageNode.setAttribute("xlink:href", href)
newImageNode.setAttribute("xlink:type", "simple")
newImageNode.setAttribute("xlink:show", "embed")
newImageNode.setAttribute("xlink:actuate", "onLoad")
newImageNode.setAttribute("draw:mime-type", imageMarker.odfjsImage.mediaType)
const newFrameNode = currentNode.ownerDocument?.createElement('draw:frame')
newFrameNode.setAttribute("text:anchor-type", "as-char")
const buffer = new Uint8Array(imageMarker.odfjsImage.content)
const dimensions = imageSize(buffer)
const MAX_WIDTH = 10 // cm
const MAX_HEIGHT = 10 // cm
let width;
let height;
if(dimensions.width > dimensions.height){
// image in landscape
width = MAX_WIDTH;
height = width*dimensions.height/dimensions.width
}
else{
// image in portrait
height = MAX_HEIGHT;
width = height*dimensions.width/dimensions.height
}
newFrameNode.setAttribute("svg:width", `${width}cm`)
newFrameNode.setAttribute("svg:height", `${height}cm`)
newFrameNode.appendChild(newImageNode)
currentNode.parentNode?.replaceChild(newFrameNode, currentNode)
} else {
throw new Error(`No valid OdfjsImage value has been found for expression: ${imageMarker.expression}`)
}
}
}
}
}
else {
// ignore because it will be treated as part of the outer {#each} block
}
}
if(currentNode.nodeType === Node.ATTRIBUTE_NODE) {
// Looking for variables for substitutions
if(!insideAnOpenBlock) {
// @ts-ignore
if(currentNode.value) {
// @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.value, compartment)
if(placesToFill) {
// @ts-ignore
currentNode.value = placesToFill.fill()
}
}
}
else {
// ignore because it will be treated as part of the {#each} block
}
}
})
}
}

View File

@ -1,186 +0,0 @@
import {ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayReader, TextWriter, Uint8ArrayWriter} from '@zip.js/zip.js';
import {parseXML, serializeToString} from '../../DOMUtils.js'
import {makeManifestFile, getManifestFileData} from '../manifest.js';
import prepareTemplateDOMTree from './prepareTemplateDOMTree.js';
import 'ses'
import fillOdtElementTemplate from './fillOdtElementTemplate.js';
/** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */
/** @import {ODFManifest, ODFManifestFileEntry} from '../manifest.js' */
/** @import {OdfjsImage} from '../../types.js' */
/** @typedef {ArrayBuffer} ODTFile */
const ODTMimetype = 'application/vnd.oasis.opendocument.text'
/**
*
* @param {Document} document
* @param {Compartment} compartment
* @param {(OdfjsImage) => string} addImageToOdtFile
* @returns {void}
*/
function fillOdtDocumentTemplate(document, compartment, addImageToOdtFile) {
prepareTemplateDOMTree(document)
fillOdtElementTemplate(document, compartment, addImageToOdtFile)
}
const keptFiles = new Set(['content.xml', 'styles.xml', 'mimetype', 'META-INF/manifest.xml'])
/**
*
* @param {string} filename
* @returns {boolean}
*/
function keepFile(filename) {
return keptFiles.has(filename) || filename.startsWith('Pictures/')
}
/**
* @param {ODTFile} odtTemplate
* @param {any} data
* @returns {Promise<ODTFile>}
*/
export default async function fillOdtTemplate(odtTemplate, data) {
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtTemplate)));
// Lire toutes les entrées du fichier ODT
const entries = reader.getEntriesGenerator();
// Créer un ZipWriter pour le nouveau fichier ODT
const writer = new ZipWriter(new Uint8ArrayWriter());
/** @type {ODFManifest} */
let manifestFileData;
/** @type {{filename: string, content: Reader, options?: ZipWriterAddDataOptions}[]} */
const zipEntriesToAdd = []
/** @type {ODFManifestFileEntry[]} */
const newManifestEntries = []
/**
* Return href
* @param {OdfjsImage} odfjsImage
* @returns {string}
*/
function addImageToOdtFile(odfjsImage) {
// console.log({odfjsImage})
const filename = `Pictures/${odfjsImage.fileName}`
zipEntriesToAdd.push({content: new Uint8ArrayReader(new Uint8Array(odfjsImage.content)), filename})
newManifestEntries.push({fullPath: filename, mediaType: odfjsImage.mediaType})
return filename
}
// Parcourir chaque entrée du fichier ODT
for await(const entry of entries) {
const filename = entry.filename
//console.log('entry', filename, entry.directory)
// remove other files
if(!keepFile(filename)) {
// ignore, do not create a corresponding entry in the new zip
}
else {
let content
let options
switch(filename) {
case 'mimetype':
content = new TextReader(ODTMimetype)
options = {
level: 0,
compressionMethod: 0,
dataDescriptor: false,
extendedTimestamp: false,
}
zipEntriesToAdd.push({filename, content, options})
break;
case 'content.xml':
// @ts-ignore
const contentXml = await entry.getData(new TextWriter());
const contentDocument = parseXML(contentXml);
const compartment = new Compartment({
globals: data,
__options__: true
})
fillOdtDocumentTemplate(contentDocument, compartment, addImageToOdtFile)
const updatedContentXml = serializeToString(contentDocument)
content = new TextReader(updatedContentXml)
options = {
lastModDate: entry.lastModDate,
level: 9
};
zipEntriesToAdd.push({filename, content, options})
break;
case 'META-INF/manifest.xml':
// @ts-ignore
const manifestXml = await entry.getData(new TextWriter());
const manifestDocument = parseXML(manifestXml);
manifestFileData = getManifestFileData(manifestDocument)
break;
case 'styles.xml':
default:
const blobWriter = new BlobWriter();
// @ts-ignore
await entry.getData(blobWriter);
const blob = await blobWriter.getData();
content = new BlobReader(blob)
zipEntriesToAdd.push({filename, content})
break;
}
}
}
for(const {fullPath, mediaType} of newManifestEntries){
manifestFileData.fileEntries.set(fullPath, {fullPath, mediaType})
}
for(const {filename, content, options} of zipEntriesToAdd) {
await writer.add(filename, content, options);
}
const newZipFilenames = new Set(zipEntriesToAdd.map(ze => ze.filename))
if(!manifestFileData) {
throw new Error(`'META-INF/manifest.xml' zip entry missing`)
}
// remove ignored files from manifest.xml
for(const filename of manifestFileData.fileEntries.keys()) {
if(!newZipFilenames.has(filename)) {
manifestFileData.fileEntries.delete(filename)
}
}
const manifestFileXml = makeManifestFile(manifestFileData)
await writer.add('META-INF/manifest.xml', new TextReader(manifestFileXml));
await reader.close();
return writer.close();
}

View File

@ -1,10 +0,0 @@
// the regexps below are shared, so they shoudn't have state (no 'g' flag)
export const variableRegex = /\{([^{#\/:]+?)\}/
export const imageMarkerRegex = /{#image\s+([^}]+?)\s*}/;
export const ifStartMarkerRegex = /{#if\s+([^}]+?)\s*}/;
export const elseMarker = '{:else}'
export const closingIfMarker = '{/if}'
export const eachStartMarkerRegex = /{#each\s+([^}]+?)\s+as\s+([^}]+?)\s*}/;
export const eachClosingMarker = '{/each}'

View File

@ -1,530 +0,0 @@
//@ts-check
import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js";
import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js'
/**
*
* @param {string} text
* @param {string | RegExp} pattern
* @returns {{marker: string, index: number}[]}
*/
function findAllMatches(text, pattern) {
const results = [];
let match;
if(typeof pattern === 'string') {
// For string markers like elseMarker and closingIfMarker
let index = 0;
while((index = text.indexOf(pattern, index)) !== -1) {
results.push({
marker: pattern,
index: index
});
index += pattern.length;
}
} else {
// For regex patterns
pattern = new RegExp(pattern.source, 'g');
while((match = pattern.exec(text)) !== null) {
results.push({
marker: match[0],
index: match.index
});
}
}
return results;
}
/**
* text position of a node relative to a text nodes within a container
*
* @param {Text} node
* @param {Text[]} containerTextNodes
* @returns {number}
*/
function getNodeTextPosition(node, containerTextNodes) {
let position = 0;
for(const currentTextNode of containerTextNodes) {
if(currentTextNode === node) {
return position
}
else {
position += (currentTextNode.textContent || '').length;
}
}
throw new Error(`[${getNodeTextPosition.name}] None of containerTextNodes elements is equal to node`)
}
/** @typedef {Node[]} DOMPath */
/**
* remove nodes between startNode and endNode
* including startNode and endNode
*
* @param {Node} startNode
* @param {Node} endNode
* @param {string} text
* @returns {void}
*/
function replaceBetweenNodesWithText(startNode, endNode, text) {
// find both ancestry branch
const startNodeAncestors = new Set(getAncestors(startNode))
const endNodeAncestors = new Set(getAncestors(endNode))
// find common ancestor
const commonAncestor = findCommonAncestor(startNode, endNode)
let remove = false
let toRemove = []
let commonAncestorChild = commonAncestor.firstChild
let commonAncestorInsertionChild
while(commonAncestorChild){
if(startNodeAncestors.has(commonAncestorChild)){
remove = true
}
if(remove){
toRemove.push(commonAncestorChild)
if(endNodeAncestors.has(commonAncestorChild)){
commonAncestorInsertionChild = commonAncestorChild.nextSibling
break;
}
}
commonAncestorChild = commonAncestorChild.nextSibling
}
for(const node of toRemove){
commonAncestor.removeChild(node)
}
//console.log('replaceBetweenNodesWithText startNode', startNode.textContent)
const newTextNode = commonAncestor.ownerDocument.createTextNode(text)
if(commonAncestorInsertionChild){
commonAncestor.insertBefore(newTextNode, commonAncestorInsertionChild)
}
else{
commonAncestor.appendChild(newTextNode)
}
}
/**
* Consolidate markers which are split among several Text nodes
*
* @param {Document} document
*/
function consolidateMarkers(document){
// Perform a first pass to detect templating markers with formatting to remove it
const potentialMarkersContainers = [
...Array.from(document.getElementsByTagName('text:p')),
...Array.from(document.getElementsByTagName('text:h'))
]
for(const potentialMarkersContainer of potentialMarkersContainers) {
/** @type {{marker: string, index: number}[]} */
const consolidatedMarkers = []
/** @type {Text[]} */
let containerTextNodesInTreeOrder = [];
function refreshContainerTextNodes(){
containerTextNodesInTreeOrder = []
traverse(potentialMarkersContainer, node => {
if(node.nodeType === Node.TEXT_NODE) {
containerTextNodesInTreeOrder.push(/** @type {Text} */(node))
}
})
}
refreshContainerTextNodes()
let fullText = ''
for(const node of containerTextNodesInTreeOrder){
fullText = fullText + node.textContent
}
// Check for each template marker
const positionedMarkers = [
...findAllMatches(fullText, ifStartMarkerRegex),
...findAllMatches(fullText, elseMarker),
...findAllMatches(fullText, closingIfMarker),
...findAllMatches(fullText, eachStartMarkerRegex),
...findAllMatches(fullText, eachClosingMarker),
...findAllMatches(fullText, variableRegex)
];
//if(positionedMarkers.length >= 1)
// console.log('positionedMarkers', positionedMarkers)
while(consolidatedMarkers.length < positionedMarkers.length) {
refreshContainerTextNodes()
// For each marker, check if it's contained within a single text node
for(const positionedMarker of positionedMarkers.slice(consolidatedMarkers.length)) {
//console.log('positionedMarker', positionedMarker)
let currentPos = 0;
let startNode;
let endNode;
// Find which text node(s) contain this marker
for(const textNode of containerTextNodesInTreeOrder) {
const nodeStart = currentPos;
const nodeEnd = nodeStart + textNode.textContent.length;
// If start of marker is in this node
if(!startNode && positionedMarker.index >= nodeStart && positionedMarker.index < nodeEnd) {
startNode = textNode;
}
// If end of marker is in this node
if(startNode && positionedMarker.index + positionedMarker.marker.length > nodeStart &&
positionedMarker.index + positionedMarker.marker.length <= nodeEnd) {
endNode = textNode;
break;
}
currentPos = nodeEnd;
}
if(!startNode){
throw new Error(`Could not find startNode for marker '${positionedMarker.marker}'`)
}
if(!endNode){
throw new Error(`Could not find endNode for marker '${positionedMarker.marker}'`)
}
// Check if marker spans multiple nodes
if(startNode !== endNode) {
//console.log('startNode !== endNode', startNode.textContent, endNode.textContent)
const commonAncestor = findCommonAncestor(startNode, endNode)
/** @type {Node} */
let commonAncestorStartChild = startNode
while(commonAncestorStartChild.parentNode !== commonAncestor){
commonAncestorStartChild = commonAncestorStartChild.parentNode
}
/** @type {Node} */
let commonAncestorEndChild = endNode
while(commonAncestorEndChild.parentNode !== commonAncestor){
commonAncestorEndChild = commonAncestorEndChild.parentNode
}
// Calculate relative positions within the nodes
let startNodeTextContent = startNode.textContent || '';
let endNodeTextContent = endNode.textContent || '';
// Calculate the position within the start node
let posInStartNode = positionedMarker.index - getNodeTextPosition(startNode, containerTextNodesInTreeOrder);
// Calculate the position within the end node
let posInEndNode = (positionedMarker.index + positionedMarker.marker.length) - getNodeTextPosition(endNode, containerTextNodesInTreeOrder);
let newStartNode = startNode
// if there is before-text, split
if(posInStartNode > 0) {
// Text exists before the marker - preserve it
// set newStartNode to a Text node containing only the marker beginning
newStartNode = startNode.splitText(posInStartNode)
// startNode/beforeStartNode now contains only non-marker text
// then, by definition of .splitText(posInStartNode):
posInStartNode = 0
// move the marker beginning part to become a child of commonAncestor
newStartNode.parentNode?.removeChild(newStartNode)
commonAncestor.insertBefore(newStartNode, commonAncestorStartChild.nextSibling)
//console.log('commonAncestor after before-text split', commonAncestor.textContent )
}
// if there is after-text, split
if(posInEndNode < endNodeTextContent.length) {
// Text exists after the marker - preserve it
endNode.splitText(posInEndNode);
// endNode now contains only the end of marker text
// then, by definition of .splitText(posInEndNode):
posInEndNode = endNodeTextContent.length
// move the marker ending part to become a child of commonAncestor
if(endNode !== commonAncestorEndChild){
endNode.parentNode?.removeChild(endNode)
commonAncestor.insertBefore(endNode, commonAncestorEndChild)
}
//console.log('commonAncestor after after-text split', commonAncestor.textContent )
}
// then, replace all nodes between (new)startNode and (new)endNode with a single textNode in commonAncestor
replaceBetweenNodesWithText(newStartNode, endNode, positionedMarker.marker)
//console.log('commonAncestor after replaceBetweenNodesWithText', commonAncestor.textContent )
// After consolidation, break as the DOM structure has changed
// and containerTextNodesInTreeOrder needs to be refreshed
consolidatedMarkers.push(positionedMarker)
break;
}
consolidatedMarkers.push(positionedMarker)
}
}
//console.log('consolidatedMarkers', consolidatedMarkers)
}
}
/**
* @typedef {typeof closingIfMarker | typeof eachClosingMarker | typeof eachStartMarkerRegex.source | typeof elseMarker | typeof ifStartMarkerRegex.source | typeof variableRegex.source} MarkerType
*/
/**
* @typedef {Object} MarkerNode
* @prop {Node} node
* @prop {MarkerType} markerType
*/
/**
* isolate markers which are in Text nodes with other texts
*
* @param {Document} document
* @returns {Map<Node, MarkerType>}
*/
function isolateMarkerText(document){
/** @type {ReturnType<isolateMarkerText>} */
const markerNodes = new Map()
traverse(document, currentNode => {
//console.log('isolateMarkers', currentNode.nodeName, currentNode.textContent)
if(currentNode.nodeType === Node.TEXT_NODE) {
// find all marker starts and ends and split textNode
let remainingText = currentNode.textContent || ''
while(remainingText.length >= 1) {
let matchText;
let matchIndex;
/** @type {MarkerType} */
let markerType;
// looking for a block marker
for(const marker of [ifStartMarkerRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingMarker]) {
if(typeof marker === 'string') {
const index = remainingText.indexOf(marker)
if(index !== -1) {
matchText = marker
matchIndex = index
markerType = marker
// found the first match
break; // get out of loop
}
}
else {
// marker is a RegExp
const match = remainingText.match(marker)
if(match) {
matchText = match[0]
matchIndex = match.index
markerType = marker.source
// found the first match
break; // get out of loop
}
}
}
if(matchText) {
// split 3-way : before-match, match and after-match
if(matchText.length < remainingText.length) {
// @ts-ignore
let afterMatchTextNode = currentNode.splitText(matchIndex + matchText.length)
if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1) {
remainingText = afterMatchTextNode.textContent
}
else {
remainingText = ''
}
// per spec, currentNode now contains before-match and match text
/** @type {Node} */
let matchTextNode
// @ts-ignore
if(matchIndex > 0) {
// @ts-ignore
matchTextNode = currentNode.splitText(matchIndex)
}
else{
matchTextNode = currentNode
}
markerNodes.set(matchTextNode, markerType)
// per spec, currentNode now contains only before-match text
if(afterMatchTextNode) {
currentNode = afterMatchTextNode
}
}
else {
remainingText = ''
}
}
else {
remainingText = ''
}
}
}
else {
// skip
}
})
//console.log('markerNodes', [...markerNodes].map(([node, markerType]) => [node.textContent, markerType]))
return markerNodes
}
/**
* after isolateMatchingMarkersStructure, matching markers (opening/closing each, if/then/closing if)
* are put in isolated branches within their common ancestors
*
* UNFINISHED - maybe another day if relevant
*
* @param {Document} document
* @param {Map<Node, MarkerType>} markerNodes
*/
//function isolateMatchingMarkersStructure(document, markerNodes){
/** @type {MarkerNode[]} */
/* let currentlyOpenBlocks = []
traverse(document, currentNode => {
const markerType = markerNodes.get(currentNode)
if(markerType){
switch(markerType){
case eachStartMarkerRegex.source:
case ifStartMarkerRegex.source: {
currentlyOpenBlocks.push({
node: currentNode,
markerType
})
break;
}
case eachClosingMarker: {
const lastOpenedBlockMarkerNode = currentlyOpenBlocks.pop()
if(!lastOpenedBlockMarkerNode)
throw new Error(`{/each} found without corresponding opening {#each x as y}`)
if(lastOpenedBlockMarkerNode.markerType !== eachStartMarkerRegex.source)
throw new Error(`{/each} found while the last opened block was not an opening {#each x as y} (it was a ${lastOpenedBlockMarkerNode.markerType})`)
const openingEachNode = lastOpenedBlockMarkerNode.node
const closingEachNode = currentNode
const commonAncestor = findCommonAncestor(openingEachNode, closingEachNode)
if(openingEachNode.parentNode !== commonAncestor && openingEachNode.parentNode.childNodes.length >= 2){
if(openingEachNode.previousSibling){
// create branch for previousSiblings
let previousSibling = openingEachNode.previousSibling
const previousSiblings = []
while(previousSibling){
previousSiblings.push(previousSibling.previousSibling)
previousSibling = previousSibling.previousSibling
}
// put previous siblings in tree order
previousSiblings.reverse()
const parent = openingEachNode.parentNode
const parentClone = parent.cloneNode(false)
for(const previousSibling of previousSiblings){
previousSibling.parentNode.removeChild(previousSibling)
parentClone.appendChild(previousSibling)
}
let openingEachNodeBranch = openingEachNode.parentNode
let branchForPreviousSiblings = parentClone
while(openingEachNodeBranch.parentNode !== commonAncestor){
const newParentClone = openingEachNodeBranch.parentNode.cloneNode(false)
branchForPreviousSiblings.parentNode.removeChild(branchForPreviousSiblings)
newParentClone.appendChild(branchForPreviousSiblings)
}
}
}
break;
}
default:
throw new TypeError(`MarkerType not recognized: '${markerType}`)
}
}
})
}*/
/**
* This function prepares the template DOM tree in a way that makes it easily processed by the template execution
* Specifically, after the call to this function, the document is altered to respect the following property:
*
* each template marker ({#each ... as ...}, {/if}, etc.) placed within a single Text node
*
* If the template marker was partially formatted in the original document, the formatting is removed so the
* marker can be within a single Text node
*
* If the template marker was in a Text node with other text, the Text node is split in a way to isolate the marker
* from the rest of the text
*
* @param {Document} document
*/
export default function prepareTemplateDOMTree(document){
consolidateMarkers(document)
// after consolidateMarkers, each marker is in at most one text node
// (formatting with markers is removed)
isolateMarkerText(document)
// after isolateMarkerText, each marker is in exactly one text node
// (markers are separated from text that was before or after in the same text node)
}

View File

@ -1,23 +1,22 @@
//@ts-check
import { Uint8ArrayReader, ZipReader, TextWriter } from '@zip.js/zip.js';
import {parseXML} from './DOMUtils.js'
/** @import {Entry} from '@zip.js/zip.js'*/
/** @import {SheetName, SheetRawContent, SheetRowRawContent, SheetCellRawContent, OdfjsImage} from './types.js' */
/** @import {SheetName, SheetRawContent, SheetRowRawContent, SheetCellRawContent} from './types.js' */
// https://dom.spec.whatwg.org/#interface-node
const TEXT_NODE = 3
/**
*
* @param {Element} cell
*
* @param {Element} cell
* @returns {string}
*/
function extraxtODSCellText(cell) {
let text = '';
const childNodes = cell.childNodes;
for (const child of Array.from(childNodes)) {
if (child.nodeType === TEXT_NODE) {
// Direct text node, append the text directly
@ -33,15 +32,13 @@ function extraxtODSCellText(cell) {
text += pChild.nodeValue; // Append text inside <text:p>
} else if (pChild.nodeName === 'text:line-break') {
text += '\n'; // Append newline for <text:line-break />
} else if (pChild.nodeName === 'text:a' || pChild.nodeName === 'text:span') {
text += pChild.textContent
}
}
} else if (child.nodeName === 'text:line-break') {
text += '\n'; // Append newline for <text:line-break /> directly under <table:table-cell>
}
}
return text.trim();
}
@ -49,9 +46,10 @@ function extraxtODSCellText(cell) {
/**
* Extracts raw table content from an ODS file.
* @param {ArrayBuffer} arrayBuffer - The ODS file.
* @param {(str: string) => Document} parseXML - Function to parse XML content.
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
export async function getODSTableRawContent(arrayBuffer) {
export async function _getODSTableRawContent(arrayBuffer, parseXML) {
const zipDataReader = new Uint8ArrayReader(new Uint8Array(arrayBuffer));
const zipReader = new ZipReader(zipDataReader);
const zipEntries = await zipReader.getEntries()
@ -125,9 +123,104 @@ export async function getODSTableRawContent(arrayBuffer) {
}
/**
* Extracts raw table content from an XLSX file.
* @param {ArrayBuffer} arrayBuffer - The XLSX file.
* @param {(str: string) => Document} parseXML - Function to parse XML content.
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
export async function _getXLSXTableRawContent(arrayBuffer, parseXML) {
const zipDataReader = new Uint8ArrayReader(new Uint8Array(arrayBuffer));
const zipReader = new ZipReader(zipDataReader);
const zipEntries = await zipReader.getEntries()
await zipReader.close();
/** @type {Map<Entry['filename'], Entry>} */
const entryByFilename = new Map()
for(const entry of zipEntries){
const filename = entry.filename
entryByFilename.set(filename, entry)
}
const sharedStringsEntry = entryByFilename.get('xl/sharedStrings.xml')
if(!sharedStringsEntry){
throw new TypeError(`entry 'xl/sharedStrings.xml' manquante dans le zip`)
}
//@ts-ignore
const sharedStringsXml = await sharedStringsEntry.getData(new TextWriter());
const sharedStringsDoc = parseXML(sharedStringsXml);
const sharedStrings = Array.from(sharedStringsDoc.getElementsByTagName('sst')[0].getElementsByTagName('si')).map(si => si.textContent);
// Get sheet names and their corresponding XML files
const workbookEntry = entryByFilename.get('xl/workbook.xml')
if(!workbookEntry){
throw new TypeError(`entry 'xl/workbook.xml' manquante dans le zip`)
}
//@ts-ignore
const workbookXml = await workbookEntry.getData(new TextWriter());
const workbookDoc = parseXML(workbookXml);
const sheets = Array.from(workbookDoc.getElementsByTagName('sheets')[0].getElementsByTagName('sheet'));
const sheetNames = sheets.map(sheet => sheet.getAttribute('name'));
const sheetIds = sheets.map(sheet => sheet.getAttribute('r:id'));
// Read the relations to get the actual filenames for each sheet
const workbookRelsEntry = entryByFilename.get('xl/_rels/workbook.xml.rels')
if(!workbookRelsEntry){
throw new TypeError(`entry 'xl/_rels/workbook.xml.rels' manquante dans le zip`)
}
//@ts-ignore
const workbookRelsXml = await workbookRelsEntry.getData(new TextWriter());
const workbookRelsDoc = parseXML(workbookRelsXml);
const sheetRels = Array.from(workbookRelsDoc.getElementsByTagName('Relationship'));
const sheetFiles = sheetIds.map(id => sheetRels.find(rel => rel.getAttribute('Id') === id).getAttribute('Target').replace('worksheets/', ''));
// Read each sheet's XML and extract data in parallel
const sheetDataPs = sheetFiles.map((sheetFile, index) => (
// @ts-ignore
entryByFilename.get(`xl/worksheets/${sheetFile}`).getData(new TextWriter()).then(sheetXml => {
const sheetDoc = parseXML(sheetXml);
const rows = sheetDoc.getElementsByTagName('sheetData')[0].getElementsByTagName('row');
const sheetData = [];
for (let row of Array.from(rows)) {
const cells = row.getElementsByTagName('c');
const rowData = [];
for (let cell of Array.from(cells)) {
const cellType = cell.getAttribute('t') || 'n';
let cellValue = cell.getElementsByTagName('v')[0]?.textContent || '';
if (cellType === 's') {
cellValue = sharedStrings[parseInt(cellValue, 10)];
}
rowData.push({
value: cellValue,
type: cellType
});
}
sheetData.push(rowData);
}
return [sheetNames[index], sheetData];
})
));
return new Map(await Promise.all(sheetDataPs));
}
/**
* Converts a cell value to the appropriate JavaScript type based on its cell type.
* @param {SheetCellRawContent} _
* @param {SheetCellRawContent} _
* @returns {number | boolean | string | Date} The converted value.
*/
export function convertCellValue({value, type}) {
@ -161,37 +254,21 @@ export function convertCellValue({value, type}) {
}
/**
* @param {unknown} value
* @returns {value is OdfjsImage}
*/
export function isOdfjsImage(value) {
if (typeof value === 'object' && value!==null
&& "content" in value && value.content instanceof ArrayBuffer
&& "fileName" in value && typeof value.fileName === 'string'
&& "mediaType" in value && typeof value.mediaType === 'string'
) {
return true
} else {
return false
}
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, ReturnType<convertCellValue>[][]>}
*/
export function tableRawContentToValues(rawContentSheets){
return new Map(
[...rawContentSheets].map(([sheetName, rawContent]) => {
return [
sheetName,
sheetName,
rawContent
.map(row => row.map(c => convertCellValue(c)))
]
@ -204,7 +281,7 @@ export function tableRawContentToValues(rawContentSheets){
*/
/**
*
*
* @param {SheetCellRawContent} rawContentCell
* @returns {string}
*/
@ -213,8 +290,8 @@ export function cellRawContentToStrings(rawContentCell){
}
/**
*
* @param {SheetRowRawContent} rawContentRow
*
* @param {SheetRowRawContent} rawContentRow
* @returns {string[]}
*/
export function rowRawContentToStrings(rawContentRow){
@ -222,8 +299,8 @@ export function rowRawContentToStrings(rawContentRow){
}
/**
*
* @param {SheetRawContent} rawContentSheet
*
* @param {SheetRawContent} rawContentSheet
* @returns {string[][]}
*/
export function sheetRawContentToStrings(rawContentSheet){
@ -231,8 +308,8 @@ export function sheetRawContentToStrings(rawContentSheet){
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, string[][]>}
*/
export function tableRawContentToStrings(rawContentSheets){
@ -253,16 +330,16 @@ export function tableRawContentToStrings(rawContentSheets){
/**
* This function expects the first row to contain string values which are used as column names
* It outputs an array of objects which keys are
* It outputs an array of objects which keys are
*
* @param {SheetRawContent} rawContent
* @param {SheetRawContent} rawContent
* @returns {any[]}
*/
export function sheetRawContentToObjects(rawContent){
let [firstRow, ...dataRows] = rawContent
/** @type {string[]} */
const columns = firstRow.map((r, i) => {
if (r.value === undefined || r.value === null || r.value === "") {
return `Column ${i+1}`
@ -284,8 +361,8 @@ export function sheetRawContentToObjects(rawContent){
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, any[]>}
*/
export function tableRawContentToObjects(rawContentSheets){
@ -312,7 +389,7 @@ export function isCellFilled({value}){
}
/**
* @param {SheetRowRawContent} rawContentRow
* @param {SheetRowRawContent} rawContentRow
* @returns {boolean}
*/
export function isRowNotEmpty(rawContentRow){
@ -320,7 +397,7 @@ export function isRowNotEmpty(rawContentRow){
}
/**
* @param {SheetRawContent} sheet
* @param {SheetRawContent} sheet
* @returns {SheetRawContent}
*/
export function removeEmptyRowsFromSheet(sheet){
@ -329,8 +406,8 @@ export function removeEmptyRowsFromSheet(sheet){
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentTable
*
* @param {Map<SheetName, SheetRawContent>} rawContentTable
* @returns {Map<SheetName, SheetRawContent>}
*/
export function tableWithoutEmptyRows(rawContentTable){
@ -339,4 +416,4 @@ export function tableWithoutEmptyRows(rawContentTable){
return [sheetName, removeEmptyRowsFromSheet(rawContent)]
})
)
}
}

View File

@ -10,12 +10,4 @@
/** @typedef {string} SheetName */
/**
* @typedef OdfjsImage
* @prop {ArrayBuffer} content
* @prop {string} fileName
* @prop {string} mediaType
*
*/
export {}

View File

@ -2,9 +2,9 @@ import {readFile} from 'node:fs/promises'
import test from 'ava';
import {getODSTableRawContent} from '../exports.js'
import {getODSTableRawContent} from '../scripts/node.js'
const nomAgeContent = (await readFile('./tests/fixtures/nom-age.ods')).buffer
const nomAgeContent = (await readFile('./tests/data/nom-age.ods')).buffer
test('basic', async t => {
const table = await getODSTableRawContent(nomAgeContent);

View File

@ -1,6 +1,6 @@
import test from 'ava';
import {getODSTableRawContent, createOdsFile} from '../exports.js'
import {getODSTableRawContent, createOdsFile} from '../scripts/node.js'
/** @import {SheetName, SheetRawContent} from '../scripts/types.js' */

View File

@ -1,13 +1,44 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {getOdtTemplate, getOdtTextContent} from '../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate} from '../scripts/node.js'
import { listZipEntries } from './_helpers/zip-analysis.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
`)
});
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('basic template filling with {#each}', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/enum-courses.odt')
const templatePath = join(import.meta.dirname, './data/enum-courses.odt')
const templateContent = `🧺 La liste de courses incroyable 🧺
{#each listeCourses as élément}
@ -28,7 +59,7 @@ test('basic template filling with {#each}', async t => {
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
try{
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
@ -38,44 +69,14 @@ Radis
Jus d'orange
Pâtes à lasagne (fraîches !)
`)
}catch(e){console.error(e); throw e}
});
test('Filling with {#each} and non-iterable value results in no error and empty result', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/enum-courses.odt')
const templateContent = `🧺 La liste de courses incroyable 🧺
{#each listeCourses as élément}
{élément}
{/each}
`
const data = {
listeCourses : undefined
}
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 🧺
`)
});
test('template filling with {#each} generating a list', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/liste-courses.odt')
const templatePath = join(import.meta.dirname, './data/liste-courses.odt')
const templateContent = `🧺 La liste de courses incroyable 🧺
- {#each listeCourses as élément}
@ -112,7 +113,7 @@ test('template filling with {#each} generating a list', async t => {
test('template filling with 2 sequential {#each}', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/liste-fruits-et-légumes.odt')
const templatePath = join(import.meta.dirname, './data/liste-fruits-et-légumes.odt')
const templateContent = `Liste de fruits et légumes
Fruits
@ -163,8 +164,9 @@ Poivron 🫑
});
test('template filling with nested {#each}s', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/légumes-de-saison.odt')
const templatePath = join(import.meta.dirname, './data/légumes-de-saison.odt')
const templateContent = `Légumes de saison
{#each légumesSaison as saisonLégumes}
@ -248,45 +250,9 @@ Hiver
});
test('template filling with text after {/each} in same text node', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/text-after-closing-each.odt')
const templateContent = `Légumes de saison
{#each légumes as légume}
{légume},
{/each} en {saison}
`
const data = {
saison: 'Printemps',
légumes: [
'Asperge',
'Betterave',
'Blette'
]
}
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
Asperge,
Betterave,
Blette,
en Printemps
`)
});
test('template filling of a table', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/tableau-simple.odt')
const templatePath = join(import.meta.dirname, './data/tableau-simple.odt')
const templateContent = `Évolution énergie en kWh par personne en France
Année
@ -346,30 +312,33 @@ Année
});
test('nested each without common ancestor for inner each', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/nested-each-without-common-ancestor-for-inner-each.odt')
const templateContent = `{#each liste_espèces_par_impact as élément}
{#each élément.liste_espèces as espèce}
{/each}
{/each}
`
test('template filling preserves images', async t => {
const templatePath = join(import.meta.dirname, './data/template-avec-image.odt')
const data = {
liste_espèces_par_impact: [
{}
]
commentaire : `J'adooooooore 🤩 West covinaaaaaaaaaaa 🎶`
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateEntries = await listZipEntries(odtTemplate)
const templateTextContent = await getOdtTextContent(odtTemplate)
//console.log('templateEntries', templateEntries.map(({filename, directory}) => ({filename, directory})))
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
t.assert(
templateEntries.find(entry => entry.filename.startsWith('Pictures/')),
`One zip entry of the template is expected to have a name that starts with 'Pictures/'`
)
const odtResult = await fillOdtTemplate(odtTemplate, data)
const resultEntries = await listZipEntries(odtResult)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, ``)
//console.log('resultEntries', resultEntries.map(({filename, directory}) => ({filename, directory})))
t.assert(
resultEntries.find(entry => entry.filename.startsWith('Pictures/')),
`One zip entry of the result is expected to have a name that starts with 'Pictures/'`
)
});
})

View File

@ -1,36 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('basic template filling with variable substitution', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/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
`)
});

View File

@ -1,25 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('template with {#each} inside an {#if}', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/if-then-each.odt')
const templateContent = `{#if liste_départements.length >= 2}{#each liste_départements as département}{département}, {/each} {/if}`
const data = {liste_départements : ['95', '33']}
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(), `95, 33,`)
});

View File

@ -1,211 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('template filling with several layers of formatting in {#each ...} start marker', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-plusieurs-couches.odt')
const templateContent = `Liste de nombres
Les nombres : {#each nombres as n}{n} {/each} !!
`
const data = {
nombres : [1,2,3,5]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de nombres
Les nombres : 1 2 3 5  !!
`)
});
test('template filling - both {#each ...} and {/each} within the same Text node are formatted', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-2-markeurs-formatted.odt')
const templateContent = `Liste de nombres
Les nombres : {#each nombres as n}{n} {/each} !!
`
const data = {
nombres : [2,3,5,8]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de nombres
Les nombres : 2 3 5 8  !!
`)
});
test('template filling - {#each ...} and text before partially formatted', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-each-start-and-before-formatted.odt')
const templateContent = `Liste de nombres
Les nombres : {#each nombres as n}{n} {/each} !!
`
const data = {
nombres : [3,5,8, 13]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de nombres
Les nombres : 3 5 8 13  !!
`)
});
test('template filling - {/each} and text after partially formatted', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/formatting-liste-nombres-each-end-and-after-formatted.odt')
const templateContent = `Liste de nombres
Les nombres : {#each nombres as n}{n} {/each} !!
`
const data = {
nombres : [5,8, 13, 21]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de nombres
Les nombres : 5 8 13 21  !!
`)
});
test('template filling - partially formatted variable', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/partially-formatted-variable.odt')
const templateContent = `Nombre
Voici le nombre : {nombre} !!!
`
const data = {nombre : 37}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
//try{
const odtResult = await fillOdtTemplate(odtTemplate, data)
//}catch(e){console.error(e)}
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Nombre
Voici le nombre : 37 !!!
`)
});
test('template filling - formatted-start-each-single-paragraph', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/formatted-start-each-single-paragraph.odt')
const templateContent = `
{#each nombres as n}
{n}
{/each}
`
const data = {nombres : [37, 38, 39]}
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, `
37
38
39
`)
});
test('template filling - formatted ghost if then', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/ghost-if.odt')
const templateContent = `
Utilisation de sources lumineuses : {#if scientifique.source_lumineuses}Oui{:else}Non{/if}
`
const data = {scientifique: {source_lumineuses: true}}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent.trim(), templateContent.trim(), 'reconnaissance du template')
let odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent.trim(), `
Utilisation de sources lumineuses : Oui
`.trim())
});
test('template filling - formatted ghost if else', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/ghost-if.odt')
const templateContent = `
Utilisation de sources lumineuses : {#if scientifique.source_lumineuses}Oui{:else}Non{/if}
`
const data = {scientifique: {source_lumineuses: false}}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent.trim(), templateContent.trim(), 'reconnaissance du template')
let odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent.trim(), `
Utilisation de sources lumineuses : Non
`.trim())
});

View File

@ -1,91 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('basic template filling with {#if}{:else} - then branch', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/description-nombre.odt')
const templateContent = `Description du nombre {n}
{#if n<5}
n est un petit nombre
{:else}
n est un grand nombre
{/if}
`
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
// then branch
const odtResult3 = await fillOdtTemplate(odtTemplate, {n: 3})
const odtResult3TextContent = await getOdtTextContent(odtResult3)
t.deepEqual(odtResult3TextContent, `Description du nombre 3
n est un petit nombre
`)
});
test('basic template filling with {#if}{:else} - else branch', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/description-nombre.odt')
const templateContent = `Description du nombre {n}
{#if n<5}
n est un petit nombre
{:else}
n est un grand nombre
{/if}
`
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
try{
// else branch
const odtResult8 = await fillOdtTemplate(odtTemplate, {n: 8})
const odtResult8TextContent = await getOdtTextContent(odtResult8)
t.deepEqual(odtResult8TextContent, `Description du nombre 8
n est un grand nombre
`)
}
catch(e){console.error(e); throw e}
});
test('complex structured if', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/left-branch-content-and-two-consecutive-ifs.odt')
const templateContent = `Utilisation de sources lumineuses : {#if scientifique.source_lumineuses}Oui{:else}Non{/if}
{#if scientifique.source_lumineuses && scientifique.modalités_source_lumineuses }
Modalités dutilisation de sources lumineuses : {scientifique.modalités_source_lumineuses}
{/if}
`
const data = {
scientifique: {
source_lumineuses: false,
//modalités_source_lumineuses: 'lampes torches'
}
}
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(), `Utilisation de sources lumineuses : Non`)
});

View File

@ -1,84 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import { readFile } from 'node:fs/promises'
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
import { listZipEntries } from '../helpers/zip-analysis.js';
import { getContentDocument } from '../../scripts/odf/odt/getOdtTextContent.js';
test('template filling preserves images', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/template-avec-image.odt')
const data = {
commentaire : `J'adooooooore 🤩 West covinaaaaaaaaaaa 🎶`
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateEntries = await listZipEntries(odtTemplate)
//console.log('templateEntries', templateEntries.map(({filename, directory}) => ({filename, directory})))
t.assert(
templateEntries.find(entry => entry.filename.startsWith('Pictures/')),
`One zip entry of the template is expected to have a name that starts with 'Pictures/'`
)
const odtResult = await fillOdtTemplate(odtTemplate, data)
const resultEntries = await listZipEntries(odtResult)
//console.log('resultEntries', resultEntries.map(({filename, directory}) => ({filename, directory})))
t.assert(
resultEntries.find(entry => entry.filename.startsWith('Pictures/')),
`One zip entry of the result is expected to have a name that starts with 'Pictures/'`
)
})
test('insert 2 images', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/basic-image-insertion.odt')
const odtTemplate = await getOdtTemplate(templatePath)
const templateContent = `{title}
{#each photos as photo}
{#image photo}
{/each}
`
const templateTextContent = await getOdtTextContent(odtTemplate)
t.is(templateTextContent, templateContent, 'reconnaissance du template')
const photo1Path = join(import.meta.dirname, '../fixtures/pitchou-1.png')
const photo2Path = join(import.meta.dirname, '../fixtures/pitchou-2.png')
const photo1Buffer = (await readFile(photo1Path)).buffer
const photo2Buffer = (await readFile(photo2Path)).buffer
const photos = [{content: photo1Buffer, fileName: 'pitchou-1.png', mediaType: 'image/png'}, {content: photo2Buffer, fileName: 'pitchou-2.png', mediaType: 'image/png'}]
const data = {
title: 'Titre de mon projet',
photos,
}
const odtResult = await fillOdtTemplate(odtTemplate, data)
const resultEntries = await listZipEntries(odtResult)
t.is(
resultEntries.filter(entry => entry.filename.startsWith('Pictures/')).length, 2,
`Two pictures in 'Pictures/' folder are expected`
)
const odtContentDocument = await getContentDocument(odtResult)
const drawImageElements = odtContentDocument.getElementsByTagName('draw:image')
t.is(drawImageElements.length, 2, 'Two draw:image elements should be in the generated document.')
})

View File

@ -1,65 +0,0 @@
import test from 'ava';
import {join} from 'node:path';
import {getOdtTemplate} from '../../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate, getOdtTextContent} from '../../exports.js'
test('template filling {#if ...}{/if} within a single text node', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/inline-if-nombres.odt')
const templateContent = `Taille de nombre
Le nombre {n} est {#if n<5}petit{:else}grand{/if}.
`
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult3 = await fillOdtTemplate(odtTemplate, {n : 3})
const odtResult3TextContent = await getOdtTextContent(odtResult3)
t.deepEqual(odtResult3TextContent, `Taille de nombre
Le nombre 3 est petit.
`)
const odtResult9 = await fillOdtTemplate(odtTemplate, {n : 9})
const odtResult9TextContent = await getOdtTextContent(odtResult9)
t.deepEqual(odtResult9TextContent, `Taille de nombre
Le nombre 9 est grand.
`)
});
test('template filling {#each ...}{/each} within a single text node', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/liste-nombres.odt')
const templateContent = `Liste de nombres
Les nombres : {#each nombres as n}{n} {/each} !!
`
const data = {
nombres : [1,1,2,3,5,8,13,21]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de nombres
Les nombres : 1 1 2 3 5 8 13 21  !!
`)
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

View File

@ -2,10 +2,10 @@ import {readFile} from 'node:fs/promises'
import test from 'ava';
import {getODSTableRawContent} from '../exports.js'
import {getODSTableRawContent} from '../scripts/node.js'
test('.ods file with table:number-columns-repeated attribute in cell', async t => {
const repeatedCellFileContent = (await readFile('./tests/fixtures/cellules-répétées.ods')).buffer
const repeatedCellFileContent = (await readFile('./tests/data/cellules-répétées.ods')).buffer
const table = await getODSTableRawContent(repeatedCellFileContent);
@ -17,7 +17,7 @@ test('.ods file with table:number-columns-repeated attribute in cell', async t =
test('.ods cells with dates should be recognized', async t => {
const odsFileWithDates = (await readFile('./tests/fixtures/cellules avec dates.ods')).buffer
const odsFileWithDates = (await readFile('./tests/data/cellules avec dates.ods')).buffer
const table = await getODSTableRawContent(odsFileWithDates);
const feuille1 = table.get('Feuille1')
@ -39,7 +39,7 @@ test('.ods cells with dates should be recognized', async t => {
test('.ods file with new lines in content is ', async t => {
const repeatedCellFileContent = (await readFile('./tests/fixtures/cellule avec sauts.ods')).buffer
const repeatedCellFileContent = (await readFile('./tests/data/cellule avec sauts.ods')).buffer
const table = await getODSTableRawContent(repeatedCellFileContent);
@ -55,34 +55,4 @@ Pourquoi t'aimeraient-ils seulement comme tu es ? (hein)
Si t'es pas comme eux quand t'es naturel`
t.deepEqual(feuille1[0][0].value, expectedValue)
});
test('.ods cells with mails should be recognized', async t => {
const odsFileWithEmails = (await readFile('./tests/fixtures/cellules avec emails.ods')).buffer
const table = await getODSTableRawContent(odsFileWithEmails);
const feuille1 = table.get('Feuille1')
const row1 = feuille1[0]
t.deepEqual(row1[0].value, 'Nom')
t.deepEqual(row1[1].value, 'Email')
const row2 = feuille1[1]
t.deepEqual(row2[0].value, 'Dav')
t.deepEqual(row2[1].value, 'david@example.org')
const row3 = feuille1[2]
t.deepEqual(row3[0].value, 'Fanny')
t.deepEqual(row3[1].value, 'lemaildeFanny@example.com')
});
test('.ods cells with partially styled content should be recognized', async t => {
const odsFileWithStyle = (await readFile('./tests/fixtures/cellule avec style.ods')).buffer;
const table = await getODSTableRawContent(odsFileWithStyle);
const feuille1 = table.get('Feuille1');
const row1 = feuille1[0];
t.deepEqual(row1[0].value, 'Toto titi');
});
});

View File

@ -1,5 +1,5 @@
import test from 'ava';
import { sheetRawContentToObjects } from "../exports.js"
import { sheetRawContentToObjects } from "../scripts/shared.js"
test("Empty header value should be kept", t => {
const rawContent = [

View File

@ -1,28 +1,31 @@
import {writeFile, readFile} from 'node:fs/promises'
import {writeFile} from 'node:fs/promises'
import {join} from 'node:path';
import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate} from '../exports.js'
import {fillOdtTemplate} from '../scripts/node.js'
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/template-anniversaire.odt')
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/fixtures/enum-courses.odt')
/*
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/fixtures/liste-fruits-et-légumes.odt')
const templatePath = join(import.meta.dirname, '../tests/data/liste-fruits-et-légumes.odt')
const data = {
fruits : [
'Pastèque 🍉',
@ -37,7 +40,7 @@ const data = {
}*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/légumes-de-saison.odt')
const templatePath = join(import.meta.dirname, '../tests/data/légumes-de-saison.odt')
const data = {
légumesSaison : [
{
@ -76,8 +79,8 @@ const data = {
}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/tableau-simple.odt')
const templatePath = join(import.meta.dirname, '../tests/data/tableau-simple.odt')
const data = {
annéeConsos : [
{ année: 1970, conso: 36252.637},
@ -88,60 +91,16 @@ const data = {
{ année: 2020, conso: 37859.246},
]
}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/template-avec-image.odt')
const templatePath = join(import.meta.dirname, '../tests/data/template-avec-image.odt')
const data = {
commentaire : `J'adooooooore 🤩 West covinaaaaaaaaaaa 🎶`
}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/partially-formatted-variable.odt')
const data = {nombre : 37}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/text-after-closing-each.odt')
const data = {
saison: 'Printemps',
légumes: [
'Asperge',
'Betterave',
'Blette'
]
}
*/
// const templatePath = join(import.meta.dirname, '../tests/fixtures/text-after-closing-each.odt')
// const data = {
// saison: 'Printemps',
// légumes: [
// 'Asperge',
// 'Betterave',
// 'Blette'
// ]
// }
// const templatePath = join(import.meta.dirname, '../tests/fixtures/if-then-each.odt')
// const data = {liste_départements : ['95', '33']}
const templatePath = join(import.meta.dirname, '../tests/fixtures/basic-image-insertion.odt')
const photo1Path = join(import.meta.dirname, '../tests/fixtures/pitchou-1.png')
const photo2Path = join(import.meta.dirname, '../tests/fixtures/pitchou-2.png')
const photo1Buffer = (await readFile(photo1Path)).buffer
const photo2Buffer = (await readFile(photo2Path)).buffer
const photos = [{content: photo1Buffer, fileName: 'pitchou-1.png', mediaType: 'image/png'}, {content: photo2Buffer, fileName: 'pitchou-2.png', mediaType: 'image/png'}]
const data = {
title: 'Titre de mon projet',
photos,
}
const odtTemplate = await getOdtTemplate(templatePath)
const odtResult = await fillOdtTemplate(odtTemplate, data)