diff --git a/package-lock.json b/package-lock.json index 2beaead..2603318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@xmldom/xmldom": "^0.9.8", "@zip.js/zip.js": "^2.7.57", + "image-size": "^2.0.2", "ses": "^1.12.0" }, "devDependencies": { @@ -2185,6 +2186,18 @@ "node": ">=10 <11 || >=12 <13 || >=14" } }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/immutable": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.4.tgz", @@ -6129,6 +6142,11 @@ "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", "dev": true }, + "image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==" + }, "immutable": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.4.tgz", diff --git a/package.json b/package.json index 9c9c43a..170c2d0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@xmldom/xmldom": "^0.9.8", "@zip.js/zip.js": "^2.7.57", + "image-size": "^2.0.2", "ses": "^1.12.0" } } diff --git a/scripts/odf/odt/getOdtTextContent.js b/scripts/odf/odt/getOdtTextContent.js index 36b3160..2422eda 100644 --- a/scripts/odf/odt/getOdtTextContent.js +++ b/scripts/odf/odt/getOdtTextContent.js @@ -7,7 +7,7 @@ import {parseXML, Node} from '../../DOMUtils.js' * @param {ODTFile} odtFile * @returns {Promise} */ -async function getContentDocument(odtFile) { +export async function getContentDocument(odtFile) { const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(odtFile))); const entries = await reader.getEntries(); diff --git a/scripts/odf/templating/fillOdtElementTemplate.js b/scripts/odf/templating/fillOdtElementTemplate.js index 6e25366..a03e5f2 100644 --- a/scripts/odf/templating/fillOdtElementTemplate.js +++ b/scripts/odf/templating/fillOdtElementTemplate.js @@ -1,5 +1,8 @@ import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js"; -import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.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 @@ -182,12 +185,18 @@ class TemplateBlock{ /** @type {Node[]} */ #middleContent; + /**@type {any} */ + #addImageToOdtFile; + /** * * @param {Node} startNode * @param {Node} endNode + * @param {(OdfjsImage) => string} addImageToOdtFile */ - constructor(startNode, endNode){ + constructor(startNode, endNode, addImageToOdtFile){ + this.#addImageToOdtFile = addImageToOdtFile + // @ts-expect-error xmldom.Node this.#commonAncestor = findCommonAncestor(startNode, endNode) @@ -231,7 +240,7 @@ class TemplateBlock{ const startChild = this.startBranch.at(1) if(startChild /*&& startChild !== */){ //console.log('[fillBlockContentTemplate] startChild', startChild.nodeName, startChild.textContent) - fillOdtElementTemplate(startChild, compartement) + fillOdtElementTemplate(startChild, compartement, this.#addImageToOdtFile) } //console.log('[fillBlockContentTemplate] after startChild') @@ -239,7 +248,7 @@ class TemplateBlock{ // 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) + fillOdtElementTemplate(Array.from(this.#middleContent), compartement, this.#addImageToOdtFile) //console.log('[fillBlockContentTemplate] after middleContent') @@ -249,7 +258,7 @@ class TemplateBlock{ if(endChild){ //console.log('[fillBlockContentTemplate] endChild', endChild.nodeName, endChild.textContent) - fillOdtElementTemplate(endChild, compartement) + fillOdtElementTemplate(endChild, compartement, this.#addImageToOdtFile) } //console.log('[fillBlockContentTemplate] after endChild') @@ -347,7 +356,7 @@ class TemplateBlock{ } } - return new TemplateBlock(startLeafCloneNode, endLeafCloneNode) + return new TemplateBlock(startLeafCloneNode, endLeafCloneNode, this.#addImageToOdtFile) } } @@ -426,8 +435,9 @@ function findPlacesToFillInString(str, compartment) { * @param {Node} ifClosingMarkerNode * @param {string} ifBlockConditionExpression * @param {Compartment} compartment + * // TODO type,addImageToOdtFile */ -function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) { +function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile) { //const docEl = ifOpeningMarkerNode.ownerDocument.documentElement const conditionValue = compartment.evaluate(ifBlockConditionExpression) @@ -443,11 +453,11 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifElseMarkerNode.childNodes.length, ifElseMarkerNode.textContent )*/ - thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode) - elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode) + thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode, addImageToOdtFile) + elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode, addImageToOdtFile) } else { - thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode) + thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode, addImageToOdtFile) } if(conditionValue) { @@ -486,14 +496,15 @@ function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, * @param {string} itemExpression * @param {Node} endNode * @param {Compartment} compartment + * // TODO type addImageToOdtFile */ -function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment) { +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) + const repeatedTemplateBlock = new TemplateBlock(startNode, endNode, addImageToOdtFile) // Find the iterable in the data let iterable = compartment.evaluate(iterableExpression) @@ -554,6 +565,29 @@ function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, c } +/** + * @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 @@ -564,9 +598,10 @@ const EACH = eachStartMarkerRegex.source * * @param {RootElementArgument | RootElementArgument[]} rootElements * @param {Compartment} compartment + * @param {(OdfjsImage) => string} addImageToOdtFile * @returns {void} */ -export default function fillOdtElementTemplate(rootElements, compartment) { +export default function fillOdtElementTemplate(rootElements, compartment, addImageToOdtFile) { if(!Array.isArray(rootElements)){ rootElements = [rootElements] @@ -653,7 +688,7 @@ export default function fillOdtElementTemplate(rootElements, compartment) { // execute replacement loop //console.log('start of fillEachBlock') - fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment) + fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment, addImageToOdtFile) //console.log('end of fillEachBlock') @@ -728,7 +763,7 @@ export default function fillOdtElementTemplate(rootElements, compartment) { // found an {#if} and its corresponding {/if} // execute replacement loop - fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) + fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment, addImageToOdtFile) ifOpeningMarkerNode = undefined ifElseMarkerNode = undefined @@ -761,6 +796,54 @@ export default function fillOdtElementTemplate(rootElements, compartment) { 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}`) + } + } + + } } } diff --git a/scripts/odf/templating/fillOdtTemplate.js b/scripts/odf/templating/fillOdtTemplate.js index 6ca8047..63aea42 100644 --- a/scripts/odf/templating/fillOdtTemplate.js +++ b/scripts/odf/templating/fillOdtTemplate.js @@ -11,7 +11,8 @@ lockdown(); /** @import {Reader, ZipWriterAddDataOptions} from '@zip.js/zip.js' */ -/** @import {ODFManifest} from '../manifest.js' */ +/** @import {ODFManifest, ODFManifestFileEntry} from '../manifest.js' */ +/** @import {OdfjsImage} from '../../types.js' */ /** @typedef {ArrayBuffer} ODTFile */ @@ -23,11 +24,12 @@ const ODTMimetype = 'application/vnd.oasis.opendocument.text' * * @param {Document} document * @param {Compartment} compartment + * @param {(OdfjsImage) => string} addImageToOdtFile * @returns {void} */ -function fillOdtDocumentTemplate(document, compartment) { +function fillOdtDocumentTemplate(document, compartment, addImageToOdtFile) { prepareTemplateDOMTree(document) - fillOdtElementTemplate(document, compartment) + fillOdtElementTemplate(document, compartment, addImageToOdtFile) } @@ -64,6 +66,21 @@ export default async function fillOdtTemplate(odtTemplate, data) { /** @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) { @@ -96,13 +113,15 @@ export default async function fillOdtTemplate(odtTemplate, data) { // @ts-ignore const contentXml = await entry.getData(new TextWriter()); const contentDocument = parseXML(contentXml); + + const compartment = new Compartment({ globals: data, __options__: true }) - fillOdtDocumentTemplate(contentDocument, compartment) + fillOdtDocumentTemplate(contentDocument, compartment, addImageToOdtFile) const updatedContentXml = serializeToString(contentDocument) @@ -138,6 +157,9 @@ export default async function fillOdtTemplate(odtTemplate, data) { } } + 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); diff --git a/scripts/odf/templating/markers.js b/scripts/odf/templating/markers.js index f2cceb5..114e891 100644 --- a/scripts/odf/templating/markers.js +++ b/scripts/odf/templating/markers.js @@ -1,9 +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}' \ No newline at end of file +export const eachClosingMarker = '{/each}' diff --git a/scripts/shared.js b/scripts/shared.js index 725a80b..ba3b9ed 100644 --- a/scripts/shared.js +++ b/scripts/shared.js @@ -4,7 +4,7 @@ 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} from './types.js' */ +/** @import {SheetName, SheetRawContent, SheetRowRawContent, SheetCellRawContent, OdfjsImage} from './types.js' */ // https://dom.spec.whatwg.org/#interface-node @@ -160,6 +160,22 @@ export function convertCellValue({value, type}) { } +/** + * @param {unknown} value + * @returns {value is OdfjsImage} + */ +export function isOdfjsImage(value) { + if (typeof value === 'object' && value!==null + && "content" in value && value.content instanceof ArrayBuffer + && "fileName" in value && typeof value.fileName === 'string' + && "mediaType" in value && typeof value.mediaType === 'string' + ) { + return true + } else { + return false + } +} + diff --git a/scripts/types.js b/scripts/types.js index 9463fe0..afed658 100644 --- a/scripts/types.js +++ b/scripts/types.js @@ -10,4 +10,12 @@ /** @typedef {string} SheetName */ +/** + * @typedef OdfjsImage + * @prop {ArrayBuffer} content + * @prop {string} fileName + * @prop {string} mediaType + * +*/ + export {} \ No newline at end of file diff --git a/tests/fill-odt-template/image.js b/tests/fill-odt-template/image.js index 9162feb..315c475 100644 --- a/tests/fill-odt-template/image.js +++ b/tests/fill-odt-template/image.js @@ -1,13 +1,15 @@ 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} from '../../exports.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 => { +test.skip('template filling preserves images', async t => { const templatePath = join(import.meta.dirname, '../fixtures/template-avec-image.odt') const data = { @@ -35,4 +37,48 @@ test('template filling preserves images', async t => { `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.') + }) \ No newline at end of file diff --git a/tests/fixtures/basic-image-insertion.odt b/tests/fixtures/basic-image-insertion.odt new file mode 100644 index 0000000..021f4a4 Binary files /dev/null and b/tests/fixtures/basic-image-insertion.odt differ diff --git a/tests/fixtures/pitchou-1.png b/tests/fixtures/pitchou-1.png new file mode 100644 index 0000000..ba3c46a Binary files /dev/null and b/tests/fixtures/pitchou-1.png differ diff --git a/tests/fixtures/pitchou-2.png b/tests/fixtures/pitchou-2.png new file mode 100644 index 0000000..757faa3 Binary files /dev/null and b/tests/fixtures/pitchou-2.png differ diff --git a/tools/create-odt-file-from-template.js b/tools/create-odt-file-from-template.js index 6af5ea8..809002b 100644 --- a/tools/create-odt-file-from-template.js +++ b/tools/create-odt-file-from-template.js @@ -1,4 +1,4 @@ -import {writeFile} from 'node:fs/promises' +import {writeFile, readFile} from 'node:fs/promises' import {join} from 'node:path'; import {getOdtTemplate} from '../scripts/odf/odtTemplate-forNode.js' @@ -114,9 +114,33 @@ const data = { ] } */ +// 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/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)