Add support for DOCX file generation and enhance template manager functionality
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

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.
This commit is contained in:
Tom Butcher 2026-11-08 20:15:45 +00:00
parent 1d8fcdd831
commit 2453789343
7 changed files with 1532 additions and 21 deletions

View File

@ -11,10 +11,12 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"bwip-js": "^4.11.4",
"canonical-json": "^0.2.0", "canonical-json": "^0.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"diff": "^9.0.0", "diff": "^9.0.0",
"docx": "^9.15.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
@ -63,6 +65,7 @@
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jszip": "^3.10.2",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"sequelize-cli": "^6.6.3", "sequelize-cli": "^6.6.3",
"standard": "^17.1.2", "standard": "^17.1.2",

77
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
body-parser: body-parser:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.2 version: 2.2.2
bwip-js:
specifier: ^4.11.4
version: 4.11.4
canonical-json: canonical-json:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
@ -41,6 +44,9 @@ importers:
diff: diff:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.0 version: 9.0.0
docx:
specifier: ^9.15.3
version: 9.15.3
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
@ -177,6 +183,9 @@ importers:
jest-junit: jest-junit:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.0.0 version: 16.0.0
jszip:
specifier: ^3.10.2
version: 3.10.2
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.8.1 version: 3.8.1
@ -1738,6 +1747,9 @@ packages:
'@types/node@25.2.0': '@types/node@25.2.0':
resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==}
'@types/node@26.6.6':
resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==}
'@types/stack-utils@2.0.3': '@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
@ -2125,11 +2137,6 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true 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: basic-ftp@5.3.1:
resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -2231,6 +2238,10 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
bwip-js@4.11.4:
resolution: {integrity: sha512-FOXnOQkinxog8FwxdnPB+8k2wRqwPnqXwxX37KcXOtMNW+9CinOkGv9MtIoRC4HX/UyogT0/9lIp6FDm2DcquQ==}
hasBin: true
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -2281,8 +2292,8 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
chromedriver@152.0.1: chromedriver@157.0.0:
resolution: {integrity: sha512-8zet2yPAFOSlYxUIcQ/1yOOC1xP4ooPZiKFRdlJhFawBKvlZIHDU6qv4LDOfPEw1yXbyu+tQTzbL0Vfb+vHdQQ==} resolution: {integrity: sha512-IwYuey+2f+yczWMxei84oF2MMKbrrDU+G4AQT95fXLG3rURvB7pemRgZWDLVZHDMV6g5eDPKE0TT9s6Lw45Kbg==}
engines: {node: '>=22'} engines: {node: '>=22'}
hasBin: true hasBin: true
@ -2567,6 +2578,10 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'} 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: dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
@ -2956,6 +2971,9 @@ packages:
fd-slicer@1.1.0: fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@ -3715,8 +3733,8 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
jszip@3.10.1: jszip@3.10.2:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
jwa@2.0.1: jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@ -4076,6 +4094,11 @@ packages:
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
hasBin: true 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: napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -5112,6 +5135,9 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 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: unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -7575,6 +7601,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
'@types/node@26.6.6':
dependencies:
undici-types: 8.9.0
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
'@types/validator@13.15.10': {} '@types/validator@13.15.10': {}
@ -8010,10 +8040,7 @@ snapshots:
baseline-browser-mapping@2.9.19: {} baseline-browser-mapping@2.9.19: {}
basic-ftp@5.1.0: {} basic-ftp@5.3.1: {}
basic-ftp@5.3.1:
optional: true
bcrypt@6.0.0: bcrypt@6.0.0:
dependencies: dependencies:
@ -8117,6 +8144,8 @@ snapshots:
dependencies: dependencies:
streamsearch: 1.1.0 streamsearch: 1.1.0
bwip-js@4.11.4: {}
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
@ -8169,7 +8198,7 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
chromedriver@152.0.1: chromedriver@157.0.0:
dependencies: dependencies:
'@testim/chrome-version': 1.1.4 '@testim/chrome-version': 1.1.4
adm-zip: 0.6.0 adm-zip: 0.6.0
@ -8425,6 +8454,12 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 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: dom-serializer@1.4.1:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@ -8897,7 +8932,7 @@ snapshots:
archiver: 5.3.2 archiver: 5.3.2
dayjs: 1.11.23 dayjs: 1.11.23
fast-csv: 4.3.6 fast-csv: 4.3.6
jszip: 3.10.1 jszip: 3.10.2
readable-stream: 3.6.2 readable-stream: 3.6.2
saxes: 5.0.1 saxes: 5.0.1
tmp: 0.2.5 tmp: 0.2.5
@ -9018,6 +9053,8 @@ snapshots:
dependencies: dependencies:
pend: 1.2.0 pend: 1.2.0
fflate@0.8.3: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
@ -9203,7 +9240,7 @@ snapshots:
get-uri@6.0.5: get-uri@6.0.5:
dependencies: dependencies:
basic-ftp: 5.1.0 basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2 data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@5.5.0) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
@ -10041,7 +10078,7 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
jszip@3.10.1: jszip@3.10.2:
dependencies: dependencies:
lie: 3.3.0 lie: 3.3.0
pako: 1.0.11 pako: 1.0.11
@ -10071,7 +10108,7 @@ snapshots:
dependencies: dependencies:
jwk-to-pem: 2.0.7 jwk-to-pem: 2.0.7
optionalDependencies: optionalDependencies:
chromedriver: 152.0.1 chromedriver: 157.0.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- kerberos - kerberos
@ -10348,6 +10385,8 @@ snapshots:
nanoid@5.1.6: {} nanoid@5.1.6: {}
nanoid@6.0.2: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@ -11592,6 +11631,8 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici-types@8.9.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0: unicode-match-property-ecmascript@2.0.0:

