From 245378934366994cfe3ee24dc5db9d83493bf848 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 8 Nov 2026 20:15:45 +0000 Subject: [PATCH] Add support for DOCX file generation and enhance template manager functionality This commit introduces the ability to generate DOCX files in the template manager by adding a new `renderDocx` method. The `wordfactory.js` module is created to handle the generation of Word documents, utilizing the `docx` library for formatting. The template manager is updated to include 'docx' in the download types, and corresponding tests are added to ensure the correct rendering of Word documents. Additionally, the email template schema is modified to support DOCX file types, enhancing the overall document handling capabilities of the application. --- package.json | 3 + pnpm-lock.yaml | 77 +- .../management/emailtemplate.schema.js | 2 +- .../__tests__/templatemanager.test.js | 50 + src/templates/__tests__/wordfactory.test.js | 232 ++++ src/templates/templatemanager.js | 79 +- src/templates/wordfactory.js | 1110 +++++++++++++++++ 7 files changed, 1532 insertions(+), 21 deletions(-) create mode 100644 src/templates/__tests__/wordfactory.test.js create mode 100644 src/templates/wordfactory.js diff --git a/package.json b/package.json index 6717fce..af18abf 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "bcrypt": "^6.0.0", "blurhash": "^2.0.5", "body-parser": "^2.2.0", + "bwip-js": "^4.11.4", "canonical-json": "^0.2.0", "cors": "^2.8.5", "dayjs": "^1.11.23", "diff": "^9.0.0", + "docx": "^9.15.3", "dotenv": "^17.2.3", "ejs": "^3.1.10", "exceljs": "^4.4.0", @@ -63,6 +65,7 @@ "eslint-plugin-prettier": "^5.5.4", "jest": "^30.2.0", "jest-junit": "^16.0.0", + "jszip": "^3.10.2", "prettier": "^3.6.2", "sequelize-cli": "^6.6.3", "standard": "^17.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ecaeb..b7742dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: body-parser: specifier: ^2.2.0 version: 2.2.2 + bwip-js: + specifier: ^4.11.4 + version: 4.11.4 canonical-json: specifier: ^0.2.0 version: 0.2.0 @@ -41,6 +44,9 @@ importers: diff: specifier: ^9.0.0 version: 9.0.0 + docx: + specifier: ^9.15.3 + version: 9.15.3 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -177,6 +183,9 @@ importers: jest-junit: specifier: ^16.0.0 version: 16.0.0 + jszip: + specifier: ^3.10.2 + version: 3.10.2 prettier: specifier: ^3.6.2 version: 3.8.1 @@ -1738,6 +1747,9 @@ packages: '@types/node@25.2.0': resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -2125,11 +2137,6 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} - engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.1, please upgrade - basic-ftp@5.3.1: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} @@ -2231,6 +2238,10 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + bwip-js@4.11.4: + resolution: {integrity: sha512-FOXnOQkinxog8FwxdnPB+8k2wRqwPnqXwxX37KcXOtMNW+9CinOkGv9MtIoRC4HX/UyogT0/9lIp6FDm2DcquQ==} + hasBin: true + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2281,8 +2292,8 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chromedriver@152.0.1: - resolution: {integrity: sha512-8zet2yPAFOSlYxUIcQ/1yOOC1xP4ooPZiKFRdlJhFawBKvlZIHDU6qv4LDOfPEw1yXbyu+tQTzbL0Vfb+vHdQQ==} + chromedriver@157.0.0: + resolution: {integrity: sha512-IwYuey+2f+yczWMxei84oF2MMKbrrDU+G4AQT95fXLG3rURvB7pemRgZWDLVZHDMV6g5eDPKE0TT9s6Lw45Kbg==} engines: {node: '>=22'} hasBin: true @@ -2567,6 +2578,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + docx@9.15.3: + resolution: {integrity: sha512-3FnU6K5cuvGXEAn24yhvP3mfrZ1G8k9+hH8pSVDYsMR+mGmfK/t0NR5AwoMQodnaaxiRt2e+SvckffcJe/NvTQ==} + engines: {node: '>=16'} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -2956,6 +2971,9 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3715,8 +3733,8 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jszip@3.10.1: - resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -4076,6 +4094,11 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanoid@6.0.2: + resolution: {integrity: sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==} + engines: {node: ^22 || ^24 || >=26} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5112,6 +5135,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -7575,6 +7601,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + '@types/stack-utils@2.0.3': {} '@types/validator@13.15.10': {} @@ -8010,10 +8040,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - basic-ftp@5.1.0: {} - - basic-ftp@5.3.1: - optional: true + basic-ftp@5.3.1: {} bcrypt@6.0.0: dependencies: @@ -8117,6 +8144,8 @@ snapshots: dependencies: streamsearch: 1.1.0 + bwip-js@4.11.4: {} + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -8169,7 +8198,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chromedriver@152.0.1: + chromedriver@157.0.0: dependencies: '@testim/chrome-version': 1.1.4 adm-zip: 0.6.0 @@ -8425,6 +8454,12 @@ snapshots: dependencies: esutils: 2.0.3 + docx@9.15.3: + dependencies: + '@types/node': 26.6.6 + fflate: 0.8.3 + nanoid: 6.0.2 + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -8897,7 +8932,7 @@ snapshots: archiver: 5.3.2 dayjs: 1.11.23 fast-csv: 4.3.6 - jszip: 3.10.1 + jszip: 3.10.2 readable-stream: 3.6.2 saxes: 5.0.1 tmp: 0.2.5 @@ -9018,6 +9053,8 @@ snapshots: dependencies: pend: 1.2.0 + fflate@0.8.3: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -9203,7 +9240,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.1.0 + basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: @@ -10041,7 +10078,7 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jszip@3.10.1: + jszip@3.10.2: dependencies: lie: 3.3.0 pako: 1.0.11 @@ -10071,7 +10108,7 @@ snapshots: dependencies: jwk-to-pem: 2.0.7 optionalDependencies: - chromedriver: 152.0.1 + chromedriver: 157.0.0 transitivePeerDependencies: - debug - kerberos @@ -10348,6 +10385,8 @@ snapshots: nanoid@5.1.6: {} + nanoid@6.0.2: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -11592,6 +11631,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@8.9.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: diff --git a/src/database/schemas/management/emailtemplate.schema.js b/src/database/schemas/management/emailtemplate.schema.js index 844ec1f..e25ad3c 100644 --- a/src/database/schemas/management/emailtemplate.schema.js +++ b/src/database/schemas/management/emailtemplate.schema.js @@ -48,7 +48,7 @@ const emailTemplateAttachmentSchema = new Schema( type: { type: String, enum: ['file', 'documentTemplate'], required: true }, file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true }, fileName: { type: String, required: true }, - fileType: { type: String, enum: ['pdf', 'jpeg', 'png', 'svg'], required: false }, + fileType: { type: String, enum: ['pdf', 'jpeg', 'png', 'svg', 'docx'], required: false }, }, { _id: true } ); diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js index 754b6ee..baa1016 100644 --- a/src/templates/__tests__/templatemanager.test.js +++ b/src/templates/__tests__/templatemanager.test.js @@ -62,6 +62,10 @@ jest.unstable_mockModule('../pdffactory.js', () => ({ generatePDF: jest.fn().mockResolvedValue(Buffer.from('pdf-data')), })); +jest.unstable_mockModule('../wordfactory.js', () => ({ + generateDocx: jest.fn().mockResolvedValue(Buffer.from('docx-data')), +})); + jest.unstable_mockModule('../../events/eventmanager.js', () => ({ eventManager: { sendObjectEvent: jest.fn().mockResolvedValue({ success: true }), @@ -122,6 +126,7 @@ const { getObject, listObjects } = await import('../../database/database.js'); const { getFilter } = await import('../../utils.js'); const ejs = (await import('ejs')).default; const { generatePDF } = await import('../pdffactory.js'); +const { generateDocx } = await import('../wordfactory.js'); const { eventManager } = await import('../../events/eventmanager.js'); const { redisServer } = await import('../../database/redis.js'); const { TEMPLATE_RESOURCE_TTL_SECONDS } = await import('../templateresources.js'); @@ -917,6 +922,51 @@ describe('TemplateManager', () => { expect(result.buffers).toHaveLength(1); }); + it('should render a Word download', async () => { + getObject.mockResolvedValue({ + documentSize: { + width: 100, + height: 100, + infiniteHeight: false, + printPadding: true, + paddingLeft: 10, + paddingRight: 10, + paddingTop: 5, + paddingBottom: 5, + }, + global: false, + objectType: 'printer', + }); + + const result = await templateManager.renderDownload('temp-id', 'content', {}, 'docx'); + expect(result.type).toBe('docx'); + expect(result.mime).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); + expect(result.extension).toBe('docx'); + expect(result.buffers).toEqual([Buffer.from('docx-data')]); + expect(generateDocx).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + width: 120, + height: 110, + infiniteHeight: false, + padding: { paddingTop: 5, paddingRight: 10, paddingBottom: 5, paddingLeft: 10 }, + }) + ); + }); + + it('normalizes word as a docx download', async () => { + getObject.mockResolvedValue({ + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: false, + objectType: 'printer', + }); + + const result = await templateManager.renderDownload('temp-id', 'content', {}, 'word'); + expect(result.type).toBe('docx'); + }); + it('stores the render in redis and publishes progress when a renderRequestId is provided', async () => { getObject.mockResolvedValue({ documentSize: { width: 100, height: 100, infiniteHeight: false }, diff --git a/src/templates/__tests__/wordfactory.test.js b/src/templates/__tests__/wordfactory.test.js new file mode 100644 index 0000000..72f2602 --- /dev/null +++ b/src/templates/__tests__/wordfactory.test.js @@ -0,0 +1,232 @@ +import { jest } from '@jest/globals'; +import JSZip from 'jszip'; + +jest.unstable_mockModule('../../config.js', () => ({ + default: { + server: { logLevel: 'info' }, + app: { urlApi: 'https://api.example.test' }, + }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), + }, +})); + +const { generateDocx, cssColorToHex, cssLengthToPx, parseStyle, mmToTwips } = + await import('../wordfactory.js'); + +function wrap(content) { + return `
${content}
`; +} + +async function readDocx(buffer) { + const zip = await JSZip.loadAsync(buffer); + const read = async (name) => (zip.file(name) ? zip.file(name).async('string') : ''); + return { + document: await read('word/document.xml'), + header: await read('word/header1.xml'), + footer: await read('word/footer1.xml'), + media: Object.keys(zip.files).filter((name) => name.startsWith('word/media/')), + }; +} + +function textOf(xml) { + return [...xml.matchAll(/]*>([^<]*)<\/w:t>/g)].map((match) => match[1]); +} + +const pageOptions = { + width: 100, + height: 60, + padding: { paddingTop: 5, paddingRight: 5, paddingBottom: 5, paddingLeft: 5 }, +}; + +describe('wordfactory helpers', () => { + it('parses style declarations', () => { + expect(parseStyle('color: red; font-size: 14px;')).toEqual({ + color: 'red', + 'font-size': '14px', + }); + }); + + it('converts css colours to hex', () => { + expect(cssColorToHex('#f00')).toBe('FF0000'); + expect(cssColorToHex('rgb(0, 128, 255)')).toBe('0080FF'); + expect(cssColorToHex('black')).toBe('000000'); + expect(cssColorToHex('#0000002e')).toBe('D1D1D1'); + expect(cssColorToHex('transparent')).toBeUndefined(); + }); + + it('converts css lengths to pixels', () => { + expect(cssLengthToPx('12px')).toBe(12); + expect(cssLengthToPx('9pt')).toBe(12); + expect(cssLengthToPx('2em', 10)).toBe(20); + expect(cssLengthToPx('50%')).toBeNull(); + }); + + it('converts millimetres to twips', () => { + expect(mmToTwips(25.4)).toBe(1440); + }); +}); + +describe('generateDocx', () => { + it('produces a zip (docx) buffer with the page size and margins', async () => { + const buffer = await generateDocx(wrap('

Hello

'), pageOptions); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.subarray(0, 2).toString()).toBe('PK'); + + const { document } = await readDocx(buffer); + expect(document).toContain(` { + const buffer = await generateDocx(wrap('

Hello

'), { + width: 80, + height: 'auto', + infiniteHeight: true, + }); + const { document } = await readDocx(buffer); + expect(document).toContain(` { + const buffer = await generateDocx( + wrap('

Report & Summary

'), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(textOf(document)).toEqual(['Report & Summary']); + expect(document).toContain(''); + // 34px -> 25.5pt -> 51 half-points + expect(document).toContain(''); + }); + + it('applies inline colour, size, and alignment to styled paragraphs', async () => { + const buffer = await generateDocx( + wrap( + '

Styled bold

' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(textOf(document)).toEqual(['Styled ', 'bold']); + expect(document).toContain(''); + expect(document).toContain(''); + expect(document).toContain(''); + expect(document).toContain(' { + const buffer = await generateDocx( + wrap( + '

Left

Right

' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(document.match(//g)).toHaveLength(1); + expect(document.match(//g)).toHaveLength(2); + expect(textOf(document)).toEqual(['Left', 'Right']); + expect(document).toContain('w:val="none"'); + expect(document).not.toContain('w:val="single"'); + }); + + it('renders tables with borders and shaded header rows', async () => { + const buffer = await generateDocx( + wrap( + '
NameQty
Widget3
' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(document.match(//g)).toHaveLength(1); + expect(document.match(//g)).toHaveLength(2); + expect(textOf(document)).toEqual(['Name', 'Qty', 'Widget', '3']); + expect(document).toContain('w:val="single"'); + expect(document).toContain('w:fill="D1D1D1"'); + expect(document).toContain(''); + }); + + it('renders dividers as a bottom paragraph border', async () => { + const buffer = await generateDocx(wrap('
'), pageOptions); + const { document } = await readDocx(buffer); + expect(document).toMatch(/]*w:val="single"/); + }); + + it('renders page breaks', async () => { + const buffer = await generateDocx( + wrap( + '

One


Two

' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(document).toContain(''); + expect(textOf(document)).toEqual(['One', 'Two']); + }); + + it('renders progress bars as a two-cell table', async () => { + const buffer = await generateDocx( + wrap( + '
' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(document.match(//g)).toHaveLength(2); + expect(document).toContain('w:fill="000000"'); + }); + + it('places header and footer elements in the section header and footer', async () => { + const buffer = await generateDocx( + wrap( + '

Top

Body

Page !pageNumber of !totalPages

' + ), + pageOptions + ); + const { document, header, footer } = await readDocx(buffer); + expect(textOf(document)).toEqual(['Body']); + expect(textOf(header)).toEqual(['Top']); + expect(footer).toContain('PAGE'); + expect(footer).toContain('NUMPAGES'); + expect(textOf(footer)).toEqual(['Page ', ' of ']); + }); + + it('embeds barcodes as images', async () => { + const buffer = await generateDocx( + wrap( + '
' + ), + pageOptions + ); + const { document, media } = await readDocx(buffer); + expect(media).toHaveLength(1); + expect(document).toContain(''); + }); + + it('embeds data-uri images', async () => { + const png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + const buffer = await generateDocx( + wrap(``), + pageOptions + ); + const { document, media } = await readDocx(buffer); + expect(media).toHaveLength(1); + expect(document).toContain(''); + }); + + it('reports progress', async () => { + const onProgress = jest.fn().mockResolvedValue(undefined); + await generateDocx(wrap('

Hello

'), { ...pageOptions, onProgress }); + expect(onProgress).toHaveBeenCalledWith(1, 'Word document ready'); + }); +}); diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index 6c334ec..17ea243 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -17,6 +17,7 @@ import { getObject, listObjects } from '../database/database.js'; import { getModelByName } from '../services/misc/model.js'; import { getFilter } from '../utils.js'; import { generatePDF } from './pdffactory.js'; +import { generateDocx } from './wordfactory.js'; import { convertPDFToImage } from './pdfUtils.js'; import { createCursorIntellisenseRender, @@ -40,7 +41,8 @@ dayjs.extend(timezone); const logger = log4js.getLogger('Template Manager'); logger.level = config.server.logLevel; -const DOWNLOAD_TYPES = ['pdf', 'png', 'svg', 'jpeg']; +const DOWNLOAD_TYPES = ['pdf', 'png', 'svg', 'jpeg', 'docx']; +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX = 'documenttemplaterenders:'; const DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS = 15; @@ -379,6 +381,9 @@ function normalizeDownloadType(type) { if (normalized === 'jpg') { return 'jpeg'; } + if (normalized === 'word' || normalized === 'doc') { + return 'docx'; + } return normalized; } @@ -1259,6 +1264,60 @@ export class TemplateManager { }); } + async renderDocx(id, content, data = {}, options = {}) { + return runWithTemplateResourceSession(async () => { + try { + logger.debug('Rendering Word document for template:', id); + const { onProgress, padding, ejsOptions } = splitRenderOptions(options); + const report = typeof onProgress === 'function' ? onProgress : async () => {}; + const templateOptions = { padding, ...ejsOptions, onProgress: report }; + + const renderedTemplate = await this.renderTemplate( + id, + content, + data, + 1, + templateOptions, + false + ); + + if (renderedTemplate.error != undefined) { + await clearTemplateResourceKeys(); + return { error: renderedTemplate.error, code: renderedTemplate.code }; + } + + await report(0.35, 'Generating Word document...'); + const docxBuffer = await generateDocx(renderedTemplate.html, { + width: renderedTemplate.width, + height: renderedTemplate.height, + infiniteHeight: renderedTemplate.infiniteHeight, + padding: { + paddingTop: renderedTemplate.paddingTop, + paddingRight: renderedTemplate.paddingRight, + paddingBottom: renderedTemplate.paddingBottom, + paddingLeft: renderedTemplate.paddingLeft, + }, + onProgress: async (progress, message) => { + await report(0.35 + progress * 0.55, message); + }, + }); + + // Resource URLs are fetched while building the document, so clear afterwards. + await clearTemplateResourceKeys(); + return { + docx: docxBuffer, + width: renderedTemplate.width, + height: renderedTemplate.height, + infiniteHeight: renderedTemplate.infiniteHeight, + }; + } catch (error) { + logger.warn('Error whilst rendering Word document:', error.message); + await clearTemplateResourceKeys(); + return { error: error.message, code: 500 }; + } + }); + } + async renderImages(id, content, data = {}, options = {}, format = 'jpeg') { try { logger.debug(`Rendering ${format} for template:`, id); @@ -1344,7 +1403,7 @@ export class TemplateManager { if (!DOWNLOAD_TYPES.includes(normalizedType)) { const result = { - error: `Unsupported download type: ${type}. Use pdf, png, svg or jpeg.`, + error: `Unsupported download type: ${type}. Use pdf, png, svg, jpeg or docx.`, code: 400, }; if (renderRequestId) { @@ -1372,6 +1431,21 @@ export class TemplateManager { extension: 'pdf', buffers: [result.pdf], }; + } else if (normalizedType === 'docx') { + result = await this.renderDocx(id, content, data, renderOptions); + if (result.error) { + if (renderRequestId) { + await storeDocumentTemplateRender(renderRequestId, result); + await report(1, result.error); + } + return result; + } + result = { + type: 'docx', + mime: DOCX_MIME, + extension: 'docx', + buffers: [result.docx], + }; } else if (normalizedType === 'svg') { result = await this.renderSVG(id, content, data, renderOptions); if (result.error) { @@ -1538,6 +1612,7 @@ export class TemplateManager { export const templateManager = new TemplateManager(); export { DOWNLOAD_TYPES, + DOCX_MIME, normalizeDownloadType, DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX, DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS, diff --git a/src/templates/wordfactory.js b/src/templates/wordfactory.js new file mode 100644 index 0000000..fcbe77d --- /dev/null +++ b/src/templates/wordfactory.js @@ -0,0 +1,1110 @@ +import log4js from 'log4js'; +import posthtml from 'posthtml'; +import axios from 'axios'; +import sharp from 'sharp'; +import bwipjs from 'bwip-js'; +import { + AlignmentType, + BorderStyle, + Document, + Footer, + Header, + ImageRun, + Packer, + PageBreak, + PageNumber, + Paragraph, + ShadingType, + Table, + TableCell, + TableRow, + TextRun, + VerticalAlign, + WidthType, +} from 'docx'; +import config from '../config.js'; + +const logger = log4js.getLogger('Word Factory'); +logger.level = config.server.logLevel; + +const DEFAULT_FONT = 'Calibri'; +const DEFAULT_TEXT_PX = 12; +const DEFAULT_PAGE_HEIGHT_MM = 297; +const PX_PER_MM = 96 / 25.4; +const TWIPS_PER_MM = 1440 / 25.4; +const IMAGE_FETCH_TIMEOUT_MS = 15000; + +const TITLE_STYLES = { + h1: { px: 34, bold: true }, + h2: { px: 24, bold: true }, + h3: { px: 19, bold: true }, + h4: { px: 16, bold: true }, +}; + +const BARCODE_FORMATS = { + code128: 'code128', + code39: 'code39', + ean13: 'ean13', + ean8: 'ean8', + upc: 'upca', + upca: 'upca', + qrcode: 'qrcode', +}; + +const NAMED_COLORS = { + black: '000000', + white: 'FFFFFF', + red: 'FF0000', + green: '008000', + blue: '0000FF', + yellow: 'FFFF00', + orange: 'FFA500', + grey: '808080', + gray: '808080', + silver: 'C0C0C0', + purple: '800080', + pink: 'FFC0CB', + brown: 'A52A2A', + transparent: null, +}; + +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', +}; + +const NO_BORDER = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }; +const NO_BORDERS = { + top: NO_BORDER, + bottom: NO_BORDER, + left: NO_BORDER, + right: NO_BORDER, + insideHorizontal: NO_BORDER, + insideVertical: NO_BORDER, +}; + +function solidBorder(color = '000000', size = 6) { + return { style: BorderStyle.SINGLE, size, color }; +} + +function solidBorders(color = '000000', size = 6) { + const border = solidBorder(color, size); + return { + top: border, + bottom: border, + left: border, + right: border, + insideHorizontal: border, + insideVertical: border, + }; +} + +export function mmToTwips(mm) { + return Math.round((Number(mm) || 0) * TWIPS_PER_MM); +} + +export function decodeEntities(text) { + return String(text ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => { + const lower = entity.toLowerCase(); + if (lower.startsWith('#x')) { + return String.fromCodePoint(parseInt(lower.slice(2), 16)); + } + if (lower.startsWith('#')) { + return String.fromCodePoint(parseInt(lower.slice(1), 10)); + } + return ENTITIES[lower] ?? match; + }); +} + +export function parseStyle(style) { + const result = {}; + if (typeof style !== 'string') return result; + for (const declaration of style.split(';')) { + const index = declaration.indexOf(':'); + if (index === -1) continue; + const property = declaration.slice(0, index).trim().toLowerCase(); + const value = declaration.slice(index + 1).trim(); + if (property && value) { + result[property] = value; + } + } + return result; +} + +/** + * Converts a CSS length to CSS pixels. Returns null when the value cannot be + * expressed as an absolute length (percentages, auto, keywords). + */ +export function cssLengthToPx(value, basePx = DEFAULT_TEXT_PX) { + if (value == null) return null; + const match = String(value) + .trim() + .match(/^(-?\d*\.?\d+)\s*(px|pt|mm|cm|in|em|rem)?$/i); + if (!match) return null; + const number = Number(match[1]); + const unit = (match[2] || 'px').toLowerCase(); + switch (unit) { + case 'pt': + return number / 0.75; + case 'mm': + return number * PX_PER_MM; + case 'cm': + return number * PX_PER_MM * 10; + case 'in': + return number * 96; + case 'em': + case 'rem': + return number * basePx; + default: + return number; + } +} + +export function cssPercent(value) { + const match = String(value ?? '') + .trim() + .match(/^(\d*\.?\d+)%$/); + return match ? Number(match[1]) : null; +} + +function pxToHalfPoints(px) { + return Math.max(2, Math.round(px * 0.75 * 2)); +} + +function pxToTwips(px) { + return Math.round(px * 15); +} + +export function cssColorToHex(color) { + if (color == null) return undefined; + const value = String(color).trim().toLowerCase(); + if (value === '') return undefined; + if (value in NAMED_COLORS) { + return NAMED_COLORS[value] ?? undefined; + } + const hex = value.match(/^#([0-9a-f]{3,8})$/); + if (hex) { + let digits = hex[1]; + if (digits.length === 3 || digits.length === 4) { + digits = digits + .slice(0, 3) + .split('') + .map((char) => char + char) + .join(''); + } + if (digits.length === 8) { + const alpha = parseInt(digits.slice(6, 8), 16) / 255; + return blendWithWhite(digits.slice(0, 6), alpha); + } + return digits.slice(0, 6).toUpperCase(); + } + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/); + if (rgb) { + const channels = [rgb[1], rgb[2], rgb[3]].map((channel) => + Math.min(255, Number(channel)).toString(16).padStart(2, '0') + ); + const alpha = rgb[4] != null ? Number(rgb[4]) : 1; + return blendWithWhite(channels.join(''), alpha); + } + return undefined; +} + +function blendWithWhite(hex, alpha) { + if (!(alpha < 1)) return hex.toUpperCase(); + const blended = [0, 2, 4].map((offset) => { + const channel = parseInt(hex.slice(offset, offset + 2), 16); + return Math.round(channel * alpha + 255 * (1 - alpha)) + .toString(16) + .padStart(2, '0'); + }); + return blended.join('').toUpperCase(); +} + +function classList(node) { + return String(node?.attrs?.class || '') + .split(/\s+/) + .filter(Boolean); +} + +function hasClass(node, className) { + return classList(node).includes(className); +} + +function textAlignToAlignment(value) { + switch (String(value || '').toLowerCase()) { + case 'center': + return AlignmentType.CENTER; + case 'right': + case 'end': + case 'flex-end': + return AlignmentType.RIGHT; + case 'justify': + return AlignmentType.JUSTIFIED; + case 'left': + case 'start': + case 'flex-start': + return AlignmentType.LEFT; + default: + return undefined; + } +} + +function alignItemsToVertical(value) { + switch (String(value || '').toLowerCase()) { + case 'center': + return VerticalAlign.CENTER; + case 'flex-end': + case 'end': + return VerticalAlign.BOTTOM; + default: + return VerticalAlign.TOP; + } +} + +function firstEdge(value) { + if (value == null) return null; + const parts = String(value).trim().split(/\s+/); + const px = cssLengthToPx(parts[0]); + return px == null ? null : px; +} + +/** + * Splits CSS shorthand (margin/padding) into { top, right, bottom, left } px. + */ +function edgesToPx(value) { + if (value == null) return null; + const parts = String(value) + .trim() + .split(/\s+/) + .map((part) => cssLengthToPx(part) ?? 0); + if (parts.length === 0) return null; + const [a, b = a, c = a, d = b] = parts; + return { top: a, right: b, bottom: c, left: d }; +} + +function inheritContext(ctx, styles, node) { + const next = { ...ctx }; + const color = cssColorToHex(styles.color); + if (color) next.color = color; + const sizePx = cssLengthToPx(styles['font-size'], ctx.sizePx); + if (sizePx) next.sizePx = sizePx; + const background = cssColorToHex(styles.background || styles['background-color']); + if (background) next.background = background; + if (styles['font-weight']) { + const weight = styles['font-weight']; + next.bold = weight === 'bold' || weight === 'bolder' || Number(weight) >= 600; + } + if (styles['font-style']) { + next.italic = styles['font-style'] === 'italic'; + } + if (styles['text-decoration']?.includes('underline')) { + next.underline = true; + } + const alignment = textAlignToAlignment(styles['text-align']); + if (alignment) next.alignment = alignment; + if (node && (node.tag === 'strong' || node.tag === 'b')) next.bold = true; + if (node && (node.tag === 'em' || node.tag === 'i')) next.italic = true; + if (node && node.tag === 'u') next.underline = true; + return next; +} + +function runOptions(ctx) { + const options = { + font: ctx.font || DEFAULT_FONT, + size: pxToHalfPoints(ctx.sizePx || DEFAULT_TEXT_PX), + bold: ctx.bold === true, + italics: ctx.italic === true, + }; + if (ctx.underline) options.underline = {}; + if (ctx.color) options.color = ctx.color; + if (ctx.background) { + options.shading = { type: ShadingType.CLEAR, fill: ctx.background, color: 'auto' }; + } + return options; +} + +// Tag TextRuns with their source text so trimRuns can detect whitespace-only runs. +function taggedTextRun(options) { + const run = new TextRun(options); + if (typeof options?.text === 'string') { + Object.defineProperty(run, '__fcText', { value: options.text, enumerable: false }); + } + return run; +} + +function textToRuns(text, ctx) { + const runs = []; + const pattern = /(!pageNumber|!totalPages)/g; + let cursor = 0; + let match; + while ((match = pattern.exec(text)) != null) { + if (match.index > cursor) { + runs.push(taggedTextRun({ ...runOptions(ctx), text: text.slice(cursor, match.index) })); + } + runs.push( + new TextRun({ + ...runOptions(ctx), + children: [match[1] === '!pageNumber' ? PageNumber.CURRENT : PageNumber.TOTAL_PAGES], + }) + ); + cursor = match.index + match[1].length; + } + if (cursor < text.length) { + runs.push(taggedTextRun({ ...runOptions(ctx), text: text.slice(cursor) })); + } + return runs; +} + +async function fetchImageBuffer(src) { + const value = String(src || '').trim(); + if (!value) return null; + const dataUri = value.match(/^data:([^;,]+)?(;base64)?,(.*)$/s); + if (dataUri) { + return dataUri[2] + ? Buffer.from(dataUri[3], 'base64') + : Buffer.from(decodeURIComponent(dataUri[3]), 'utf8'); + } + if (/^https?:\/\//i.test(value)) { + const response = await axios.get(value, { + responseType: 'arraybuffer', + timeout: IMAGE_FETCH_TIMEOUT_MS, + }); + return Buffer.from(response.data); + } + return null; +} + +async function toImageRun(buffer, styles, ctx) { + if (!buffer || buffer.length === 0) return null; + let image = sharp(buffer); + let metadata = await image.metadata(); + let type = metadata.format === 'jpeg' ? 'jpg' : metadata.format; + if (type !== 'png' && type !== 'jpg') { + buffer = await image.png().toBuffer(); + image = sharp(buffer); + metadata = await image.metadata(); + type = 'png'; + } + const naturalWidth = metadata.width || 100; + const naturalHeight = metadata.height || 100; + + let widthPx = cssLengthToPx(styles.width, ctx.sizePx); + let heightPx = cssLengthToPx(styles.height, ctx.sizePx); + const widthPercent = cssPercent(styles.width); + if (widthPx == null && widthPercent != null) { + widthPx = (ctx.contentWidthPx * widthPercent) / 100; + } + if (widthPx == null && heightPx == null) { + widthPx = Math.min(naturalWidth, ctx.contentWidthPx); + } + if (widthPx == null) { + widthPx = (heightPx * naturalWidth) / naturalHeight; + } + if (heightPx == null) { + heightPx = (widthPx * naturalHeight) / naturalWidth; + } + if (widthPx > ctx.contentWidthPx) { + heightPx = (heightPx * ctx.contentWidthPx) / widthPx; + widthPx = ctx.contentWidthPx; + } + + return new ImageRun({ + type, + data: buffer, + transformation: { width: Math.round(widthPx), height: Math.round(heightPx) }, + }); +} + +async function barcodeToImageRun(svgNode, containerStyles, ctx) { + const attrs = svgNode?.attrs || {}; + const text = decodeEntities(attrs['jsbarcode-value'] || '').trim(); + if (!text) return null; + const format = String(attrs['jsbarcode-format'] || 'code128').toLowerCase(); + const bcid = BARCODE_FORMATS[format] || 'code128'; + try { + const png = await bwipjs.toBuffer({ + bcid, + text, + scale: 3, + height: bcid === 'qrcode' ? undefined : 12, + includetext: false, + }); + return toImageRun(png, containerStyles, ctx); + } catch (error) { + logger.warn(`Failed to render ${format} barcode for Word export:`, error.message); + return textToRuns(text, ctx)[0]; + } +} + +function paragraphOptions(ctx, styles = {}) { + const options = {}; + const alignment = textAlignToAlignment(styles['text-align']) || ctx.alignment; + if (alignment) options.alignment = alignment; + const margin = edgesToPx(styles.margin); + const padding = edgesToPx(styles.padding); + const before = (margin?.top || 0) + (padding?.top || 0); + const after = (margin?.bottom || 0) + (padding?.bottom || 0); + options.spacing = { before: pxToTwips(before), after: pxToTwips(after) }; + const indentLeft = (margin?.left || 0) + (padding?.left || 0); + const indentRight = (margin?.right || 0) + (padding?.right || 0); + if (indentLeft || indentRight) { + options.indent = { left: pxToTwips(indentLeft), right: pxToTwips(indentRight) }; + } + const background = cssColorToHex(styles.background || styles['background-color']); + if (background) { + options.shading = { type: ShadingType.CLEAR, fill: background, color: 'auto' }; + } + const borderPx = firstEdge(styles.border); + if (borderPx) { + const colorMatch = String(styles.border).match(/(#[0-9a-f]{3,8}|rgba?\([^)]*\)|[a-z]+)\s*$/i); + const border = solidBorder( + cssColorToHex(colorMatch?.[1]) || '000000', + Math.max(2, borderPx * 6) + ); + options.border = { top: border, bottom: border, left: border, right: border }; + } + return options; +} + +class WordBuilder { + constructor(ctx) { + this.ctx = ctx; + } + + isBlock(node) { + if (typeof node === 'string') return false; + const tag = node.tag; + return ( + [ + 'div', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'table', + 'ul', + 'ol', + 'li', + 'section', + 'article', + 'header', + 'footer', + 'main', + 'aside', + 'nav', + 'blockquote', + 'pre', + ].includes(tag) || + (tag === 'img' && !hasClass(node, 'inline')) + ); + } + + /** + * Renders a list of nodes to block-level docx elements (Paragraph | Table). + */ + async renderBlocks(nodes, ctx, parentStyles = {}) { + const blocks = []; + let pendingRuns = []; + + const flush = () => { + pendingRuns = trimRuns(pendingRuns); + if (pendingRuns.length === 0) { + pendingRuns = []; + return; + } + blocks.push(new Paragraph({ ...paragraphOptions(ctx), children: pendingRuns })); + pendingRuns = []; + }; + + for (const node of nodes || []) { + if (typeof node === 'string') { + const text = decodeEntities(node).replace(/\s+/g, ' '); + if (text.trim() === '' && pendingRuns.length === 0) continue; + pendingRuns.push(...textToRuns(text, ctx)); + continue; + } + if (node == null || node.tag === 'script' || node.tag === 'style') continue; + + if (this.isBlock(node)) { + flush(); + blocks.push(...(await this.renderBlock(node, ctx))); + } else { + pendingRuns.push(...(await this.renderInline(node, ctx))); + } + } + flush(); + return blocks; + } + + async renderInline(node, ctx) { + if (typeof node === 'string') { + return textToRuns(decodeEntities(node).replace(/\s+/g, ' '), ctx); + } + if (node.tag === 'br') { + return [new TextRun({ ...runOptions(ctx), break: 1 })]; + } + const styles = parseStyle(node.attrs?.style); + const next = inheritContext(ctx, styles, node); + + if (node.tag === 'img') { + try { + const buffer = await fetchImageBuffer(node.attrs?.src); + const run = await toImageRun(buffer, styles, next); + return run ? [run] : []; + } catch (error) { + logger.warn('Failed to embed image in Word export:', error.message); + return []; + } + } + if (node.tag === 'svg' && node.attrs?.['jsbarcode-value'] != null) { + const run = await barcodeToImageRun(node, {}, next); + return run ? [run] : []; + } + if (node.tag === 'svg') { + return []; + } + + const runs = []; + for (const child of node.content || []) { + runs.push(...(await this.renderInline(child, next))); + } + return runs; + } + + async renderBlock(node, ctx) { + const styles = parseStyle(node.attrs?.style); + const next = inheritContext(ctx, styles, node); + const tag = node.tag; + + if (tag === 'hr') { + if (hasClass(node, 'documentPageBreak')) { + return [new Paragraph({ children: [new PageBreak()] })]; + } + const color = cssColorToHex(styles.background) || '000000'; + return [ + new Paragraph({ + spacing: { before: pxToTwips(4), after: pxToTwips(4) }, + border: { bottom: { ...solidBorder(color, 6), space: 1 } }, + children: [], + }), + ]; + } + + if (TITLE_STYLES[tag]) { + const title = TITLE_STYLES[tag]; + const titleCtx = { + ...next, + sizePx: cssLengthToPx(styles['font-size'], ctx.sizePx) || title.px, + bold: styles['font-weight'] ? next.bold : title.bold, + }; + const runs = []; + for (const child of node.content || []) { + runs.push(...(await this.renderInline(child, titleCtx))); + } + return [new Paragraph({ ...paragraphOptions(titleCtx, styles), children: trimRuns(runs) })]; + } + + if (tag === 'p' || tag === 'li' || tag === 'blockquote' || tag === 'pre') { + const runs = []; + if (tag === 'li') { + runs.push(new TextRun({ ...runOptions(next), text: '\u2022 ' })); + } + let hasBlockChild = false; + for (const child of node.content || []) { + if (this.isBlock(child)) { + hasBlockChild = true; + break; + } + } + if (hasBlockChild) { + return this.renderBlocks(node.content, next, styles); + } + for (const child of node.content || []) { + runs.push(...(await this.renderInline(child, next))); + } + return [new Paragraph({ ...paragraphOptions(next, styles), children: trimRuns(runs) })]; + } + + if (tag === 'img') { + try { + const buffer = await fetchImageBuffer(node.attrs?.src); + const run = await toImageRun(buffer, styles, next); + if (!run) return []; + return [new Paragraph({ ...paragraphOptions(next, styles), children: [run] })]; + } catch (error) { + logger.warn('Failed to embed image in Word export:', error.message); + return []; + } + } + + if (tag === 'table') { + return [await this.renderTable(node, next, styles)]; + } + + if (hasClass(node, 'documentProgressBar')) { + return [this.renderProgressBar(node, next, styles)]; + } + + if (hasClass(node, 'documentBarcodeContainer')) { + const svg = (node.content || []).find((child) => child?.tag === 'svg'); + const run = await barcodeToImageRun(svg, styles, next); + if (!run) return []; + return [new Paragraph({ ...paragraphOptions(next, styles), children: [run] })]; + } + + if (hasClass(node, 'documentFlex') && !/column/i.test(styles['flex-direction'] || '')) { + return [await this.renderFlexRow(node, next, styles)]; + } + + // Generic container (div, Container, vertical Flex, section...). + const hasBorder = firstEdge(styles.border) != null; + const background = cssColorToHex(styles.background || styles['background-color']); + const containerCtx = { ...next }; + const padding = edgesToPx(styles.padding); + const innerWidthPx = Math.max( + 20, + ctx.contentWidthPx - ((padding?.left || 0) + (padding?.right || 0)) + ); + const children = await this.renderBlocks( + node.content, + { ...containerCtx, contentWidthPx: innerWidthPx }, + styles + ); + if (children.length === 0 && !hasBorder && !background) return []; + + if (hasBorder || background || padding) { + const borderColorMatch = String(styles.border || '').match( + /(#[0-9a-f]{3,8}|rgba?\([^)]*\)|[a-z]+)\s*$/i + ); + const borders = hasBorder + ? solidBorders(cssColorToHex(borderColorMatch?.[1]) || '000000', 6) + : NO_BORDERS; + const cell = new TableCell({ + children: children.length ? children : [new Paragraph({ children: [] })], + borders, + shading: background + ? { type: ShadingType.CLEAR, fill: background, color: 'auto' } + : undefined, + margins: padding + ? { + top: pxToTwips(padding.top), + bottom: pxToTwips(padding.bottom), + left: pxToTwips(padding.left), + right: pxToTwips(padding.right), + } + : undefined, + }); + const widthPx = cssLengthToPx(styles.width, ctx.sizePx); + const widthPercent = cssPercent(styles.width); + const tableWidth = widthPx + ? { size: pxToTwips(widthPx), type: WidthType.DXA } + : { size: widthPercent ?? 100, type: WidthType.PERCENTAGE }; + const margin = edgesToPx(styles.margin); + const blocks = []; + if (margin?.top) blocks.push(spacer(margin.top)); + blocks.push( + new Table({ rows: [new TableRow({ children: [cell] })], width: tableWidth, borders }) + ); + if (margin?.bottom) blocks.push(spacer(margin.bottom)); + return blocks; + } + + const margin = edgesToPx(styles.margin); + const blocks = []; + if (margin?.top) blocks.push(spacer(margin.top)); + blocks.push(...children); + if (margin?.bottom) blocks.push(spacer(margin.bottom)); + return blocks; + } + + async renderFlexRow(node, ctx, styles) { + const items = (node.content || []).filter( + (child) => typeof child !== 'string' || child.trim() !== '' + ); + if (items.length === 0) { + return new Paragraph({ children: [] }); + } + const gapPx = cssLengthToPx(styles['column-gap'] || styles.gap, ctx.sizePx) || 0; + const justify = String(styles['justify-content'] || '').toLowerCase(); + const verticalAlign = alignItemsToVertical(styles['align-items']); + + // Column widths: explicit widths first, remaining space shared by flex-grow or evenly. + const widths = items.map((item) => { + if (typeof item === 'string') return null; + const itemStyles = parseStyle(item.attrs?.style); + const px = cssLengthToPx(itemStyles.width, ctx.sizePx); + if (px != null) return px; + const percent = cssPercent(itemStyles.width); + if (percent != null) return (ctx.contentWidthPx * percent) / 100; + return null; + }); + const fixedTotal = widths.reduce((sum, width) => sum + (width || 0), 0); + const flexible = widths.filter((width) => width == null).length; + const remaining = Math.max(0, ctx.contentWidthPx - fixedTotal - gapPx * (items.length - 1)); + const grows = items.map((item) => + typeof item === 'string' ? 1 : Number(parseStyle(item.attrs?.style)['flex-grow']) || 1 + ); + const growTotal = items.reduce( + (sum, _item, index) => sum + (widths[index] == null ? grows[index] : 0), + 0 + ); + const resolvedWidths = widths.map((width, index) => { + if (width != null) return width; + if (flexible === 0) return 0; + return (remaining * grows[index]) / (growTotal || flexible); + }); + + const cells = []; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const cellWidthPx = Math.max(10, resolvedWidths[index]); + let alignment; + if (justify === 'center') alignment = AlignmentType.CENTER; + else if (justify === 'flex-end' || justify === 'end') alignment = AlignmentType.RIGHT; + else if (justify === 'space-between' && items.length > 1) { + if (index === items.length - 1) alignment = AlignmentType.RIGHT; + else if (index > 0) alignment = AlignmentType.CENTER; + } else if (justify === 'space-around' || justify === 'space-evenly') { + alignment = AlignmentType.CENTER; + } + const cellCtx = { ...ctx, contentWidthPx: cellWidthPx }; + if (alignment) cellCtx.alignment = alignment; + const children = + typeof item === 'string' + ? await this.renderBlocks([item], cellCtx) + : this.isBlock(item) + ? await this.renderBlock(item, cellCtx) + : await this.renderBlocks([item], cellCtx); + cells.push( + new TableCell({ + children: children.length ? children : [new Paragraph({ children: [] })], + width: { size: pxToTwips(cellWidthPx), type: WidthType.DXA }, + borders: NO_BORDERS, + verticalAlign, + margins: { + top: 0, + bottom: 0, + left: index === 0 ? 0 : pxToTwips(gapPx / 2), + right: index === items.length - 1 ? 0 : pxToTwips(gapPx / 2), + }, + }) + ); + } + + return new Table({ + rows: [new TableRow({ children: cells })], + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: resolvedWidths.map((width) => pxToTwips(Math.max(10, width))), + borders: NO_BORDERS, + }); + } + + renderProgressBar(node, ctx, styles) { + const inner = (node.content || []).find((child) => child?.tag === 'div'); + const innerStyles = parseStyle(inner?.attrs?.style); + const percent = Math.min(100, Math.max(0, cssPercent(innerStyles.width) ?? 0)); + const widthPx = cssLengthToPx(styles.width, ctx.sizePx) || 260; + const heightPx = cssLengthToPx(styles.height, ctx.sizePx) || 8; + const fill = cssColorToHex(innerStyles.background) || '000000'; + const borderColor = '000000'; + const totalTwips = pxToTwips(widthPx); + const filledTwips = Math.round((totalTwips * percent) / 100); + const emptyTwips = totalTwips - filledTwips; + const rowHeight = pxToTwips(heightPx); + const cells = []; + const makeCell = (size, shaded) => + new TableCell({ + children: [new Paragraph({ spacing: { before: 0, after: 0, line: 1 }, children: [] })], + width: { size, type: WidthType.DXA }, + shading: shaded ? { type: ShadingType.CLEAR, fill, color: 'auto' } : undefined, + borders: NO_BORDERS, + margins: { top: 0, bottom: 0, left: 0, right: 0 }, + }); + if (filledTwips > 0) cells.push(makeCell(filledTwips, true)); + if (emptyTwips > 0 || cells.length === 0) cells.push(makeCell(Math.max(emptyTwips, 1), false)); + + const columnWidths = cells.length === 2 ? [filledTwips, emptyTwips] : [totalTwips]; + return new Table({ + rows: [new TableRow({ children: cells, height: { value: rowHeight, rule: 'exact' } })], + width: { size: totalTwips, type: WidthType.DXA }, + columnWidths, + borders: solidBorders(borderColor, 6), + }); + } + + async renderTable(node, ctx, styles) { + const rowNodes = []; + const collectRows = (nodes) => { + for (const child of nodes || []) { + if (typeof child === 'string') continue; + if (child.tag === 'tr') rowNodes.push(child); + else if (['thead', 'tbody', 'tfoot'].includes(child.tag)) collectRows(child.content); + } + }; + collectRows(node.content); + + const widthPx = cssLengthToPx(styles.width, ctx.sizePx); + const widthPercent = cssPercent(styles.width); + const tableWidthPx = widthPx ?? (ctx.contentWidthPx * (widthPercent ?? 100)) / 100; + + const rows = []; + for (const rowNode of rowNodes) { + const rowStyles = parseStyle(rowNode.attrs?.style); + const rowCtx = inheritContext(ctx, rowStyles, rowNode); + const isHeader = hasClass(rowNode, 'documentTableRowHeader'); + const isFooter = hasClass(rowNode, 'documentTableRowFooter'); + const rowShade = + cssColorToHex(rowStyles.background) || (isHeader || isFooter ? 'D1D1D1' : undefined); + const cellNodes = (rowNode.content || []).filter( + (child) => typeof child !== 'string' && (child.tag === 'td' || child.tag === 'th') + ); + if (cellNodes.length === 0) continue; + + const cellWidths = cellNodes.map((cellNode) => { + const cellStyles = parseStyle(cellNode.attrs?.style); + const px = cssLengthToPx(cellStyles.width, ctx.sizePx); + if (px != null) return px; + const percent = cssPercent(cellStyles.width); + return percent != null ? (tableWidthPx * percent) / 100 : null; + }); + const fixed = cellWidths.reduce((sum, width) => sum + (width || 0), 0); + const flexible = cellWidths.filter((width) => width == null).length; + const share = flexible ? Math.max(10, (tableWidthPx - fixed) / flexible) : 0; + + const cells = []; + for (let index = 0; index < cellNodes.length; index += 1) { + const cellNode = cellNodes[index]; + const cellStyles = parseStyle(cellNode.attrs?.style); + const cellWidthPx = cellWidths[index] ?? share; + const cellCtx = inheritContext( + { + ...rowCtx, + bold: cellNode.tag === 'th' ? true : rowCtx.bold, + contentWidthPx: cellWidthPx - 8, + }, + cellStyles, + cellNode + ); + if (isHeader && !cellStyles['text-align'] && !cellCtx.alignment) { + cellCtx.alignment = AlignmentType.LEFT; + } + const children = await this.renderBlocks(cellNode.content, cellCtx, cellStyles); + const shade = cssColorToHex(cellStyles.background) || rowShade; + const padding = edgesToPx(cellStyles.padding) || { top: 4, right: 4, bottom: 4, left: 4 }; + const colspan = Number(cellNode.attrs?.colspan) || undefined; + const rowspan = Number(cellNode.attrs?.rowspan) || undefined; + cells.push( + new TableCell({ + children: children.length ? children : [new Paragraph({ children: [] })], + width: { size: pxToTwips(cellWidthPx), type: WidthType.DXA }, + shading: shade ? { type: ShadingType.CLEAR, fill: shade, color: 'auto' } : undefined, + margins: { + top: pxToTwips(padding.top), + bottom: pxToTwips(padding.bottom), + left: pxToTwips(padding.left), + right: pxToTwips(padding.right), + }, + columnSpan: colspan, + rowSpan: rowspan, + verticalAlign: VerticalAlign.CENTER, + }) + ); + } + rows.push(new TableRow({ children: cells, tableHeader: isHeader })); + } + + if (rows.length === 0) { + return new Paragraph({ children: [] }); + } + + const borderColorMatch = String(styles.border || '').match( + /(#[0-9a-f]{3,8}|rgba?\([^)]*\)|[a-z]+)\s*$/i + ); + return new Table({ + rows, + width: widthPx + ? { size: pxToTwips(widthPx), type: WidthType.DXA } + : { size: widthPercent ?? 100, type: WidthType.PERCENTAGE }, + borders: solidBorders(cssColorToHex(borderColorMatch?.[1]) || '000000', 6), + }); + } +} + +function spacer(px) { + return new Paragraph({ + spacing: { before: 0, after: 0, line: pxToTwips(px) || 1, lineRule: 'exact' }, + children: [], + }); +} + +function trimRuns(runs) { + // Remove runs that are purely whitespace at the paragraph edges. + const result = [...runs]; + while (result.length && isWhitespaceRun(result[0])) result.shift(); + while (result.length && isWhitespaceRun(result[result.length - 1])) result.pop(); + return result; +} + +function isWhitespaceRun(run) { + const text = run?.__fcText; + return typeof text === 'string' && text.trim() === ''; +} + +function findNode(tree, predicate) { + for (const node of tree || []) { + if (typeof node === 'string' || node == null) continue; + if (predicate(node)) return node; + const found = findNode(node.content, predicate); + if (found) return found; + } + return null; +} + +function findBody(tree) { + const content = findNode(tree, (node) => node.attrs?.id === 'content'); + if (content) return content.content || []; + const body = findNode(tree, (node) => node.tag === 'body'); + if (body) return body.content || []; + return tree || []; +} + +/** + * Pulls nodes flagged as header/footer (documentHeader / documentFooter classes) + * out of the content tree so they can be placed in the Word section header/footer. + */ +function extractHeaderFooter(nodes) { + const headers = []; + const footers = []; + const walk = (list) => { + const kept = []; + for (const node of list || []) { + if (typeof node === 'string' || node == null) { + kept.push(node); + continue; + } + if (hasClass(node, 'documentHeader')) { + headers.push(node); + continue; + } + if (hasClass(node, 'documentFooter')) { + footers.push(node); + continue; + } + if (Array.isArray(node.content) && node.tag !== 'table') { + kept.push({ ...node, content: walk(node.content) }); + } else { + kept.push(node); + } + } + return kept; + }; + const body = walk(nodes); + return { body, headers, footers }; +} + +async function parseHtml(html) { + let captured = []; + await posthtml([ + (tree) => { + captured = Array.isArray(tree) ? [...tree] : []; + return tree; + }, + ]).process(String(html ?? ''), { recognizeSelfClosing: true }); + return captured; +} + +/** + * Generates a Word (.docx) document from rendered template HTML. + * @param {string} html - Rendered template HTML (output of TemplateManager.renderTemplate) + * @param {Object} options + * @param {number} options.width - Page width in mm (including padding) + * @param {number|string} options.height - Page height in mm, or 'auto' + * @param {boolean} options.infiniteHeight - Whether the template has no fixed height + * @param {Object} options.padding - { paddingTop, paddingRight, paddingBottom, paddingLeft } in mm + * @param {Function} options.onProgress - async (progress, message) reporter + * @returns {Promise} + */ +export async function generateDocx(html, options = {}) { + const report = typeof options.onProgress === 'function' ? options.onProgress : async () => {}; + const widthMm = Number(options.width) || 210; + const infiniteHeight = options.infiniteHeight === true || options.height === 'auto'; + const heightMm = infiniteHeight + ? DEFAULT_PAGE_HEIGHT_MM + : Number(options.height) || DEFAULT_PAGE_HEIGHT_MM; + const padding = options.padding || {}; + const margins = { + top: Number(padding.paddingTop) || 0, + right: Number(padding.paddingRight) || 0, + bottom: Number(padding.paddingBottom) || 0, + left: Number(padding.paddingLeft) || 0, + }; + const contentWidthMm = Math.max(10, widthMm - margins.left - margins.right); + + await report(0.05, 'Parsing document...'); + const tree = await parseHtml(html); + const { body, headers, footers } = extractHeaderFooter(findBody(tree)); + + const ctx = { + font: DEFAULT_FONT, + sizePx: DEFAULT_TEXT_PX, + bold: false, + italic: false, + contentWidthPx: contentWidthMm * PX_PER_MM, + }; + const builder = new WordBuilder(ctx); + + await report(0.3, 'Building Word document...'); + const children = await builder.renderBlocks(body, ctx); + const headerChildren = headers.length ? await builder.renderBlocks(headers, ctx) : []; + const footerChildren = footers.length ? await builder.renderBlocks(footers, ctx) : []; + + const section = { + properties: { + page: { + size: { width: mmToTwips(widthMm), height: mmToTwips(heightMm) }, + margin: { + top: mmToTwips(margins.top), + right: mmToTwips(margins.right), + bottom: mmToTwips(margins.bottom), + left: mmToTwips(margins.left), + header: mmToTwips(margins.top), + footer: mmToTwips(margins.bottom), + gutter: 0, + }, + }, + }, + children: children.length ? children : [new Paragraph({ children: [] })], + }; + if (headerChildren.length) { + section.headers = { default: new Header({ children: headerChildren }) }; + } + if (footerChildren.length) { + section.footers = { default: new Footer({ children: footerChildren }) }; + } + + const document = new Document({ + creator: 'Farm Control', + styles: { + default: { + document: { + run: { font: DEFAULT_FONT, size: pxToHalfPoints(DEFAULT_TEXT_PX) }, + paragraph: { spacing: { before: 0, after: 0, line: 240 } }, + }, + }, + }, + sections: [section], + }); + + await report(0.85, 'Packing Word document...'); + const buffer = Buffer.from(await Packer.toBuffer(document)); + await report(1, 'Word document ready'); + return buffer; +}