Compare commits

...

97 Commits
v0.1.0 ... main

Author SHA1 Message Date
e6e09a0361 Properly support currency fields
Some checks failed
Build and Deploy / build (push) Failing after 5m8s
Build and Deploy / deploy (push) Has been skipped
2026-02-05 03:32:14 -07:00
3d98e41d2b Add column width calculation
Some checks failed
Build and Deploy / build (push) Failing after 11s
Build and Deploy / deploy (push) Has been skipped
2026-02-05 01:54:56 -07:00
7c87d3220d Update repository url
Some checks failed
Build and Deploy / build (push) Failing after 12s
Build and Deploy / deploy (push) Has been skipped
2026-02-05 01:10:55 -07:00
8b60ceb39c Add bold cell formatting option
Some checks failed
Build and Deploy / build (push) Failing after 14s
Build and Deploy / deploy (push) Has been skipped
2026-02-05 01:10:10 -07:00
Hannaeko
c677c7267b bump readme version 2025-09-22 16:35:43 +02:00
Hannaeko
415a26e3f1 0.30.0 2025-09-22 16:35:13 +02:00
hannaeko
90b97e23e9
fix text extraction for cells with partial styling (#23) 2025-09-22 16:34:43 +02:00
David Bruant
02d5338634 bump readme version 2025-09-18 16:16:21 +02:00
David Bruant
542183a593 0.29.0 2025-09-18 16:15:55 +02:00
David Bruant
a31f57026d
Remove default call to lockdown (#22)
* restore test

* suppression de l'appel à lockdown par défaut

* improving readme

* ses@1.14

* add section on securing fillOdtTemplate
2025-09-18 16:15:24 +02:00
David Bruant
98817e9d34 bump readme version 2025-09-18 10:41:35 +02:00
David Bruant
ca40785155 0.28.0 2025-09-18 10:41:10 +02:00
Clémence
8cc74a6fe6
Extract text inside a text:a tag (#21)
* Extract text inside a text:a tag

* Extract text inside a text:a tag and create a test

* Fix test regression

* Use example domain name for mails

* nettoyage console.log

---------

Co-authored-by: David Bruant <davidbruant@protonmail.com>
2025-09-18 10:36:59 +02:00
Clémence Fernandez
0938a97a83 bump readme version 0.27.0 2025-09-16 16:45:34 +02:00
Clémence Fernandez
8b0c1c6eb0 0.27.0 2025-09-16 16:44:27 +02:00
Clémence
d934d0dfb0
Add images (#16)
* add template

* Rename template

* Add test for insert 2 images

* image marker regex

* Ajout d'un test pour vérifier que le texte du template est bon

* WORK IN PROGRESS - trouver et évaluer la balise image

* Create OfjsImage type

* create addImageToOdtFile

* Regenerate yo odt to inspect it

* Add a draw image and a draw frame into odt file

* Test if there are two draw:image in the generated document

* Add pictures in manifest.xml to fix corrupted file

* Adapt anchor type

* Fix images aspect with ratio
2025-09-16 16:43:47 +02:00
David Bruant
a4d273793e bump readme version 2025-09-12 17:23:37 +02:00
David Bruant
9d942899ed 0.26.0 2025-09-12 17:23:15 +02:00
David Bruant
8d3d91da2f
Error thrown by mistake for {#each} inside an {#if} (#19)
* reduced test case

* test case

* adding complex test case
2025-09-12 17:22:19 +02:00
David Bruant
fbadfc7144 0.25.0 2025-07-19 14:07:54 +02:00
David Bruant
e0b2316c42 bump readme version 2025-07-19 14:07:46 +02:00
David Bruant
8eb15ad97c
fillOdtElementTemplate now takes an array of nodes as argument for the situation of calling it on the middleContent of a block with several elements so they're evaluated together (#15)
* reproducing bug

* reducing test case

* fillOdtElementTemplate now takes an array of nodes as argument for the situation of calling it on the middleContent of a block with several elements so they're evaluated together
2025-07-19 14:07:10 +02:00
David Bruant
136b240b99 0.24.0 2025-05-27 14:56:15 +02:00
David Bruant
4c67eaacd0 bump readme version 2025-05-27 14:56:10 +02:00
David Bruant
5aac86553c
Ouinon bug (#10)
* rename

* change order of removals in fillIfBlock to properly remove right/left content
2025-05-27 14:55:53 +02:00
David Bruant
9fa9c9eb62 0.23.0 2025-05-27 08:20:31 +02:00
David Bruant
38b146155c bump readme version 2025-05-27 08:20:23 +02:00
David Bruant
2d559bac5d
Better abstraction (#9)
* beginning of refactoring - if tests passing

* Beginning of passing tests for each

* Les tests each passent

* progress

* Les tests passent
2025-05-27 08:19:51 +02:00
David Bruant
a1e99b4519 0.22.0 2025-05-21 15:38:49 +02:00
David Bruant
4f49685acd bump readme version 2025-05-21 15:38:35 +02:00
David Bruant
03c8188bf5 add package.files field 2025-05-21 15:35:56 +02:00
David Bruant
6bb363485d 0.21.0 2025-05-21 15:10:28 +02:00
David Bruant
95a20b23c0 readme version bump 2025-05-21 15:10:25 +02:00
David Bruant
29a2429c00
Bug if 2 (#8)
* Adjust variable marker to ignore {:else}

* Getting start branch right content and end branch left content when extracting block content

* remove open if block after {/if}

* look for left/right branch content only up to common ancestor

* fix test case to new reality

* fix minor bug
2025-05-21 15:09:47 +02:00
David Bruant
4e86fc1656 0.20.0 2025-05-09 18:49:02 +02:00
David Bruant
9bdf7cdf1b readme version bump 2025-05-09 18:48:56 +02:00
David Bruant
5400c963a7
after-text in #each block (#7)
* Text 'text after {/each} in same text node' failing

* some tests passing

* tests passing

* unused var

* styling
2025-05-09 18:48:34 +02:00
David Bruant
6ad0bab069 0.19.0 2025-05-08 21:29:20 +02:00
David Bruant
e916456d41 bump readme version 2025-05-08 21:29:10 +02:00
David Bruant
183da0053b
the tests ae passing (#6) 2025-05-08 21:28:45 +02:00
David Bruant
4672929480 0.18.0 2025-05-08 17:14:32 +02:00
David Bruant
e6583cb136 bump readme version 2025-05-08 17:14:27 +02:00
David Bruant
0bbc57afb7
Formatted markers (#5)
* Adding failing test case

* test runs and fails

* passing formatting test

* passing test

* refactoring moving different parts to their own files

* Refactoring - doc in prepareTemplateDOMTree

* Test of 2 formatted markers within same Text node passes

* passing test with {#each ...} and text before partially formatted

* Test with {/each} and text after partially formatted passing

* woops with proper test case

* test with partially formatted variable passes
2025-05-08 17:13:51 +02:00
David Bruant
428d230666 0.17.0 2025-05-07 09:16:57 +02:00
David Bruant
adddb73c3a bump version in readme 2025-05-07 09:16:47 +02:00
David Bruant
c9284343e8
If blocks (#4)
* Adding {#if} test case

* Expression evaluation based on ses Compartments

* New Compartment usage

* split template filling tests

* passing tests except the if one

* in progress

* Refactoring: extracting extractBlockContent method

* test if qui passe

* Move tree preparation to its own function so it's done ony once

* if in a single text node works
2025-05-07 09:15:29 +02:00
David Bruant
3da7f29cb0
Split text nodes so they contain at most one structuring block (#3)
* rename test folders

* failing test

* First promising result of splitting textNodes to enable {#each}{/each} block within a single text node

* tests passing

* cleanup
2025-04-26 19:59:44 +02:00
David Bruant
28559585ba bump readme version 2025-04-17 19:39:17 +02:00
David Bruant
6c91298d0f 0.16.0 2025-04-17 19:39:06 +02:00
David Bruant
1094c9b838
Silently ignore non-iterables in {#each} (#2)
* Silently ignore non-iterables in {#each}

* silence console.info
2025-04-17 19:38:32 +02:00
David Bruant
ecfedf9317 readme bump 2025-04-17 17:39:50 +02:00
David Bruant
479686b81c 0.15.0 2025-04-17 17:39:33 +02:00
David Bruant
5a539f333d
expose odt text function (#1)
* Remove xlsx support

* Restructure exports to avoid duplication of DOM-related code

* browser DOM exports

* Fixing exports field in package.json
2025-04-17 17:39:08 +02:00
David Bruant
c345323524 update readme 2025-04-14 14:56:45 +02:00
David Bruant
f32db3218e readme bump version 2025-04-14 14:45:30 +02:00
David Bruant
334b5b19de 0.14.0 2025-04-14 14:45:02 +02:00
David Bruant
c917963adb update readme 2025-04-14 14:37:03 +02:00
David Bruant
475e30ba45 Update readme and package.json following repo change 2025-04-14 14:34:51 +02:00
David Bruant
d51129843c 0.13.0 2025-04-09 10:39:02 +02:00
David Bruant
85add4ca96 readme bump 2025-04-09 10:39:02 +02:00
David Bruant
e7fc82fb6c
Odt template with images (#9)
* Add a test about image preservation

* Image preserving odt generation
2025-04-09 10:36:27 +02:00
David Bruant
c937294fdc bump version in readme 2025-04-07 11:01:43 +02:00
David Bruant
fe608636d4 0.12.0 2025-04-07 11:01:43 +02:00
David Bruant
57dfb4f050
Templ odt (#8)
* add rough roadmap

* adding test of odt template filling

* progress

* avec les titres

* premier jet de remplacement

* progress i guess

* Bimz ! Remplissage de template et passage de tests !

* nettoyages

* zip entries async generator

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

* Test avec le each et amélioration de getOdtTextContent

* yep

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

* Meilleur packaging du zip

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

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

* 2 sibling each in a document

* add nested each test

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

* Refacto API for Node.js

* add fillOdtTemplate to browser exports

* Mention template filling in  readme
2025-04-07 11:00:18 +02:00
David Bruant
86a0b8ea49 readme 2025-03-25 22:08:29 +01:00
David Bruant
9710b1b18a 0.11.0 2025-03-25 22:07:32 +01:00
David Bruant
72f56dafff 0.10.1 2025-03-25 22:07:26 +01:00
David Bruant
a3a2393217
Zipjs (#7)
* Remplacement de unzipit par zip.js

* Générer un fichier qui est valide et s'ouvre dans LibreOffice

* blip bloop, smaller browser bundle

* uninstall xlsx
2025-03-25 22:06:53 +01:00
David Bruant
05190774c4 0.10.0 2024-10-23 21:07:49 +02:00
David Bruant
ed065d1302 version 0.10 in readme 2024-10-23 21:07:46 +02:00
David Bruant
1b10a06205 Change XLSX imports to accodomate rollup bundles 2024-10-23 21:06:01 +02:00
David Bruant
fae82b575e 0.9.0 2024-10-23 19:14:03 +02:00
David Bruant
29b73f58b4 0.8.0 2024-10-23 19:13:42 +02:00
David Bruant
57e0145ec7
Create ods file (#5)
* Ajout de création de fichier .ods

* Ajout d'un outil pour créer un fichier .ods + rajout de styles.xml et meta.xml pour peut-être plaire à LibreOffice

* Créer des fichiers considérés corrompus-mais-réparables LibreOffice

* Creating libre-office-validated ods files

* readme with ods creation
2024-10-23 19:13:29 +02:00
David Bruant
c90359bc7b
Add support for newline in .ods cell texts (#4) 2024-07-24 22:09:08 +02:00
Fanny Cheung
08a2d5f63c
Fix header row (pull request #3)
Fix header row
2024-07-16 17:15:16 +02:00
Fanny Cheung
ecd9f1c4e1 Update to version 0.7.0 2024-07-16 17:14:12 +02:00
Fanny Cheung
bb469e1f08 0.7.0 2024-07-16 17:13:35 +02:00
Fanny Cheung
a2c69626c3 Add test 2024-07-16 17:13:24 +02:00
Fanny Cheung
10bb30b344 Fix header row 2024-07-16 17:00:28 +02:00
David Bruant
fbf5f8abc9
Merge pull request #1 from DavidBruant/date-and-number-columns-repeated-and-split-exports
Date and number columns repeated and split exports
2024-07-08 15:32:03 +02:00
David Bruant
6b93aa2879 0.6.0 2024-07-08 15:30:47 +02:00
David Bruant
4b41bdabc1 bump readme version to 0.6 2024-07-08 15:30:42 +02:00
David Bruant
b2179513f5 Split exports into 2 files : browser and node 2024-07-08 15:29:25 +02:00
David Bruant
652c001776 minus logs 2024-07-08 15:21:47 +02:00
David Bruant
0e425bc161 Make xlsx support in Node.js 2024-07-08 15:21:37 +02:00
David Bruant
ba2ca08b33 Prise en compte des valeurs dates et des cellules répétées dans les fichiers .ods 2024-07-08 15:06:36 +02:00
David Bruant
78fc692d95 0.5.0 2024-06-18 11:50:17 +02:00
David Bruant
246b43c8e4 Re-organise and improve exports 2024-06-18 11:49:57 +02:00
David Bruant
52fd13ece9 0.4.0 2024-06-18 11:24:27 +02:00
David Bruant
efd1c9dbb8 change package name 2024-06-18 11:24:22 +02:00
David Bruant
15826941f1 change version in readme 2024-06-18 11:07:14 +02:00
David Bruant
8dcd7f75ce 0.3.0 2024-06-18 11:06:48 +02:00
David Bruant
7a93508043 Add node.js support + tests + change API 2024-06-18 11:06:41 +02:00
David Bruant
0271b68452 correction readme 2024-06-16 14:58:03 +02:00
David Bruant
52681a088e readme 2024-06-16 14:31:57 +02:00
David Bruant
6f526e1106 0.2.0 2024-06-16 13:58:33 +02:00
66 changed files with 7858 additions and 329 deletions

6
.gitignore vendored
View File

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

29
exports.js Normal file
View File

@ -0,0 +1,29 @@
//@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/xlsx</title>
<title>Upload ods file</title>
<meta name="description" content=" ">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -16,7 +16,7 @@
<link crossorigin="anonymous" rel="stylesheet" href="./build/bundle.css">
<script src="./build/bundle.js" type="module" crossorigin="anonymous"></script>
<script src="./build/front-end.js" type="module" crossorigin="anonymous"></script>
</head>
<body>
<main>

3892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +1,47 @@
{
"name": "front-end-template",
"version": "0.1.0",
"name": "@odfjs/odfjs",
"version": "0.30.0",
"type": "module",
"browser": "./scripts/main.js",
"exports": "./exports.js",
"files": [
"exports.js",
"scripts"
],
"imports": {
"#DOM": {
"node": "./scripts/DOM/node.js",
"browser": "./scripts/DOM/browser.js"
}
},
"scripts": {
"build": "rollup -c",
"dev": "npm-run-all --parallel dev:* start",
"dev:rollup": "rollup -c -w",
"start": "http-server -c-1 ."
"start": "http-server -c-1 .",
"test": "ava"
},
"repository": {
"url": "https://source.netsyms.com/PostalPortal/odfjs.git"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"ava": "^6.1.3",
"http-server": "^14.1.1",
"npm-run-all": "^4.1.5",
"rollup": "^4.18.0",
"rollup-plugin-css-only": "^4.5.2",
"rollup-plugin-svelte": "^7.1.6",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.58.3",
"svelte": "^4.2.9",
"svelte-preprocess": "^5.1.3"
},
"dependencies": {
"unzipit": "^1.4.3"
"@xmldom/xmldom": "^0.9.8",
"@zip.js/zip.js": "^2.7.57",
"image-size": "^2.0.2",
"ses": "^1.14.0"
}
}

155
readme.md
View File

@ -1,12 +1,147 @@
# Front-end template
# @odfjs/odfjs
This repo is meant to be a template repo. Not useful in itself, but a starter kit for other projects
Small lib to parse/understand .odf files (.odt, .ods) in the browser and node.js
A simple theme is already set up
A JavaScript bundler with [svelte](https://svelte.dev/) is set up
## Rough roadmap
- [x] add odt templating
- [x] remove support for xlsx
- [ ] add a .ods minifyer
- [ ] add a generic .ods visualizer
- [ ] move to a dedicated odf docs org
- [ ] add a quick .odt visualiser (maybe converting to markdown first?)
## Usage
### Install
```sh
npm i https://github.com/odfjs/odfjs.git#v0.30.0
```
### Basic - reading an ods file
```js
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent} from '@odfjs/odfjs'
/**
* @param {ArrayBuffer} odsFile - content of an .ods file
* @return {Promise<any[]>}
*/
async function getFileData(odsFile){
return getODSTableRawContent(odsFile)
.then(tableWithoutEmptyRows)
.then(tableRawContentToObjects)
}
```
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)
to the appropriate JavaScript value
### Basic - creating an ods file
```js
import {createOdsFile} from '@odfjs/odfjs'
const content = new Map([
[
'La feuille',
[
[
{value: '37', type: 'float'},
{value: '26', type: 'string'}
]
],
],
[
"L'autre feuille",
[
[
{value: '1', type: 'string'},
{value: '2', type: 'string'},
{value: '3', type: 'string'},
{value: '5', type: 'string'},
{value: '8', type: 'string'}
]
],
]
])
const ods = await createOdsFile(content)
// ods is an ArrayBuffer representing an ods file with the content described by the Map
```
(and there is a tool to test file creation:
`node tools/create-an-ods-file.js > yo.ods`)
### Filling an .odt template
odf.js proposes a template syntax
In an .odt file, write the following:
```txt
Hey {nom}!
Your birthdate is {dateNaissance}
```
And then run the code:
```js
import {join} from 'node:path';
import {getOdtTemplate, fillOdtTemplate} from '@odfjs/odfjs'
// replace with your template path
const templatePath = join(import.meta.dirname, './tests/data/template-anniversaire.odt')
const data = {
nom: 'David Bruant',
dateNaissance: '8 mars 1987'
}
const odtTemplate = await getOdtTemplate(templatePath)
const odtResult = await fillOdtTemplate(odtTemplate, data)
process.stdout.write(new Uint8Array(odtResult))
```
There are also loops in the form:
```txt
- {#each listeCourses as élément}
- {élément}
- {/each}
```
They can be used to generate lists or tables in .odt files from data and a template using this syntax
#### 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/
Continuous deployment is setup via Github Actions. The continuous deployement builds with `npm run build` then does a `git push origin online`, then triggers a github page build of the `online` branch
## Local dev
@ -16,14 +151,10 @@ npm run dev
```
## Expectations and licence
I expect to be credited for the work on this repo
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)**
#### Dependencies
Bootstrap reboot is **MIT**-licenced
Svelte and rollup config are **MIT**-licence

View File

@ -4,6 +4,7 @@ import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import css from 'rollup-plugin-css-only';
import sveltePreprocess from 'svelte-preprocess'
import { visualizer } from "rollup-plugin-visualizer";
const production = !process.env.ROLLUP_WATCH;
@ -13,7 +14,7 @@ export default {
output: {
sourcemap: true,
format: 'es',
file: 'build/bundle.js'
dir: 'build'
},
plugins: [
svelte({
@ -37,6 +38,7 @@ export default {
}),
commonjs(),
visualizer(),
// If we're building for production (npm run build
// instead of npm run dev), minify
//production && terser()

View File

@ -1,8 +1,23 @@
<script>
import {getTableRawContentFromFile, tableRawContentToObjects} from './main.js'
import {tableRawContentToObjects, tableWithoutEmptyRows, getODSTableRawContent, createOdsFile} from '../exports.js'
/** @import {SheetName, SheetRawContent} from './types.js' */
const ODS_TYPE = "application/vnd.oasis.opendocument.spreadsheet";
const XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/**
*
* @param {File} file
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
async function getTableRawContentFromFile(file){
if(file.type === ODS_TYPE)
return getODSTableRawContent(await file.arrayBuffer())
throw new TypeError(`Unsupported file type: ${file.type} (${file.name})`)
}
let files
@ -11,18 +26,21 @@
/** @type {File} */
$: file = files && files[0]
$: tableRawContent = file && getTableRawContentFromFile(file)
$: tableObjectSheets = tableRawContent && tableRawContent.then(tableRawContentToObjects) || []
$: tableObjectSheets = tableRawContent && tableRawContent.then(tableWithoutEmptyRows).then(tableRawContentToObjects) || []
$: Promise.resolve(tableObjectSheets).then(x => console.log('tableObjectSheets', x))
// ligne inutile qui utilise createOdsFile pour l'importer dans le bundle
$: tableRawContent && tableRawContent.then(createOdsFile).then(ab => console.log('length', ab.byteLength))
</script>
<h1>Import fichier .ods et .xslx</h1>
<h1>Import fichier .ods</h1>
<section>
<h2>Import</h2>
<label>
Fichier à importer:
<input bind:files type="file" id="file-input" accept="{ ['.ods', '.xlsx', ODS_TYPE, XLSX_TYPE].join(',') }" />
<input bind:files type="file" id="file-input" accept="{ ['.ods', ODS_TYPE].join(',') }" />
</label>
</section>

12
scripts/DOM/browser.js Normal file
View File

@ -0,0 +1,12 @@
//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

17
scripts/DOM/node.js Normal file
View File

@ -0,0 +1,17 @@
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"

95
scripts/DOMUtils.js Normal file
View File

@ -0,0 +1,95 @@
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
* to each Element node encountered in tree order (depth-first).
*
* This should probably be replace by the TreeWalker API when implemented by xmldom
*
* @param {Node} node
* @param {(n : Node) => void} visit
*/
export function traverse(node, visit) {
//console.log('traverse', node.nodeType, node.nodeName)
for (const child of Array.from(node.childNodes)) {
traverse(child, visit);
}
visit(node);
}
/**
*
* @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'

251
scripts/createOdsFile.js Normal file
View File

@ -0,0 +1,251 @@
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
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:automatic-styles/>
<office:master-styles/>
</office:document-styles>`;
const manifestXml = `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest manifest:version="1.2" xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:media-type="application/vnd.oasis.opendocument.spreadsheet" manifest:full-path="/"/>
<manifest:file-entry manifest:media-type="text/xml" manifest:full-path="content.xml"/>
<manifest:file-entry manifest:media-type="text/xml" manifest:full-path="styles.xml"/>
</manifest:manifest>`;
/**
* Crée un fichier .ods à partir d'un Map de feuilles de calcul
* @param {Map<SheetName, SheetRawContent>} sheetsData
* @returns {Promise<ArrayBuffer>}
*/
export async function createOdsFile(sheetsData, currencyData = null) {
// Create a new zip writer
const zipWriter = new ZipWriter(new BlobWriter('application/vnd.oasis.opendocument.spreadsheet'));
// The “mimetype” file shall be the first file of the zip file.
// It shall not be compressed, and it shall not use an 'extra field' in its header.
// https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part2-packages/OpenDocument-v1.3-os-part2-packages.html#__RefHeading__752809_826425813
zipWriter.add(
"mimetype",
new TextReader("application/vnd.oasis.opendocument.spreadsheet"),
{
compressionMethod: 0,
level: 0,
dataDescriptor: false,
extendedTimestamp: false,
}
);
const contentXml = generateContentFileXMLString(sheetsData, currencyData);
zipWriter.add("content.xml", new TextReader(contentXml), {level: 9});
zipWriter.add("styles.xml", new TextReader(stylesXml));
zipWriter.add('META-INF/manifest.xml', new TextReader(manifestXml));
// Close the zip writer and get the ArrayBuffer
const zipFile = await zipWriter.close();
return zipFile.arrayBuffer();
}
/**
* Generate the content.xml file with spreadsheet data
* @param {Map<SheetName, SheetRawContent>} sheetsData
* @returns {string}
*/
function generateContentFileXMLString(sheetsData, currencyData) {
const doc = createDocument('urn:oasis:names:tc:opendocument:xmlns:office:1.0', 'office:document-content');
const root = doc.documentElement;
// Set up namespaces
root.setAttribute('xmlns:table', 'urn:oasis:names:tc:opendocument:xmlns:table:1.0');
root.setAttribute('xmlns:text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
root.setAttribute('xmlns:style', 'urn:oasis:names:tc:opendocument:xmlns:style:1.0');
root.setAttribute('xmlns:number', 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0');
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);
const spreadsheetNode = doc.createElement('office:spreadsheet');
bodyNode.appendChild(spreadsheetNode);
// Iterate through sheets
sheetsData.forEach((sheetData, sheetName) => {
const tableNode = doc.createElement('table:table');
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);
}
// Iterate through rows
sheetData.forEach((row) => {
const rowNode = doc.createElement('table:table-row');
tableNode.appendChild(rowNode);
// Iterate through cells in row
row.forEach((cell) => {
const cellNode = doc.createElement('table:table-cell');
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) {
case 'float':
cellNode.setAttribute('office:value', cell.value.toString());
break;
case 'percentage':
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;
case 'boolean':
cellNode.setAttribute('office:boolean-value', cell.value ? 'true' : 'false');
break;
default:
const textNode = doc.createElement('text:p');
textNode.textContent = cell.value.toString();
cellNode.appendChild(textNode);
break;
}
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();
}
cellNode.appendChild(textNode);
}
}
rowNode.appendChild(cellNode);
});
});
});
return serializeToString(doc);
}
/**
* Convert cell type to OpenDocument format type
* @param {SheetCellRawContent['type']} type
* @returns {SheetCellRawContent['type']}
*/
function convertCellType(type) {
const typeMap = {
'float': 'float',
'percentage': 'percentage',
'currency': 'currency',
'date': 'date',
'time': 'time',
'boolean': 'boolean',
'string': 'string',
'n': 'float',
's': 'string',
'd': 'date',
'b': 'boolean'
};
return typeMap[type] || 'string';
}

View File

@ -1,251 +0,0 @@
//@ts-check
import { unzip } from 'unzipit';
/**
* @typedef SheetCellRawContent
* @prop {string | null | undefined} value
* @prop {'float' | 'percentage' | 'currency' | 'date' | 'time' | 'boolean' | 'string' | 'b' | 'd' | 'e' | 'inlineStr' | 'n' | 's' | 'str'} type
*/
/** @typedef {SheetCellRawContent[]} SheetRowRawContent */
/** @typedef {SheetRowRawContent[]} SheetRawContent */
/** @typedef {string} SheetName */
const ODS_TYPE = "application/vnd.oasis.opendocument.spreadsheet";
const XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/**
* Extracts raw table content from an ODS file.
* @param {File} file - The ODS file.
* @param {Function} unzip - Function to unzip the file.
* @param {Function} parseXML - Function to parse XML content.
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
async function getTableRawContentFromODSFile(file, unzip, parseXML) {
const zip = await unzip(file);
const entries = zip.entries;
// Extract the content.xml file which contains the spreadsheet data
const contentXml = await entries['content.xml'].text();
const contentDoc = parseXML(contentXml);
const tableMap = new Map();
const tables = contentDoc.getElementsByTagName('table:table');
for (let table of tables) {
const sheetName = table.getAttribute('table:name');
const rows = table.getElementsByTagName('table:table-row');
const sheetData = [];
for (let row of rows) {
const cells = row.getElementsByTagName('table:table-cell');
const rowData = [];
for (let cell of cells) {
const cellType = cell.getAttribute('office:value-type');
const cellValue = cellType === 'string' ? cell.textContent : cell.getAttribute('office:value');
rowData.push({
value: cellValue,
type: cellType
});
}
sheetData.push(rowData);
}
tableMap.set(sheetName, sheetData);
}
return tableMap;
}
/**
* Extracts raw table content from an XLSX file.
* @param {File} file - The XLSX file.
* @param {Function} unzip - Function to unzip the file.
* @param {Function} parseXML - Function to parse XML content.
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
async function getTableRawContentFromXSLXFile(file, unzip, parseXML) {
const zip = await unzip(file);
const entries = zip.entries;
const sharedStringsXml = await entries['xl/sharedStrings.xml'].text();
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 workbookXml = await entries['xl/workbook.xml'].text();
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 workbookRelsXml = await entries['xl/_rels/workbook.xml.rels'].text();
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) => (
entries[`xl/worksheets/${sheetFile}`].text().then(sheetXml => {
const sheetDoc = parseXML(sheetXml);
const rows = sheetDoc.getElementsByTagName('sheetData')[0].getElementsByTagName('row');
const sheetData = [];
for (let row of rows) {
const cells = row.getElementsByTagName('c');
const rowData = [];
for (let cell of 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));
}
const parser = new DOMParser();
/**
* @param {string} str
* @returns {Document}
*/
function parseXML(str){
return parser.parseFromString(str, 'application/xml');
}
/**
*
* @param {File} file
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
export function getTableRawContentFromFile(file){
if(file.type === ODS_TYPE)
return getTableRawContentFromODSFile(file, unzip, parseXML)
if(file.type === XLSX_TYPE)
return getTableRawContentFromXSLXFile(file, unzip, parseXML)
throw new TypeError(`Unsupported file type: ${file.type} (${file.name})`)
}
/**
* Converts a cell value to the appropriate JavaScript type based on its cell type.
* @param {SheetCellRawContent} _
* @returns {number | boolean | string | Date} The converted value.
*/
function convertCellValue({value, type}) {
if(value === ''){
return ''
}
if(value === null || value === undefined){
return ''
}
switch (type) {
case 'float':
case 'percentage':
case 'currency':
case 'n': // number
return parseFloat(value);
case 'date':
case 'd': // date
return new Date(value);
case 'boolean':
case 'b': // boolean
return value === '1' || value === 'true';
case 's': // shared string
case 'inlineStr': // inline string
case 'string':
case 'e': // error
case 'time':
default:
return value;
}
}
/**
* @param {SheetCellRawContent} rawCellContent
* @returns {boolean}
*/
function isCellNotEmpty({value}){
return value !== '' && value !== null && value !== undefined
}
/**
* @param {SheetRowRawContent} rawContentRow
* @returns {boolean}
*/
function isRowNotEmpty(rawContentRow){
return rawContentRow.some(isCellNotEmpty)
}
/**
*
* @param {SheetRawContent} rawContent
* @returns {any[]}
*/
function rawContentToObjects(rawContent){
let [firstRow, ...dataRows] = rawContent
/** @type {string[]} */
//@ts-expect-error this type is correct after the filter
const columns = firstRow.filter(({value}) => typeof value === 'string' && value.length >= 1).map(r => r.value)
return dataRows
.filter(isRowNotEmpty) // remove empty rows
.map(row => {
const obj = Object.create(null)
columns.forEach((column, i) => {
const rawValue = row[i]
obj[column] = rawValue ? convertCellValue(rawValue) : ''
})
return obj
})
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, any[]>}
*/
export function tableRawContentToObjects(rawContentSheets){
return new Map(
[...rawContentSheets].map(([sheetName, rawContent]) => {
return [sheetName, rawContentToObjects(rawContent)]
})
)
}

103
scripts/odf/manifest.js Normal file
View File

@ -0,0 +1,103 @@
/*
As specified by https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part2-packages/OpenDocument-v1.3-os-part2-packages.html#__RefHeading__752825_826425813
*/
/** @typedef {'application/vnd.oasis.opendocument.text' | 'application/vnd.oasis.opendocument.spreadsheet'} ODFMediaType */
/** @typedef {'1.2' | '1.3' | '1.4'} ODFVersion */
/**
* @typedef ODFManifestFileEntry
* @prop {string} fullPath
* @prop {string} mediaType
* @prop {string} [version]
*/
/**
* @typedef ODFManifest
* @prop {ODFMediaType} mediaType
* @prop {ODFVersion} version
* @prop {Map<ODFManifestFileEntry['fullPath'], ODFManifestFileEntry>} fileEntries
*/
/**
*
* @param {ODFManifestFileEntry} fileEntry
* @returns {string}
*/
function makeFileEntry({fullPath, mediaType}){
return `<manifest:file-entry manifest:full-path="${fullPath}" manifest:media-type="${mediaType}"/>`
}
/**
*
* @param {ODFManifest} odfManifest
* @returns {string}
*/
export function makeManifestFile({fileEntries, mediaType, version}){
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="${version}">
<manifest:file-entry manifest:full-path="/" manifest:version="${version}" manifest:media-type="${mediaType}"/>
${[...fileEntries.values()].map(makeFileEntry).join('\n')}
</manifest:manifest>`
}
/**
* @param {Document} manifestDoc
* @returns {ODFManifest}
*/
export function getManifestFileData(manifestDoc){
/** @type {Partial<ReturnType<getManifestFileData>>} */
const manifestData = {
fileEntries: new Map()
}
const manifestEl = manifestDoc.getElementsByTagName('manifest:manifest')[0]
/** @type {ODFVersion} */
// @ts-ignore
const version = manifestEl.getAttribute('manifest:version');
if(!version){
throw new Error(`Missing version attibute in manifest:manifest element of manifest.xml file`)
}
manifestData.version = version
const manifestEntryEls = manifestEl.getElementsByTagName('manifest:file-entry')
for(const manifestEntryEl of Array.from(manifestEntryEls)){
/** @type {ODFManifestFileEntry} */
const odfManifestFileEntry = {
fullPath: '',
mediaType: ''
}
const fullPath = manifestEntryEl.getAttribute('manifest:full-path')
if(!fullPath){
throw new Error(`Missing manifest:full-path attribute in manifest entry`)
}
odfManifestFileEntry.fullPath = fullPath
const mediaType = manifestEntryEl.getAttribute('manifest:media-type')
if(!mediaType){
throw new Error(`Missing manifest:media-type attribute in manifest entry for '${fullPath}'`)
}
odfManifestFileEntry.mediaType = mediaType
if(fullPath === '/'){
// @ts-ignore
manifestData.mediaType = mediaType
}
const version = manifestEntryEl.getAttribute('manifest:version')
if(version){
odfManifestFileEntry.version = version
}
// @ts-ignore
manifestData.fileEntries.set(fullPath, odfManifestFileEntry)
}
//@ts-ignore
return manifestData
}

View File

@ -0,0 +1,69 @@
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

@ -0,0 +1,11 @@
import { readFile } from 'node:fs/promises'
/**
*
* @param {string} path
* @returns {Promise<ODTFile>}
*/
export async function getOdtTemplate(path) {
const fileBuffer = await readFile(path)
return fileBuffer.buffer
}

View File

@ -0,0 +1,875 @@
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

@ -0,0 +1,186 @@
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

@ -0,0 +1,10 @@
// 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

@ -0,0 +1,530 @@
//@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)
}

342
scripts/shared.js Normal file
View File

@ -0,0 +1,342 @@
//@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' */
// https://dom.spec.whatwg.org/#interface-node
const TEXT_NODE = 3
/**
*
* @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
text += child.nodeValue;
} else if (child.nodeName === 'text:p') {
if (text.length > 0) {
// Add a newline before appending new paragraph if text already exists
text += '\n';
}
const pNodes = child.childNodes;
for (const pChild of Array.from(pNodes)) {
if (pChild.nodeType === TEXT_NODE) {
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();
}
/**
* Extracts raw table content from an ODS file.
* @param {ArrayBuffer} arrayBuffer - The ODS file.
* @returns {Promise<Map<SheetName, SheetRawContent>>}
*/
export async function getODSTableRawContent(arrayBuffer) {
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 contentXmlEntry = entryByFilename.get('content.xml')
if(!contentXmlEntry){
throw new TypeError(`entry 'content.xml' manquante dans le zip`)
}
// Extract the content.xml file which contains the spreadsheet data
//@ts-ignore
const contentXml = await contentXmlEntry.getData(new TextWriter());
//console.log('contentXml', contentXml);
const contentDoc = parseXML(contentXml);
const tableMap = new Map();
const tables = contentDoc.getElementsByTagName('table:table');
for (let table of Array.from(tables)) {
const sheetName = table.getAttribute('table:name');
const rows = table.getElementsByTagName('table:table-row');
const sheetData = [];
for (let row of Array.from(rows)) {
const cells = row.getElementsByTagName('table:table-cell');
const rowData = [];
for (let cell of Array.from(cells)) {
const cellType = cell.getAttribute('office:value-type');
let cellValue;
if (cellType === 'string') {
cellValue = extraxtODSCellText(cell)
} else if (cellType === 'date') {
cellValue = cell.getAttribute('office:date-value');
} else {
cellValue = cell.getAttribute('office:value');
}
const numberOfColumnsRepeated = cell.getAttribute('table:number-columns-repeated');
const repeatCount = numberOfColumnsRepeated ? parseInt(numberOfColumnsRepeated, 10) : 1;
if(repeatCount < 100){ // ignore excessive repetitions
for (let i = 0; i < repeatCount; i++) {
rowData.push({
value: cellValue,
type: cellType
});
}
}
}
sheetData.push(rowData);
}
tableMap.set(sheetName, sheetData);
}
return tableMap;
}
/**
* Converts a cell value to the appropriate JavaScript type based on its cell type.
* @param {SheetCellRawContent} _
* @returns {number | boolean | string | Date} The converted value.
*/
export function convertCellValue({value, type}) {
if(value === ''){
return ''
}
if(value === null || value === undefined){
return ''
}
switch (type) {
case 'float':
case 'percentage':
case 'currency':
case 'n': // number
return parseFloat(value);
case 'date':
case 'd': // date
return new Date(value);
case 'boolean':
case 'b': // boolean
return value === '1' || value === 'true';
case 's': // shared string
case 'inlineStr': // inline string
case 'string':
case 'e': // error
case 'time':
default:
return value;
}
}
/**
* @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
* @returns {Map<SheetName, ReturnType<convertCellValue>[][]>}
*/
export function tableRawContentToValues(rawContentSheets){
return new Map(
[...rawContentSheets].map(([sheetName, rawContent]) => {
return [
sheetName,
rawContent
.map(row => row.map(c => convertCellValue(c)))
]
})
)
}
/**
* Convert values to strings
*/
/**
*
* @param {SheetCellRawContent} rawContentCell
* @returns {string}
*/
export function cellRawContentToStrings(rawContentCell){
return rawContentCell.value || ''
}
/**
*
* @param {SheetRowRawContent} rawContentRow
* @returns {string[]}
*/
export function rowRawContentToStrings(rawContentRow){
return rawContentRow.map(cellRawContentToStrings)
}
/**
*
* @param {SheetRawContent} rawContentSheet
* @returns {string[][]}
*/
export function sheetRawContentToStrings(rawContentSheet){
return rawContentSheet.map(rowRawContentToStrings)
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, string[][]>}
*/
export function tableRawContentToStrings(rawContentSheets){
return new Map(
[...rawContentSheets].map(([sheetName, rawContent]) => {
return [ sheetName, sheetRawContentToStrings(rawContent) ]
})
)
}
/**
* Convert rows to objects
*/
/**
* 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
*
* @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}`
}
return r.value
})
return dataRows
.map(row => {
const obj = Object.create(null)
columns.forEach((column, i) => {
const rawValue = row[i]
obj[column] = rawValue ? convertCellValue(rawValue) : ''
})
return obj
})
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentSheets
* @returns {Map<SheetName, any[]>}
*/
export function tableRawContentToObjects(rawContentSheets){
return new Map(
[...rawContentSheets].map(([sheetName, rawContent]) => {
return [sheetName, sheetRawContentToObjects(rawContent)]
})
)
}
/**
* Emptiness
*/
/**
* @param {SheetCellRawContent} rawCellContent
* @returns {boolean}
*/
export function isCellFilled({value}){
return value !== '' && value !== null && value !== undefined
}
/**
* @param {SheetRowRawContent} rawContentRow
* @returns {boolean}
*/
export function isRowNotEmpty(rawContentRow){
return rawContentRow.some(isCellFilled)
}
/**
* @param {SheetRawContent} sheet
* @returns {SheetRawContent}
*/
export function removeEmptyRowsFromSheet(sheet){
return sheet.filter(isRowNotEmpty)
}
/**
*
* @param {Map<SheetName, SheetRawContent>} rawContentTable
* @returns {Map<SheetName, SheetRawContent>}
*/
export function tableWithoutEmptyRows(rawContentTable){
return new Map(
[...rawContentTable].map(([sheetName, rawContent]) => {
return [sheetName, removeEmptyRowsFromSheet(rawContent)]
})
)
}

21
scripts/types.js Normal file
View File

@ -0,0 +1,21 @@
/**
* @typedef SheetCellRawContent
* @prop {string | null | undefined} value
* @prop {'float' | 'percentage' | 'currency' | 'date' | 'time' | 'boolean' | 'string' | 'b' | 'd' | 'e' | 'inlineStr' | 'n' | 's' | 'str'} type
*/
/** @typedef {SheetCellRawContent[]} SheetRowRawContent */
/** @typedef {SheetRowRawContent[]} SheetRawContent */
/** @typedef {string} SheetName */
/**
* @typedef OdfjsImage
* @prop {ArrayBuffer} content
* @prop {string} fileName
* @prop {string} mediaType
*
*/
export {}

19
tests/basic-node.js Normal file
View File

@ -0,0 +1,19 @@
import {readFile} from 'node:fs/promises'
import test from 'ava';
import {getODSTableRawContent} from '../exports.js'
const nomAgeContent = (await readFile('./tests/fixtures/nom-age.ods')).buffer
test('basic', async t => {
const table = await getODSTableRawContent(nomAgeContent);
t.assert(table.has('Feuille1'))
const feuille1 = table.get('Feuille1')
t.assert(Array.isArray(feuille1))
//@ts-ignore
t.assert(Array.isArray(feuille1[0]))
//@ts-ignore
t.deepEqual(feuille1[0][0], {value: 'Nom', type: 'string'})
});

37
tests/create-ods-file.js Normal file
View File

@ -0,0 +1,37 @@
import test from 'ava';
import {getODSTableRawContent, createOdsFile} from '../exports.js'
/** @import {SheetName, SheetRawContent} from '../scripts/types.js' */
test('basic file creation', async t => {
/** @type {Map<SheetName, SheetRawContent>} */
const content = new Map([
[
'La feuille',
[
[
{value: 'azerty', type: 'string'},
{value: '37', type: 'float'}
]
]
]
])
const odsFile = await createOdsFile(content)
const parsedContent = await getODSTableRawContent(odsFile)
t.assert(parsedContent.has('La feuille'))
const feuille = parsedContent.get('La feuille')
t.deepEqual(feuille, [
[
{value: 'azerty', type: 'string'},
{value: '37', type: 'float'}
]
])
});

View File

@ -0,0 +1,36 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1,375 @@
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 {#each}', 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 : [
'Radis',
`Jus d'orange`,
'Pâtes à lasagne (fraîches !)'
]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
try{
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `🧺 La liste de courses incroyable 🧺
Radis
Jus d'orange
Pâtes à lasagne (fraîches !)
`)
}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 templateContent = `🧺 La liste de courses incroyable 🧺
- {#each listeCourses as élément}
- {élément}
- {/each}
`
const data = {
listeCourses : [
'Radis',
`Jus d'orange`,
'Pâtes à lasagne (fraîches !)'
]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `🧺 La liste de courses incroyable 🧺
- Radis
- Jus d'orange
- Pâtes à lasagne (fraîches !)
`)
});
test('template filling with 2 sequential {#each}', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/liste-fruits-et-légumes.odt')
const templateContent = `Liste de fruits et légumes
Fruits
{#each fruits as fruit}
{fruit}
{/each}
Légumes
{#each légumes as légume}
{légume}
{/each}
`
const data = {
fruits : [
'Pastèque 🍉',
`Kiwi 🥝`,
'Banane 🍌'
],
légumes: [
'Champignon 🍄‍🟫',
'Avocat 🥑',
'Poivron 🫑'
]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Liste de fruits et légumes
Fruits
Pastèque 🍉
Kiwi 🥝
Banane 🍌
Légumes
Champignon 🍄🟫
Avocat 🥑
Poivron 🫑
`)
});
test('template filling with nested {#each}s', async t => {
const templatePath = join(import.meta.dirname, '../fixtures/légumes-de-saison.odt')
const templateContent = `Légumes de saison
{#each légumesSaison as saisonLégumes}
{saisonLégumes.saison}
- {#each saisonLégumes.légumes as légume}
- {légume}
- {/each}
{/each}
`
const data = {
légumesSaison : [
{
saison: 'Printemps',
légumes: [
'Asperge',
'Betterave',
'Blette'
]
},
{
saison: 'Été',
légumes: [
'Courgette',
'Poivron',
'Laitue'
]
},
{
saison: 'Automne',
légumes: [
'Poireau',
'Potiron',
'Brocoli'
]
},
{
saison: 'Hiver',
légumes: [
'Radis',
'Chou de Bruxelles',
'Frisée'
]
}
]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent, `Légumes de saison
Printemps
- Asperge
- Betterave
- Blette
Été
- Courgette
- Poivron
- Laitue
Automne
- Poireau
- Potiron
- Brocoli
Hiver
- Radis
- Chou de Bruxelles
- Frisée
`)
});
test('template filling 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 templateContent = `Évolution énergie en kWh par personne en France
Année
Énergie par personne
{#each annéeConsos as annéeConso}
{annéeConso.année}
{annéeConso.conso}
{/each}
`
/*
Data sources:
U.S. Energy Information Administration (2023)Energy Institute -
Statistical Review of World Energy (2024)Population based on various sources (2023)
with major processing by Our World in Data
*/
const data = {
annéeConsos : [
{ année: 1970, conso: 36252.637},
{ année: 1980, conso: 43328.78},
{ année: 1990, conso: 46971.94},
{ année: 2000, conso: 53147.277},
{ année: 2010, conso: 48062.32},
{ année: 2020, conso: 37859.246},
]
}
const odtTemplate = await getOdtTemplate(templatePath)
const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent.trim(), templateContent.trim(), 'reconnaissance du template')
const odtResult = await fillOdtTemplate(odtTemplate, data)
const odtResultTextContent = await getOdtTextContent(odtResult)
t.deepEqual(odtResultTextContent.trim(), `Évolution énergie en kWh par personne en France
Année
Énergie par personne
1970
36252.637
1980
43328.78
1990
46971.94
2000
53147.277
2010
48062.32
2020
37859.246
`.trim())
});
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}
`
const data = {
liste_espèces_par_impact: [
{}
]
}
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, ``)
});

View File

@ -0,0 +1,211 @@
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

@ -0,0 +1,91 @@
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

@ -0,0 +1,84 @@
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

@ -0,0 +1,65 @@
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  !!
`)
});

BIN
tests/fixtures/basic-image-insertion.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/cellule avec sauts.ods vendored Normal file

Binary file not shown.

BIN
tests/fixtures/cellule avec style.ods vendored Normal file

Binary file not shown.

BIN
tests/fixtures/cellules avec dates.ods vendored Normal file

Binary file not shown.

BIN
tests/fixtures/cellules avec emails.ods vendored Normal file

Binary file not shown.

BIN
tests/fixtures/cellules-répétées.ods vendored Normal file

Binary file not shown.

BIN
tests/fixtures/description-nombre.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/enum-courses.odt vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/ghost-if.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/if-then-each.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/inline-if-nombres.odt vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/liste-courses.odt vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/liste-nombres.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/légumes-de-saison.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/nom-age.ods vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/pitchou-1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

BIN
tests/fixtures/pitchou-2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

BIN
tests/fixtures/tableau-simple.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/template-anniversaire.odt vendored Normal file

Binary file not shown.

BIN
tests/fixtures/template-avec-image.odt vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,11 @@
import { ZipReader, Uint8ArrayReader } from '@zip.js/zip.js';
/**
*
* @param {ArrayBuffer} odtTemplate
* @returns {ReturnType<typeof ZipReader.prototype.getEntries>}
*/
export async function listZipEntries(odtTemplate){
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtTemplate)));
return reader.getEntries();
}

88
tests/ods-files.js Normal file
View File

@ -0,0 +1,88 @@
import {readFile} from 'node:fs/promises'
import test from 'ava';
import {getODSTableRawContent} from '../exports.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 table = await getODSTableRawContent(repeatedCellFileContent);
const feuille1 = table.get('Feuille 1')
t.deepEqual(feuille1[0].length, feuille1[1].length, `First and second row should have the same number of columns`)
});
test('.ods cells with dates should be recognized', async t => {
const odsFileWithDates = (await readFile('./tests/fixtures/cellules avec dates.ods')).buffer
const table = await getODSTableRawContent(odsFileWithDates);
const feuille1 = table.get('Feuille1')
const row1 = feuille1[0]
t.deepEqual(row1[0].value, 'Nom')
t.deepEqual(row1[1].value, 'Date de naissance')
const row2 = feuille1[1]
t.deepEqual(row2[0].value, 'Dav')
t.deepEqual(row2[1].type, 'date')
t.deepEqual(row2[1].value, '1987-03-08')
const row3 = feuille1[2]
t.deepEqual(row3[0].value, 'Fanny')
t.deepEqual(row3[1].type, 'date')
t.deepEqual(row3[1].value, '1986-06-01')
});
test('.ods file with new lines in content is ', async t => {
const repeatedCellFileContent = (await readFile('./tests/fixtures/cellule avec sauts.ods')).buffer
const table = await getODSTableRawContent(repeatedCellFileContent);
const feuille1 = table.get('Feuille1')
const expectedValue = `Deviens génial, deviens génial
Tu n'sais pas encore l'enfer qui t'attend
Le regard des uns, le rejet des autres
Si t'es bizarre, si t'es pas marrant
Deviens génial, deviens génial
Deviens génial, deviens génial
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

@ -0,0 +1,39 @@
import test from 'ava';
import { sheetRawContentToObjects } from "../exports.js"
test("Empty header value should be kept", t => {
const rawContent = [
[
{
type: "string",
value: "",
},
{
type: "string",
value: "Pitchou",
},
],
[
{
type: "string",
value: "1",
},
{
type: "string",
value: "2",
},
]
]
const object = sheetRawContentToObjects(rawContent)
t.deepEqual(
object,
[
{
"Column 1": "1",
"Pitchou": "2",
}
]
)
})

View File

@ -0,0 +1,35 @@
//@ts-check
import {createOdsFile} from '../scripts/node.js'
const content = new Map([
[
'La feuille',
[
[
{value: '37', type: 'float'},
{value: '26', type: 'string'}
]
],
],
[
"L'autre feuille",
[
[
{value: '1', type: 'string'},
{value: '2', type: 'string'},
{value: '3', type: 'string'},
{value: '5', type: 'string'},
{value: '8', type: 'string'}
]
],
]
])
// @ts-ignore
const ods = await createOdsFile(content)
//console.log('writableHighWaterMark', process.stdout.writableHighWaterMark) // 16384
process.stdout.write(new Uint8Array(ods))

View File

@ -0,0 +1,149 @@
import {writeFile, readFile} from 'node:fs/promises'
import {join} from 'node:path';
import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js'
import {fillOdtTemplate} from '../exports.js'
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/template-anniversaire.odt')
const data = {
nom: 'David Bruant',
dateNaissance: '8 mars 1987'
}
*/
/*const templatePath = join(import.meta.dirname, '../tests/fixtures/enum-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 data = {
fruits : [
'Pastèque 🍉',
`Kiwi 🥝`,
'Banane 🍌'
],
légumes: [
'Champignon 🍄‍🟫',
'Avocat 🥑',
'Poivron 🫑'
]
}*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/légumes-de-saison.odt')
const data = {
légumesSaison : [
{
saison: 'Printemps',
légumes: [
'Asperge',
'Betterave',
'Blette'
]
},
{
saison: 'Été',
légumes: [
'Courgette',
'Poivron',
'Laitue'
]
},
{
saison: 'Automne',
légumes: [
'Poireau',
'Potiron',
'Brocoli'
]
},
{
saison: 'Hiver',
légumes: [
'Radis',
'Chou de Bruxelles',
'Frisée'
]
}
]
}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/tableau-simple.odt')
const data = {
annéeConsos : [
{ année: 1970, conso: 36252.637},
{ année: 1980, conso: 43328.78},
{ année: 1990, conso: 46971.94},
{ année: 2000, conso: 53147.277},
{ année: 2010, conso: 48062.32},
{ année: 2020, conso: 37859.246},
]
}
*/
/*
const templatePath = join(import.meta.dirname, '../tests/fixtures/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)
writeFile('yo.odt', new Uint8Array(odtResult))