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 `
Hello
'), pageOptions); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.subarray(0, 2).toString()).toBe('PK'); + + const { document } = await readDocx(buffer); + expect(document).toContain(`Hello
'), { + width: 80, + height: 'auto', + infiniteHeight: true, + }); + const { document } = await readDocx(buffer); + expect(document).toContain(`Styled bold
' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(textOf(document)).toEqual(['Styled ', 'bold']); + expect(document).toContain('Left
Right
| Name | Qty |
|---|---|
| Widget | 3 |
One
Two
' + ), + pageOptions + ); + const { document } = await readDocx(buffer); + expect(document).toContain('Top
Body
' + ), + 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('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