Expression evaluation based on ses Compartments

This commit is contained in:
David Bruant 2025-04-27 01:34:51 +02:00
parent 9cb48a811a
commit 66cd2cffda
4 changed files with 77 additions and 52 deletions

35
package-lock.json generated
View File

@ -1,15 +1,16 @@
{ {
"name": "ods-xlsx", "name": "@odfjs/odfjs",
"version": "0.16.0", "version": "0.16.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ods-xlsx", "name": "@odfjs/odfjs",
"version": "0.16.0", "version": "0.16.0",
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
"@zip.js/zip.js": "^2.7.57" "@zip.js/zip.js": "^2.7.57",
"ses": "^1.12.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-commonjs": "^25.0.7",
@ -40,6 +41,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@endo/env-options": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.8.tgz",
"integrity": "sha512-Xtxw9n33I4guo8q0sDyZiRuxlfaopM454AKiELgU7l3tqsylCut6IBZ0fPy4ltSHsBib7M3yF7OEMoIuLwzWVg==",
"license": "Apache-2.0"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
@ -3607,6 +3614,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/ses": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/ses/-/ses-1.12.0.tgz",
"integrity": "sha512-jvmwXE2lFxIIY1j76hFjewIIhYMR9Slo3ynWZGtGl5M7VUCw3EA0wetS+JCIbl2UcSQjAT0yGAHkyxPJreuC9w==",
"license": "Apache-2.0",
"dependencies": {
"@endo/env-options": "^1.1.8"
}
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -4555,6 +4571,11 @@
"@jridgewell/trace-mapping": "^0.3.9" "@jridgewell/trace-mapping": "^0.3.9"
} }
}, },
"@endo/env-options": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@endo/env-options/-/env-options-1.1.8.tgz",
"integrity": "sha512-Xtxw9n33I4guo8q0sDyZiRuxlfaopM454AKiELgU7l3tqsylCut6IBZ0fPy4ltSHsBib7M3yF7OEMoIuLwzWVg=="
},
"@jridgewell/gen-mapping": { "@jridgewell/gen-mapping": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
@ -7116,6 +7137,14 @@
"type-fest": "^0.13.1" "type-fest": "^0.13.1"
} }
}, },
"ses": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/ses/-/ses-1.12.0.tgz",
"integrity": "sha512-jvmwXE2lFxIIY1j76hFjewIIhYMR9Slo3ynWZGtGl5M7VUCw3EA0wetS+JCIbl2UcSQjAT0yGAHkyxPJreuC9w==",
"requires": {
"@endo/env-options": "^1.1.8"
}
},
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",

View File

@ -36,6 +36,7 @@
}, },
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
"@zip.js/zip.js": "^2.7.57" "@zip.js/zip.js": "^2.7.57",
"ses": "^1.12.0"
} }
} }

View File