View File

@ -48,7 +48,7 @@ const emailTemplateAttachmentSchema = new Schema(
type: { type: String, enum: ['file', 'documentTemplate'], required: true }, type: { type: String, enum: ['file', 'documentTemplate'], required: true },
file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true }, file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true },
fileName: { type: String, 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 } { _id: true }
); );

View File

@ -62,6 +62,10 @@ jest.unstable_mockModule('../pdffactory.js', () => ({
generatePDF: jest.fn().mockResolvedValue(Buffer.from('pdf-data')), 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', () => ({ jest.unstable_mockModule('../../events/eventmanager.js', () => ({
eventManager: { eventManager: {
sendObjectEvent: jest.fn().mockResolvedValue({ success: true }), 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 { getFilter } = await import('../../utils.js');
const ejs = (await import('ejs')).default; const ejs = (await import('ejs')).default;
const { generatePDF } = await import('../pdffactory.js'); const { generatePDF } = await import('../pdffactory.js');
const { generateDocx } = await import('../wordfactory.js');
const { eventManager } = await import('../../events/eventmanager.js'); const { eventManager } = await import('../../events/eventmanager.js');
const { redisServer } = await import('../../database/redis.js'); const { redisServer } = await import('../../database/redis.js');
const { TEMPLATE_RESOURCE_TTL_SECONDS } = await import('../templateresources.js'); const { TEMPLATE_RESOURCE_TTL_SECONDS } = await import('../templateresources.js');
@ -917,6 +922,51 @@ describe('TemplateManager', () => {
expect(result.buffers).toHaveLength(1); 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 () => { it('stores the render in redis and publishes progress when a renderRequestId is provided', async () => {
getObject.mockResolvedValue({ getObject.mockResolvedValue({
documentSize: { width: 100, height: 100, infiniteHeight: false }, documentSize: { width: 100, height: 100, infiniteHeight: false },

View File

@ -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 `<!DOCTYPE html><html><body><div class="renderPages"><div class="renderDocument documentPage" id="content">${content}</div></div></body></html>`;
}
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[^>]*>([^<]*)<\/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('<p class="documentText">Hello</p>'), pageOptions);
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.subarray(0, 2).toString()).toBe('PK');
const { document } = await readDocx(buffer);
expect(document).toContain(`<w:pgSz w:w="${mmToTwips(100)}" w:h="${mmToTwips(60)}"`);
expect(document).toMatch(/<w:pgMar w:top="283" w:right="283" w:bottom="283" w:left="283"/);
});
it('uses a standard page height for infinite-height templates', async () => {
const buffer = await generateDocx(wrap('<p class="documentText">Hello</p>'), {
width: 80,
height: 'auto',
infiniteHeight: true,
});
const { document } = await readDocx(buffer);
expect(document).toContain(`<w:pgSz w:w="${mmToTwips(80)}" w:h="${mmToTwips(297)}"`);
});
it('renders titles as bold paragraphs with the stylesheet size', async () => {
const buffer = await generateDocx(
wrap('<h1 class="documentTitle">Report &amp; Summary</h1>'),
pageOptions
);
const { document } = await readDocx(buffer);
expect(textOf(document)).toEqual(['Report &amp; Summary']);
expect(document).toContain('<w:b/>');
// 34px -> 25.5pt -> 51 half-points
expect(document).toContain('<w:sz w:val="51"/>');
});
it('applies inline colour, size, and alignment to styled paragraphs', async () => {
const buffer = await generateDocx(
wrap(
'<p class="documentText" style="color: #ff0000;font-size: 16px;text-align: center;">Styled <strong class="documentBoldText">bold</strong></p>'
),
pageOptions
);
const { document } = await readDocx(buffer);
expect(textOf(document)).toEqual(['Styled ', 'bold']);
expect(document).toContain('<w:color w:val="FF0000"/>');
expect(document).toContain('<w:sz w:val="24"/>');
expect(document).toContain('<w:jc w:val="center"/>');
expect(document).toContain('<w:rFonts w:ascii="Calibri"');
});
it('renders a flex row as a borderless table', async () => {
const buffer = await generateDocx(
wrap(
'<div class="documentFlex" style="column-gap: 8px;"><p class="documentText">Left</p><p class="documentText" style="width: 40px;">Right</p></div>'
),
pageOptions
);
const { document } = await readDocx(buffer);
expect(document.match(/<w:tbl>/g)).toHaveLength(1);
expect(document.match(/<w:tc>/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(
'<table class="documentTable"><tr class="documentTableRowHeader"><th>Name</th><th>Qty</th></tr><tr class="documentTableRow"><td>Widget</td><td>3</td></tr></table>'
),
pageOptions
);
const { document } = await readDocx(buffer);
expect(document.match(/<w:tbl>/g)).toHaveLength(1);
expect(document.match(/<w:tr>/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('<w:tblHeader/>');
});
it('renders dividers as a bottom paragraph border', async () => {
const buffer = await generateDocx(wrap('<hr class="documentDivider" />'), pageOptions);
const { document } = await readDocx(buffer);
expect(document).toMatch(/<w:pBdr><w:bottom [^>]*w:val="single"/);
});
it('renders page breaks', async () => {
const buffer = await generateDocx(
wrap(
'<p class="documentText">One</p><hr class="documentPageBreak" /><p class="documentText">Two</p>'
),
pageOptions
);
const { document } = await readDocx(buffer);
expect(document).toContain('<w:br w:type="page"/>');
expect(textOf(document)).toEqual(['One', 'Two']);
});
it('renders progress bars as a two-cell table', async () => {
const buffer = await generateDocx(
wrap(
'<div class="documentProgressBar"><div class="documentProgressBarInner" style="width: 25%"></div></div>'
),
pageOptions
);
const { document } = await readDocx(buffer);
expect(document.match(/<w:tc>/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(
'<p class="documentText documentHeader">Top</p><p class="documentText">Body</p><p class="documentText documentFooter">Page !pageNumber of !totalPages</p>'
),
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(
'<div class="documentBarcodeContainer"><svg class="documentBarcode" jsbarcode-value="ABC-123" jsbarcode-format="code128"></svg></div>'
),
pageOptions
);
const { document, media } = await readDocx(buffer);
expect(media).toHaveLength(1);
expect(document).toContain('<w:drawing>');
});
it('embeds data-uri images', async () => {
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const buffer = await generateDocx(
wrap(`<img src="data:image/png;base64,${png}" style="width: 20px;" />`),
pageOptions
);
const { document, media } = await readDocx(buffer);
expect(media).toHaveLength(1);
expect(document).toContain('<w:drawing>');
});
it('reports progress', async () => {
const onProgress = jest.fn().mockResolvedValue(undefined);
await generateDocx(wrap('<p class="documentText">Hello</p>'), { ...pageOptions, onProgress });
expect(onProgress).toHaveBeenCalledWith(1, 'Word document ready');
});
});

View File

@ -17,6 +17,7 @@ import { getObject, listObjects } from '../database/database.js';
import { getModelByName } from '../services/misc/model.js'; import { getModelByName } from '../services/misc/model.js';
import { getFilter } from '../utils.js'; import { getFilter } from '../utils.js';
import { generatePDF } from './pdffactory.js'; import { generatePDF } from './pdffactory.js';
import { generateDocx } from './wordfactory.js';
import { convertPDFToImage } from './pdfUtils.js'; import { convertPDFToImage } from './pdfUtils.js';
import { import {
createCursorIntellisenseRender, createCursorIntellisenseRender,
@ -40,7 +41,8 @@ dayjs.extend(timezone);
const logger = log4js.getLogger('Template Manager'); const logger = log4js.getLogger('Template Manager');
logger.level = config.server.logLevel; 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_KEY_PREFIX = 'documenttemplaterenders:';
const DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS = 15; const DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS = 15;
@ -379,6 +381,9 @@ function normalizeDownloadType(type) {
if (normalized === 'jpg') { if (normalized === 'jpg') {
return 'jpeg'; return 'jpeg';
} }
if (normalized === 'word' || normalized === 'doc') {
return 'docx';
}
return normalized; 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') { async renderImages(id, content, data = {}, options = {}, format = 'jpeg') {
try { try {
logger.debug(`Rendering ${format} for template:`, id); logger.debug(`Rendering ${format} for template:`, id);
@ -1344,7 +1403,7 @@ export class TemplateManager {
if (!DOWNLOAD_TYPES.includes(normalizedType)) { if (!DOWNLOAD_TYPES.includes(normalizedType)) {
const result = { 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, code: 400,
}; };
if (renderRequestId) { if (renderRequestId) {
@ -1372,6 +1431,21 @@ export class TemplateManager {
extension: 'pdf', extension: 'pdf',
buffers: [result.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') { } else if (normalizedType === 'svg') {
result = await this.renderSVG(id, content, data, renderOptions); result = await this.renderSVG(id, content, data, renderOptions);
if (result.error) { if (result.error) {
@ -1538,6 +1612,7 @@ export class TemplateManager {
export const templateManager = new TemplateManager(); export const templateManager = new TemplateManager();
export { export {
DOWNLOAD_TYPES, DOWNLOAD_TYPES,
DOCX_MIME,
normalizeDownloadType, normalizeDownloadType,
DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX, DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX,
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS, DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS,

1110
src/templates/wordfactory.js Normal file

File diff suppressed because it is too large Load Diff