Add support for DOCX file generation and enhance template manager functionality
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
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:
parent
1d8fcdd831
commit
2453789343
@ -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",
|
||||
|
||||
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
|
||||
232
src/templates/__tests__/wordfactory.test.js
Normal file
232
src/templates/__tests__/wordfactory.test.js
Normal 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 & Summary</h1>'),
|
||||
pageOptions
|
||||
);
|
||||
const { document } = await readDocx(buffer);
|
||||
expect(textOf(document)).toEqual(['Report & 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');
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
1110
src/templates/wordfactory.js
Normal file
1110
src/templates/wordfactory.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user