@ -3,6 +3,10 @@ import { ZipReader, ZipWriter, BlobReader, BlobWriter, TextReader, Uint8ArrayRea
import {traverse, parseXML, serializeToString, Node} from '../DOMUtils.js' import {traverse, parseXML, serializeToString, Node} from '../DOMUtils.js'
import {makeManifestFile, getManifestFileData} from './manifest.js'; import {makeManifestFile, getManifestFileData} from './manifest.js';
import 'ses'
lockdown();
/** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */ /** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */
/** @import {ODFManifest} from './manifest.js' */ /** @import {ODFManifest} from './manifest.js' */
@ -18,41 +22,17 @@ const ODTMimetype = 'application/vnd.oasis.opendocument.text'
/** /**
* @typedef TextPlaceToFill * @typedef TextPlaceToFill
* @property { {expression: string, replacedString:string}[] } expressions * @property { {expression: string, replacedString:string}[] } expressions
* @property {(values: any) => void} fill * @property {() => void} fill
*/ */
/**
* PPP : for now, expression is expected to be only an object property name or a dot-path
* in the future, it will certainly be a JavaScript expression
* securely evaluated within an hardernedJS Compartment https://hardenedjs.org/#compartment
* @param {string} expression
* @param {any} context - data / global object
* @return {any}
*/
function evaluateTemplateExpression(expression, context){
const parts = expression.trim().split('.')
let value = context;
for(const part of parts){
if(!value){
return undefined
}
else{
value = value[part]
}
}
return value
}
/** /**
* @param {string} str * @param {string} str
* @param {Compartment} compartment
* @returns {TextPlaceToFill | undefined} * @returns {TextPlaceToFill | undefined}
*/ */
function findPlacesToFillInString(str) { function findPlacesToFillInString(str, compartment) {
const matches = str.matchAll(/\{([^{#\/]+?)\}/g) const matches = str.matchAll(/\{([^{#\/]+?)\}/g)
/** @type {TextPlaceToFill['expressions']} */ /** @type {TextPlaceToFill['expressions']} */
@ -75,8 +55,7 @@ function findPlacesToFillInString(str) {
if (fixedPart.length >= 1) if (fixedPart.length >= 1)
parts.push(fixedPart) parts.push(fixedPart)
parts.push(() => compartment.evaluate(expression))
parts.push(data => evaluateTemplateExpression(expression, data))
remaining = newRemaining remaining = newRemaining
} }
@ -117,10 +96,9 @@ function findPlacesToFillInString(str) {
* @param {string} iterableExpression * @param {string} iterableExpression
* @param {string} itemExpression * @param {string} itemExpression
* @param {Node} endNode * @param {Node} endNode
* @param {any} data * @param {Compartment} compartment
* @param {typeof Node} Node
*/ */
function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, data, Node){ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment){
//console.log('fillEachBlock', iterableExpression, itemExpression) //console.log('fillEachBlock', iterableExpression, itemExpression)
//console.log('startNode', startNode.nodeType, startNode.nodeName) //console.log('startNode', startNode.nodeType, startNode.nodeName)
//console.log('endNode', endNode.nodeType, endNode.nodeName) //console.log('endNode', endNode.nodeType, endNode.nodeName)
@ -189,7 +167,7 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d
// Find the iterable in the data // Find the iterable in the data
// PPP eventually, evaluate the expression as a JS expression // PPP eventually, evaluate the expression as a JS expression
let iterable = evaluateTemplateExpression(iterableExpression, data) let iterable = compartment.evaluate(iterableExpression)
if(!iterable || typeof iterable[Symbol.iterator] !== 'function'){ if(!iterable || typeof iterable[Symbol.iterator] !== 'function'){
// when there is no iterable, silently replace with empty array // when there is no iterable, silently replace with empty array
iterable = [] iterable = []
@ -202,11 +180,14 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d
// @ts-ignore // @ts-ignore
const itemFragment = repeatedFragment.cloneNode(true) const itemFragment = repeatedFragment.cloneNode(true)
let insideCompartment = new Compartment(
Object.assign({}, compartment.globalThis, {[itemExpression]: item})
)
// recursive call to fillTemplatedOdtElement on itemFragment // recursive call to fillTemplatedOdtElement on itemFragment
fillTemplatedOdtElement( fillTemplatedOdtElement(
itemFragment, itemFragment,
Object.assign({}, data, {[itemExpression]: item}), insideCompartment
Node
) )
// @ts-ignore // @ts-ignore
commonAncestor.insertBefore(itemFragment, endChild) commonAncestor.insertBefore(itemFragment, endChild)
@ -220,12 +201,11 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, d
/** /**
* *
* @param {Element | DocumentFragment} rootElement * @param {Element | DocumentFragment | Document} rootElement
* @param {any} data * @param {Compartment} compartment
* @param {typeof Node} Node
* @returns {void} * @returns {void}
*/ */
function fillTemplatedOdtElement(rootElement, data, Node){ function fillTemplatedOdtElement(rootElement, compartment){
//console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName) //console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName)
// Perform a first traverse to split textnodes when they contain several block markers // Perform a first traverse to split textnodes when they contain several block markers
@ -246,6 +226,7 @@ function fillTemplatedOdtElement(rootElement, data, Node){
let thisMatch = remainingText.match(regexp) let thisMatch = remainingText.match(regexp)
// trying to find only the first match in remainingText string // trying to find only the first match in remainingText string
// @ts-ignore
if(thisMatch && (!match || match.index > thisMatch.index)){ if(thisMatch && (!match || match.index > thisMatch.index)){
match = thisMatch match = thisMatch
} }
@ -255,6 +236,7 @@ function fillTemplatedOdtElement(rootElement, data, Node){
// split 3-way : before-match, match and after-match // split 3-way : before-match, match and after-match
if(match[0].length < remainingText.length){ if(match[0].length < remainingText.length){
// @ts-ignore
let afterMatchTextNode = currentNode.splitText(match.index + match[0].length) let afterMatchTextNode = currentNode.splitText(match.index + match[0].length)
if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1){ if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1){
remainingText = afterMatchTextNode.textContent remainingText = afterMatchTextNode.textContent
@ -265,7 +247,9 @@ function fillTemplatedOdtElement(rootElement, data, Node){
// per spec, currentNode now contains before-match and match text // per spec, currentNode now contains before-match and match text
// @ts-ignore
if(match.index > 0){ if(match.index > 0){
// @ts-ignore
currentNode.splitText(match.index) currentNode.splitText(match.index)
} }
@ -344,7 +328,7 @@ function fillTemplatedOdtElement(rootElement, data, Node){
// found an #each and its corresponding /each // found an #each and its corresponding /each
// execute replacement loop // execute replacement loop
fillEachBlock(eachBlockStartNode, iterableExpression, itemExpression, eachBlockEndNode, data, Node) fillEachBlock(eachBlockStartNode, iterableExpression, itemExpression, eachBlockEndNode, compartment)
eachBlockStartNode = undefined eachBlockStartNode = undefined
iterableExpression = undefined iterableExpression = undefined
@ -356,12 +340,16 @@ function fillTemplatedOdtElement(rootElement, data, Node){
// Looking for variables for substitutions // Looking for variables for substitutions
if(!insideAnEachBlock){ if(!insideAnEachBlock){
// @ts-ignore
if (currentNode.data) { if (currentNode.data) {
const placesToFill = findPlacesToFillInString(currentNode.data) // @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.data, compartment)
if(placesToFill){ if(placesToFill){
const newText = placesToFill.fill(data) const newText = placesToFill.fill()
// @ts-ignore
const newTextNode = currentNode.ownerDocument?.createTextNode(newText) const newTextNode = currentNode.ownerDocument?.createTextNode(newText)
// @ts-ignore
currentNode.parentNode?.replaceChild(newTextNode, currentNode) currentNode.parentNode?.replaceChild(newTextNode, currentNode)
} }
} }
@ -374,10 +362,13 @@ function fillTemplatedOdtElement(rootElement, data, Node){
if(currentNode.nodeType === Node.ATTRIBUTE_NODE){ if(currentNode.nodeType === Node.ATTRIBUTE_NODE){
// Looking for variables for substitutions // Looking for variables for substitutions
if(!insideAnEachBlock){ if(!insideAnEachBlock){
// @ts-ignore
if (currentNode.value) { if (currentNode.value) {
const placesToFill = findPlacesToFillInString(currentNode.value) // @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.value, compartment)
if(placesToFill){ if(placesToFill){
currentNode.value = placesToFill.fill(data) // @ts-ignore
currentNode.value = placesToFill.fill()
} }
} }
} }
@ -454,7 +445,11 @@ export default async function fillOdtTemplate(odtTemplate, data) {
// @ts-ignore // @ts-ignore
const contentXml = await entry.getData(new TextWriter()); const contentXml = await entry.getData(new TextWriter());
const contentDocument = parseXML(contentXml); const contentDocument = parseXML(contentXml);
fillTemplatedOdtElement(contentDocument, data, Node)
const compartment = new Compartment(data)
fillTemplatedOdtElement(contentDocument, compartment)
const updatedContentXml = serializeToString(contentDocument) const updatedContentXml = serializeToString(contentDocument)
content = new TextReader(updatedContentXml) content = new TextReader(updatedContentXml)

View File

@ -36,7 +36,7 @@ Bonjoir ☀️
}); });
test('basic template filling with {#if}', async t => { test.skip('basic template filling with {#if}', async t => {
const templatePath = join(import.meta.dirname, './fixtures/description-nombre.odt') const templatePath = join(import.meta.dirname, './fixtures/description-nombre.odt')
const templateContent = `Description du nombre {n} const templateContent = `Description du nombre {n}
@ -51,7 +51,7 @@ n est un petit nombre
const templateTextContent = await getOdtTextContent(odtTemplate) const templateTextContent = await getOdtTextContent(odtTemplate)
t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template') t.deepEqual(templateTextContent, templateContent, 'reconnaissance du template')
// then branch
const odtResult3 = await fillOdtTemplate(odtTemplate, {n: 3}) const odtResult3 = await fillOdtTemplate(odtTemplate, {n: 3})
const odtResult3TextContent = await getOdtTextContent(odtResult3) const odtResult3TextContent = await getOdtTextContent(odtResult3)
t.deepEqual(odtResult3TextContent, `Description du nombre 3 t.deepEqual(odtResult3TextContent, `Description du nombre 3
@ -59,6 +59,7 @@ n est un petit nombre
n est un petit nombre n est un petit nombre
`) `)
// else branch
const odtResult8 = await fillOdtTemplate(odtTemplate, {n: 8}) const odtResult8 = await fillOdtTemplate(odtTemplate, {n: 8})
const odtResult8TextContent = await getOdtTextContent(odtResult8) const odtResult8TextContent = await getOdtTextContent(odtResult8)
t.deepEqual(odtResult8TextContent, `Description du nombre 8 t.deepEqual(odtResult8TextContent, `Description du nombre 8
@ -338,7 +339,6 @@ Les nombres : 1 1 2 3 5 8 13 21  !!
}); });
test('template filling of a table', async t => { test('template filling of a table', async t => {
const templatePath = join(import.meta.dirname, './fixtures/tableau-simple.odt') const templatePath = join(import.meta.dirname, './fixtures/tableau-simple.odt')
const templateContent = `Évolution énergie en kWh par personne en France const templateContent = `Évolution énergie en kWh par personne en France