diff --git a/package.json b/package.json index d3ac995..acd19c1 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "body-parser": "^2.2.0", "canonical-json": "^0.2.0", "cors": "^2.8.5", + "dayjs": "^1.11.23", "diff": "^9.0.0", "dotenv": "^17.2.3", + "ejs": "^3.1.10", "exceljs": "^4.4.0", "exifr": "^7.1.3", "express": "^5.1.0", @@ -33,7 +35,9 @@ "node-cron": "^4.2.1", "nodemailer": "*", "nodemon": "^3.1.11", + "pdf-to-img": "^6.2.0", "pg": "^8.16.3", + "posthtml": "^0.16.7", "puppeteer": "^24.37.5", "sequelize": "^6.37.7", "sharp": "^0.34.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 163a6ac..8e81125 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,18 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + dayjs: + specifier: ^1.11.23 + version: 1.11.23 diff: specifier: ^9.0.0 version: 9.0.0 dotenv: specifier: ^17.2.3 version: 17.2.3 + ejs: + specifier: ^3.1.10 + version: 3.1.10 exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -95,9 +101,15 @@ importers: nodemon: specifier: ^3.1.11 version: 3.1.11 + pdf-to-img: + specifier: ^6.2.0 + version: 6.2.0 pg: specifier: ^8.16.3 version: 8.18.0 + posthtml: + specifier: ^0.16.7 + version: 0.16.7 puppeteer: specifier: ^24.37.5 version: 24.37.5 @@ -1303,6 +1315,76 @@ packages: '@mongodb-js/saslprep@1.4.5': resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1781,9 +1863,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.5.18: - resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} - engines: {node: '>=12.0'} + adm-zip@0.6.0: + resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + engines: {node: '>=14.0'} agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} @@ -1916,8 +1998,8 @@ packages: axios@1.13.4: resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} @@ -2172,8 +2254,8 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chromedriver@151.0.2: - resolution: {integrity: sha512-p2NI0qrBSpoksa9S4lxqpML28I1U1oMtNPyvbjiMlBB+JOn9qRQC/cuiAWPf36lAdsYgrqhQczb12UH2he04MQ==} + chromedriver@152.0.0: + resolution: {integrity: sha512-JBMT88W2bMGSytKcIy6A3F/0DrGJeKTO1ZSThSNICeexSY+xBAEuAvjzlsGkIMaebqNF0KkDUI203CJ+GTA12g==} engines: {node: '>=22'} hasBin: true @@ -2346,8 +2428,8 @@ packages: resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} engines: {node: '>=4.0'} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} @@ -2454,6 +2536,19 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -2482,6 +2577,11 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.283: resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} @@ -2505,6 +2605,13 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@3.0.1: + resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2821,6 +2928,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2886,6 +2996,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -3055,6 +3169,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} @@ -3065,6 +3183,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@7.2.0: + resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3228,6 +3349,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-json@2.0.1: + resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3342,6 +3466,11 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jest-changed-files@30.2.0: resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3924,6 +4053,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -4102,6 +4234,15 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pdf-to-img@6.2.0: + resolution: {integrity: sha512-54CaUwpTXw0SkG6U3Twx7AJNM2ZuFNpDCJ7oHmmW113gT8RwLZSbzv4/GOr+H0PFpFisuHcUirZBKgXkJXA1zg==} + engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + hasBin: true + + pdfjs-dist@5.6.205: + resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} + engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4190,6 +4331,18 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthtml-parser@0.11.0: + resolution: {integrity: sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==} + engines: {node: '>=12'} + + posthtml-render@3.0.0: + resolution: {integrity: sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==} + engines: {node: '>=12'} + + posthtml@0.16.7: + resolution: {integrity: sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==} + engines: {node: '>=12.0.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6810,6 +6963,54 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7387,7 +7588,7 @@ snapshots: acorn@8.15.0: {} - adm-zip@0.5.18: + adm-zip@0.6.0: optional: true agent-base@6.0.2: @@ -7585,10 +7786,10 @@ snapshots: transitivePeerDependencies: - debug - axios@1.18.1: + axios@1.19.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -7874,11 +8075,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chromedriver@151.0.2: + chromedriver@152.0.0: dependencies: '@testim/chrome-version': 1.1.4 - adm-zip: 0.5.18 - axios: 1.18.1 + adm-zip: 0.6.0 + axios: 1.19.0 compare-versions: 6.1.1 proxy-agent: 8.0.2 proxy-from-env: 2.1.0 @@ -8048,7 +8249,7 @@ snapshots: date-format@4.0.14: {} - dayjs@1.11.19: {} + dayjs@1.11.23: {} debug@2.6.9: dependencies: @@ -8128,6 +8329,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dotenv@17.2.3: {} dottie@2.0.6: {} @@ -8157,6 +8376,10 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.283: {} elliptic@6.6.1: @@ -8181,6 +8404,10 @@ snapshots: dependencies: once: 1.4.0 + entities@2.2.0: {} + + entities@3.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -8565,7 +8792,7 @@ snapshots: exceljs@4.4.0: dependencies: archiver: 5.3.2 - dayjs: 1.11.19 + dayjs: 1.11.23 fast-csv: 4.3.6 jszip: 3.10.1 readable-stream: 3.6.2 @@ -8696,6 +8923,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -8766,6 +8997,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + optional: true + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -8947,6 +9187,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + hmac-drbg@1.0.1: dependencies: hash.js: 1.1.7 @@ -8959,6 +9204,13 @@ snapshots: html-escaper@2.0.2: {} + htmlparser2@7.2.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 3.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -9152,6 +9404,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-json@2.0.1: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -9276,6 +9530,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jest-changed-files@30.2.0: dependencies: execa: 5.1.1 @@ -9693,7 +9953,7 @@ snapshots: dependencies: jwk-to-pem: 2.0.7 optionalDependencies: - chromedriver: 151.0.2 + chromedriver: 152.0.0 transitivePeerDependencies: - debug - kerberos @@ -9987,6 +10247,9 @@ snapshots: node-int64@0.4.0: {} + node-readable-to-web-readable-stream@0.4.2: + optional: true + node-releases@2.0.27: {} nodemailer@7.0.13: {} @@ -10198,6 +10461,15 @@ snapshots: path-to-regexp@8.3.0: {} + pdf-to-img@6.2.0: + dependencies: + pdfjs-dist: 5.6.205 + + pdfjs-dist@5.6.205: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + node-readable-to-web-readable-stream: 0.4.2 + pend@1.2.0: {} pg-cloudflare@1.3.0: @@ -10270,6 +10542,19 @@ snapshots: dependencies: xtend: 4.0.2 + posthtml-parser@0.11.0: + dependencies: + htmlparser2: 7.2.0 + + posthtml-render@3.0.0: + dependencies: + is-json: 2.0.1 + + posthtml@0.16.7: + dependencies: + posthtml-parser: 0.11.0 + posthtml-render: 3.0.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: diff --git a/src/index.js b/src/index.js index fdb7a7b..572729a 100644 --- a/src/index.js +++ b/src/index.js @@ -72,6 +72,7 @@ import { populateUserMiddleware } from './services/misc/auth.js'; import { natsServer } from './database/nats.js'; import { initializeBuckets } from './database/ceph.js'; import { getEnvironment } from './config.js'; +import { startPDFBrowser } from './templates/pdffactory.js'; const PORT = config.server.port; const app = express(); @@ -125,6 +126,12 @@ async function initializeApp() { // Don't throw error - allow app to start without Ceph for development } + try { + await startPDFBrowser(); + } catch (err) { + logger.error('Failed to start persistent Puppeteer browser:', err); + } + // Start server app.listen(PORT, () => logger.info(`Server listening to port ${PORT}`)); logger.info(`Allowed origins: ${allowedOrigins.join(', ')}`); diff --git a/src/routes/management/documenttemplates.js b/src/routes/management/documenttemplates.js index 3eb9055..ca96b32 100644 --- a/src/routes/management/documenttemplates.js +++ b/src/routes/management/documenttemplates.js @@ -41,6 +41,8 @@ import { getDocumentTemplatePropertyValuesRouteHandler, searchDocumentTemplatesRouteHandler, getDocumentTemplateNeighborsRouteHandler, + previewDocumentTemplateRouteHandler, + downloadDocumentTemplateRouteHandler, } from '../../services/management/documenttemplates.js'; // list of document templates @@ -90,6 +92,14 @@ router.get('/neighbors', isAuthenticated, async (req, res) => { getDocumentTemplateNeighborsRouteHandler(req, res, property, filter, search, getSort(sort, listAllowedSorters), order, id); }); +router.post('/:id/preview', isAuthenticated, async (req, res) => { + previewDocumentTemplateRouteHandler(req, res); +}); + +router.post('/:id/download', isAuthenticated, async (req, res) => { + downloadDocumentTemplateRouteHandler(req, res); +}); + router.get('/:id', isAuthenticated, async (req, res) => { getDocumentTemplateRouteHandler(req, res); }); diff --git a/src/services/management/__tests__/documenttemplates.test.js b/src/services/management/__tests__/documenttemplates.test.js index 756e7a4..597a4d1 100644 --- a/src/services/management/__tests__/documenttemplates.test.js +++ b/src/services/management/__tests__/documenttemplates.test.js @@ -18,6 +18,13 @@ jest.unstable_mockModule('../../../database/schemas/management/documenttemplate. documentTemplateModel: { modelName: 'DocumentTemplate' }, })); +jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({ + templateManager: { + renderTemplate: jest.fn(), + renderDownload: jest.fn(), + }, +})); + jest.unstable_mockModule('log4js', () => ({ default: { getLogger: () => ({ @@ -35,6 +42,8 @@ const { getDocumentTemplateRouteHandler, newDocumentTemplateRouteHandler, editDocumentTemplateRouteHandler, + previewDocumentTemplateRouteHandler, + downloadDocumentTemplateRouteHandler, } = await import('../documenttemplates.js'); const { listObjects, getObject, editObject, newObject } = await import( @@ -43,6 +52,7 @@ const { listObjects, getObject, editObject, newObject } = await import( const { documentTemplateModel } = await import( '../../../database/schemas/management/documenttemplate.schema.js' ); +const { templateManager } = await import('../../../templates/templatemanager.js'); describe('Document Template Service Route Handlers', () => { let req, res; @@ -57,6 +67,7 @@ describe('Document Template Service Route Handlers', () => { res = { send: jest.fn(), status: jest.fn().mockReturnThis(), + set: jest.fn(), }; jest.clearAllMocks(); }); @@ -87,5 +98,91 @@ describe('Document Template Service Route Handlers', () => { expect(res.send).toHaveBeenCalledWith(mockTemplate); }); }); + + describe('previewDocumentTemplateRouteHandler', () => { + it('should preview a document template', async () => { + req.params.id = 'template-1'; + req.body = { content: 'Hello', testObject: { name: 'Test' }, scale: 1 }; + const mockPreview = { html: '', width: 100, height: 50 }; + templateManager.renderTemplate.mockResolvedValue(mockPreview); + + await previewDocumentTemplateRouteHandler(req, res); + + expect(templateManager.renderTemplate).toHaveBeenCalledWith( + 'template-1', + 'Hello', + { name: 'Test' }, + 1, + {}, + true + ); + expect(res.send).toHaveBeenCalledWith(mockPreview); + }); + + it('should return an error when preview fails', async () => { + req.params.id = 'template-1'; + templateManager.renderTemplate.mockResolvedValue({ + error: 'Document template not found.', + code: 404, + }); + + await previewDocumentTemplateRouteHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Document template not found.' }) + ); + }); + }); + + describe('downloadDocumentTemplateRouteHandler', () => { + it('should download a single PDF', async () => { + req.params.id = 'template-1'; + req.query = { type: 'pdf' }; + req.body = { content: 'Hello', object: { name: 'Test' } }; + const pdf = Buffer.from('pdf-data'); + templateManager.renderDownload.mockResolvedValue({ + type: 'pdf', + mime: 'application/pdf', + extension: 'pdf', + buffers: [pdf], + }); + + await downloadDocumentTemplateRouteHandler(req, res); + + expect(templateManager.renderDownload).toHaveBeenCalledWith( + 'template-1', + 'Hello', + { name: 'Test' }, + 'pdf', + {} + ); + expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/pdf'); + expect(res.send).toHaveBeenCalledWith(pdf); + }); + + it('should return base64 images when multiple pages are rendered', async () => { + req.params.id = 'template-1'; + req.query = { type: 'png' }; + req.body = { object: { name: 'Test' } }; + const pageOne = Buffer.from('page-1'); + const pageTwo = Buffer.from('page-2'); + templateManager.renderDownload.mockResolvedValue({ + type: 'png', + mime: 'image/png', + extension: 'png', + buffers: [pageOne, pageTwo], + }); + + await downloadDocumentTemplateRouteHandler(req, res); + + expect(res.send).toHaveBeenCalledWith({ + type: 'png', + mime: 'image/png', + extension: 'png', + images: [pageOne.toString('base64'), pageTwo.toString('base64')], + }); + }); + }); }); diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js index ea90108..81a8d4f 100644 --- a/src/services/management/documenttemplates.js +++ b/src/services/management/documenttemplates.js @@ -15,6 +15,7 @@ import { getPropertyValues, getObjectNeighbors, } from '../../database/database.js'; +import { templateManager } from '../../templates/templatemanager.js'; const logger = log4js.getLogger('Document Templates'); logger.level = config.server.logLevel; @@ -262,3 +263,73 @@ export const getDocumentTemplateNeighborsRouteHandler = async ( res.send(result); }; +export const previewDocumentTemplateRouteHandler = async (req, res) => { + const id = req.params.id; + const content = req.body?.content; + const testObject = req.body?.testObject || req.body?.object || {}; + const scale = req.body?.scale ?? 1; + + logger.debug(`Previewing document template with ID: ${id}`); + + const result = await templateManager.renderTemplate( + id, + content, + testObject, + scale, + {}, + true + ); + + if (result?.error) { + logger.warn('Error previewing document template:', result.error); + return res.status(result.code || 400).send(result); + } + + res.send(result); +}; + +export const downloadDocumentTemplateRouteHandler = async (req, res) => { + const id = req.params.id; + const type = req.query.type || req.body?.type || 'pdf'; + const content = req.body?.content; + const data = req.body?.object || req.body?.testObject || {}; + const width = req.body?.width || req.query.width; + const filename = req.body?.filename || req.query.filename || 'document'; + const options = {}; + if (width) { + options.width = Number(width); + } + + logger.debug(`Downloading document template ${id} as ${type}`); + + const result = await templateManager.renderDownload(id, content, data, type, options); + + if (result?.error) { + logger.warn('Error downloading document template:', result.error); + return res.status(result.code || 400).send(result); + } + + const buffers = result.buffers || []; + if (buffers.length === 0) { + return res.status(500).send({ error: 'Failed to render document template.' }); + } + + if (buffers.length === 1) { + const buffer = buffers[0]; + res.set('Content-Type', result.mime); + res.set( + 'Content-Disposition', + `attachment; filename="${filename}.${result.extension}"` + ); + res.set('Content-Length', String(buffer.length)); + return res.send(buffer); + } + + res.send({ + type: result.type, + mime: result.mime, + extension: result.extension, + images: buffers.map((buffer) => Buffer.from(buffer).toString('base64')), + }); +}; + diff --git a/src/templates/__tests__/pdffactory.test.js b/src/templates/__tests__/pdffactory.test.js new file mode 100644 index 0000000..c607383 --- /dev/null +++ b/src/templates/__tests__/pdffactory.test.js @@ -0,0 +1,108 @@ +import { jest } from '@jest/globals'; + +const page = { + setContent: jest.fn().mockResolvedValue(undefined), + evaluate: jest.fn().mockResolvedValue(120), + pdf: jest.fn().mockResolvedValue(Buffer.from('pdf-bytes')), + close: jest.fn().mockResolvedValue(undefined), +}; + +const browserInstance = { + connected: true, + newPage: jest.fn().mockResolvedValue(page), + close: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), +}; + +const puppeteer = { + launch: jest.fn().mockResolvedValue(browserInstance), +}; + +jest.unstable_mockModule('puppeteer', () => ({ + default: puppeteer, +})); + +jest.unstable_mockModule('../../config.js', () => ({ + default: { + server: { + logLevel: 'info', + }, + }, +})); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), + }, +})); + +const { generatePDF, startPDFBrowser, closePDFBrowser } = await import('../pdffactory.js'); + +describe('PDF Factory', () => { + beforeEach(() => { + jest.clearAllMocks(); + browserInstance.connected = true; + browserInstance.newPage.mockResolvedValue(page); + puppeteer.launch.mockResolvedValue(browserInstance); + }); + + afterEach(async () => { + await closePDFBrowser(); + }); + + it('launches puppeteer once and reuses it across renders', async () => { + await generatePDF('', { width: 100, height: 50 }); + await generatePDF('', { width: 100, height: 50 }); + + expect(puppeteer.launch).toHaveBeenCalledTimes(1); + expect(browserInstance.newPage).toHaveBeenCalledTimes(2); + expect(page.close).toHaveBeenCalledTimes(2); + expect(browserInstance.close).toHaveBeenCalledTimes(0); + }); + + it('shares a single in-flight launch across concurrent renders', async () => { + let resolveLaunch; + puppeteer.launch.mockImplementation( + () => + new Promise((resolve) => { + resolveLaunch = () => resolve(browserInstance); + }) + ); + + const first = generatePDF('', { width: 100, height: 50 }); + const second = generatePDF('', { width: 100, height: 50 }); + + await Promise.resolve(); + resolveLaunch(); + + await Promise.all([first, second]); + + expect(puppeteer.launch).toHaveBeenCalledTimes(1); + }); + + it('measures auto height from the content element', async () => { + await generatePDF('', { width: 80, height: 'auto' }); + + expect(page.evaluate).toHaveBeenCalled(); + expect(page.pdf).toHaveBeenCalledWith( + expect.objectContaining({ + width: '80mm', + height: '120px', + }) + ); + }); + + it('can start the browser before the first render', async () => { + await startPDFBrowser(); + expect(puppeteer.launch).toHaveBeenCalledTimes(1); + + await generatePDF('', { width: 100, height: 50 }); + expect(puppeteer.launch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js new file mode 100644 index 0000000..db28041 --- /dev/null +++ b/src/templates/__tests__/templatemanager.test.js @@ -0,0 +1,193 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('fs', () => ({ + default: { + readFileSync: jest.fn((filePath) => { + if (filePath.endsWith('basetemplate.ejs')) return '<%- content %>'; + if (filePath.endsWith('styles.css')) return 'body { color: red; }'; + if (filePath.endsWith('previewtemplate.ejs')) + return '
<%- content %>
'; + if (filePath.endsWith('rendertemplate.ejs')) + return '
<%- content %>
'; + if (filePath.endsWith('contentplaceholder.ejs')) + return '
'; + if (filePath.endsWith('previewpagination.js')) return 'console.log("pagination");'; + return ''; + }), + }, +})); + +jest.unstable_mockModule('ejs', () => ({ + default: { + render: jest.fn(async (content) => `rendered: ${content}`), + compile: jest.fn(() => jest.fn()), + }, +})); + +jest.unstable_mockModule('posthtml', () => ({ + default: jest.fn(() => ({ + process: jest.fn(async (content) => ({ html: `transformed: ${content}` })), + })), +})); + +jest.unstable_mockModule('../../database/database.js', () => ({ + getObject: jest.fn(), + listObjects: jest.fn(), +})); + +jest.unstable_mockModule('../../database/schemas/management/documenttemplate.schema.js', () => ({ + documentTemplateModel: { modelName: 'DocumentTemplate' }, +})); + +jest.unstable_mockModule('../../database/schemas/management/documentsize.schema.js', () => ({})); + +jest.unstable_mockModule('../../services/misc/model.js', () => ({ + getModelByName: jest.fn(() => ({ + model: { + schema: { + obj: { name: {}, status: {} }, + }, + }, + })), +})); + +jest.unstable_mockModule('../pdffactory.js', () => ({ + generatePDF: jest.fn().mockResolvedValue(Buffer.from('pdf-data')), +})); + +jest.unstable_mockModule('../pdfUtils.js', () => ({ + convertPDFToImage: jest.fn().mockResolvedValue([Buffer.from('image-data')]), +})); + +jest.unstable_mockModule('sharp', () => { + const sharpInstance = { + png: jest.fn().mockReturnThis(), + jpeg: jest.fn().mockReturnThis(), + toBuffer: jest.fn().mockResolvedValue(Buffer.from('converted-image')), + metadata: jest.fn().mockResolvedValue({ width: 100, height: 50 }), + }; + const sharp = jest.fn(() => sharpInstance); + return { default: sharp }; +}); + +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + info: jest.fn(), + }), + }, +})); + +jest.unstable_mockModule('../../config.js', () => ({ + default: { + server: { + logLevel: 'info', + }, + }, +})); + +const { TemplateManager } = await import('../templatemanager.js'); +const { getObject } = await import('../../database/database.js'); +const ejs = (await import('ejs')).default; +const { generatePDF } = await import('../pdffactory.js'); + +describe('TemplateManager', () => { + let templateManager; + + beforeEach(() => { + jest.clearAllMocks(); + templateManager = new TemplateManager(); + }); + + describe('renderTemplate', () => { + it('should render a template successfully', async () => { + const mockTemplate = { + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: false, + objectType: 'printer', + }; + + getObject.mockResolvedValue(mockTemplate); + + const result = await templateManager.renderTemplate('temp-id', 'some content', { + name: 'Test', + }); + + expect(getObject).toHaveBeenCalledWith(expect.objectContaining({ id: 'temp-id' })); + expect(ejs.render).toHaveBeenCalled(); + expect(result).toHaveProperty('html'); + expect(result.width).toBe(100); + expect(result.height).toBe(100); + }); + + it('should return error if template not found', async () => { + getObject.mockResolvedValue(null); + + const result = await templateManager.renderTemplate('invalid', 'content'); + + expect(result).toEqual({ error: 'Document template not found.', code: 404 }); + }); + }); + + describe('validateTemplate', () => { + it('should return true for valid EJS', () => { + expect(templateManager.validateTemplate('
<%= name %>
')).toBe(true); + }); + + it('should return false for invalid EJS', () => { + ejs.compile.mockImplementationOnce(() => { + throw new Error('syntax error'); + }); + expect(templateManager.validateTemplate('<% invalid %>')).toBe(false); + }); + }); + + describe('renderPDF', () => { + it('should render a PDF successfully', async () => { + const mockTemplate = { + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: false, + objectType: 'printer', + }; + getObject.mockResolvedValue(mockTemplate); + + const result = await templateManager.renderPDF('temp-id', 'content'); + + expect(generatePDF).toHaveBeenCalled(); + expect(result.pdf).toBeDefined(); + }); + }); + + describe('renderDownload', () => { + it('should reject unsupported types', async () => { + const result = await templateManager.renderDownload('temp-id', 'content', {}, 'gif'); + expect(result.error).toMatch(/Unsupported download type/); + expect(result.code).toBe(400); + }); + + it('should render a PDF download', async () => { + getObject.mockResolvedValue({ + documentSize: { width: 100, height: 100, infiniteHeight: false }, + global: false, + objectType: 'printer', + }); + + const result = await templateManager.renderDownload('temp-id', 'content', {}, 'pdf'); + expect(result.type).toBe('pdf'); + expect(result.buffers).toHaveLength(1); + }); + }); + + describe('formatDate', () => { + it('should format date correctly', () => { + const date = new Date('2023-01-01T12:00:00Z'); + const formatted = templateManager.formatDate(date, 'YYYY-MM-DD'); + expect(formatted).toBe('2023-01-01'); + }); + }); +}); diff --git a/src/templates/assets/basetemplate.ejs b/src/templates/assets/basetemplate.ejs new file mode 100644 index 0000000..b236782 --- /dev/null +++ b/src/templates/assets/basetemplate.ejs @@ -0,0 +1,49 @@ + + + + + + Document + + + + + + + + <%- content %> + + + <% if (typeof previewPaginationScript !== 'undefined' && previewPaginationScript) { %> + + <% } %> + + diff --git a/src/templates/assets/contentplaceholder.ejs b/src/templates/assets/contentplaceholder.ejs new file mode 100644 index 0000000..6f65d2e --- /dev/null +++ b/src/templates/assets/contentplaceholder.ejs @@ -0,0 +1,3 @@ +
+

Content

+
diff --git a/src/templates/assets/previewpagination.js b/src/templates/assets/previewpagination.js new file mode 100644 index 0000000..e69de29 diff --git a/src/templates/assets/previewtemplate.ejs b/src/templates/assets/previewtemplate.ejs new file mode 100644 index 0000000..25e4d7b --- /dev/null +++ b/src/templates/assets/previewtemplate.ejs @@ -0,0 +1,5 @@ +
+
+
<%- content %>
+
+
diff --git a/src/templates/assets/rendertemplate.ejs b/src/templates/assets/rendertemplate.ejs new file mode 100644 index 0000000..9415dae --- /dev/null +++ b/src/templates/assets/rendertemplate.ejs @@ -0,0 +1 @@ +
<%- content %>
diff --git a/src/templates/assets/styles.css b/src/templates/assets/styles.css new file mode 100644 index 0000000..a25c38d --- /dev/null +++ b/src/templates/assets/styles.css @@ -0,0 +1,131 @@ +body { + margin: 0; + font-family: 'Figtree', sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + overflow: scroll; +} + +.previewContainer { + display: flex; + justify-content: center; /* Horizontal center */ + align-items: center; /* Vertical center */ + padding: 60px; + box-sizing: border-box; + width: fit-content; +} +.previewWrapper { + position: relative; +} +.previewDocument { + background: #ffffff; + border: 1px solid #000; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + transform-origin: top left; + position: relative; + overflow: hidden; +} + +.documentText { + margin: 0; + font-size: 12px; + line-height: 1; +} +.documentTitle { + margin: 0; +} + +h1.documentTitle { + font-weight: 800; + font-size: 34px; +} + +h2.documentTitle { + font-weight: 800; +} + +h3.documentTitle { + font-weight: 700; +} +h4.documentTitle { + font-weight: 700; +} +.documentFlex { + display: flex; +} +.documentDivider { + background: black; + height: 1px; + margin: 4px 0; + border: none; +} + +.contentPlaceholder { + border: 1px solid black; + max-height: 250px; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: repeating-linear-gradient( + 45deg, + /* Angle of the stripes */ #ccc, + /* Light grey */ #ccc 10px, + /* End of first stripe */ #eee 10px, + /* Start of next stripe (slightly lighter grey) */ #eee 20px + /* End of second stripe */ + ); +} + +.contentPlaceholder > p { + text-transform: uppercase; + font-weight: 700; +} + +.documentBarcode { + width: auto !important; + height: 100% !important; +} + +.documentProgressBar { + height: 8px; + width: 260px; + border-radius: 8px; + border: 1px solid #000000; + overflow: hidden; +} + +.documentProgressBarInner { + height: 100%; + background: #000; +} + +.documentTable { + width: 100%; + border-collapse: collapse; + border: 1px solid #000000; +} + +.documentTableRow { + border: 1px solid #000000; +} + +.documentTableRow td, +.documentTableRowHeader th, +.documentTableRowFooter td { + padding: 4px; + border: 1px solid #000000; +} + +.documentTableRowHeader { + background: #0000002e; + text-align: left; + border: 1px solid #000000; +} + +.documentTableRowFooter { + background: #0000002e; + border: 1px solid #000000; +} diff --git a/src/templates/pdfUtils.js b/src/templates/pdfUtils.js new file mode 100644 index 0000000..b0318a4 --- /dev/null +++ b/src/templates/pdfUtils.js @@ -0,0 +1,78 @@ +/** + * PDF conversion utilities loaded on demand so startup + * does not require ESM-only pdf-to-img at module load time. + */ +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +async function importPackage(name) { + const packagePath = require.resolve(name); + return import(pathToFileURL(packagePath).href); +} + +function resolveDefault(module) { + return module?.default ?? module; +} + +export async function convertPDFToImage(pdfInput, options = {}) { + const [pdfToImgModule, sharpModule] = await Promise.all([ + importPackage('pdf-to-img'), + importPackage('sharp') + ]); + const { pdf } = pdfToImgModule; + const sharp = resolveDefault(sharpModule); + + try { + const { width, height, page_numbers, scale = 2, ...pdfOnlyOptions } = + options; + + const pdfOptions = { + scale, + ...pdfOnlyOptions + }; + + const document = await pdf(pdfInput, pdfOptions); + const outputImages = []; + + if ( + page_numbers && + Array.isArray(page_numbers) && + page_numbers.length > 0 + ) { + for (const pageNum of page_numbers) { + let image = await document.getPage(pageNum); + + if (width || height) { + const resizeOptions = {}; + if (width) resizeOptions.width = width; + if (height) resizeOptions.height = height; + image = await sharp(image).resize(resizeOptions).toBuffer(); + } + + outputImages.push(image); + } + } else { + for await (const image of document) { + let processedImage = image; + + if (width || height) { + const resizeOptions = {}; + if (width) resizeOptions.width = width; + if (height) resizeOptions.height = height; + processedImage = await sharp(image) + .resize(resizeOptions) + .toBuffer(); + } + + outputImages.push(processedImage); + } + } + + return outputImages; + } catch (error) { + console.error('Error converting PDF to image:', error); + throw error; + } +} diff --git a/src/templates/pdffactory.js b/src/templates/pdffactory.js new file mode 100644 index 0000000..32478a3 --- /dev/null +++ b/src/templates/pdffactory.js @@ -0,0 +1,149 @@ +import log4js from 'log4js'; +import puppeteer from 'puppeteer'; +import config from '../config.js'; + +const logger = log4js.getLogger('PDF Factory'); +logger.level = config.server.logLevel; + +const LAUNCH_OPTIONS = { + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], +}; + +let browser = null; +let launchPromise = null; +let shutdownRegistered = false; + +function isBrowserConnected() { + return browser != null && browser.connected; +} + +async function launchBrowser() { + logger.info('Launching persistent Puppeteer browser...'); + const instance = await puppeteer.launch(LAUNCH_OPTIONS); + instance.on('disconnected', () => { + if (browser === instance) { + logger.warn('Puppeteer browser disconnected'); + browser = null; + launchPromise = null; + } + }); + logger.info('Persistent Puppeteer browser ready'); + return instance; +} + +function registerShutdownHandlers() { + if (shutdownRegistered) { + return; + } + shutdownRegistered = true; + + const shutdown = async () => { + await closePDFBrowser(); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + process.once('beforeExit', shutdown); +} + +export async function startPDFBrowser() { + if (process.env.NODE_ENV !== 'test') { + registerShutdownHandlers(); + } + + if (isBrowserConnected()) { + return browser; + } + + if (launchPromise == null) { + launchPromise = launchBrowser() + .then((instance) => { + browser = instance; + return instance; + }) + .catch((error) => { + launchPromise = null; + throw error; + }); + } + + return launchPromise; +} + +export async function closePDFBrowser() { + const instance = browser; + browser = null; + launchPromise = null; + if (instance != null) { + try { + await instance.close(); + } catch (error) { + logger.warn('Error closing Puppeteer browser:', error.message); + } + } +} + +async function withPage(run) { + let page; + try { + const instance = await startPDFBrowser(); + page = await instance.newPage(); + return await run(page); + } catch (error) { + if (page == null && isBrowserConnected() === false) { + const instance = await startPDFBrowser(); + page = await instance.newPage(); + return await run(page); + } + throw error; + } finally { + if (page != null) { + await page.close().catch(() => {}); + } + } +} + +/** + * Generates a PDF from HTML content using a persistent Puppeteer browser + * @param {string} html - The HTML content to convert to PDF + * @param {Object} options - PDF generation options + * @param {number} options.width - Document width in mm + * @param {number|string} options.height - Document height in mm, or 'auto' + * @returns {Promise} The PDF buffer + */ +export async function generatePDF(html, options = {}) { + try { + return await withPage(async (page) => { + await page.setContent(html, { + waitUntil: 'domcontentloaded', + }); + + var height = options?.height || '50'; + + if (options.height == 'auto') { + const calculatedHeight = await page.evaluate(() => { + return document.getElementById('content').scrollHeight; + }); + + height = calculatedHeight; + } + + return page.pdf({ + printBackground: true, + preferCSSPageSize: true, + width: options.width ? `${options.width}mm` : undefined, + height: height ? (options.height == 'auto' ? `${height + 1}px` : `${height}mm`) : undefined, + margin: { + top: '0mm', + right: '0mm', + bottom: '0mm', + left: '0mm', + }, + }); + }); + } catch (error) { + logger.error('Error generating PDF:', error.message); + throw error; + } +} diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js new file mode 100644 index 0000000..198e8bd --- /dev/null +++ b/src/templates/templatemanager.js @@ -0,0 +1,683 @@ +import ejs from 'ejs'; +import log4js from 'log4js'; +import posthtml from 'posthtml'; +import fs from 'fs'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import timezone from 'dayjs/plugin/timezone.js'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import sharp from 'sharp'; + +import { documentTemplateModel } from '../database/schemas/management/documenttemplate.schema.js'; +import '../database/schemas/management/documentsize.schema.js'; +import config from '../config.js'; +import { getObject, listObjects } from '../database/database.js'; +import { getModelByName } from '../services/misc/model.js'; +import { generatePDF } from './pdffactory.js'; +import { convertPDFToImage } from './pdfUtils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +dayjs.extend(utc); +dayjs.extend(timezone); + +const logger = log4js.getLogger('Template Manager'); +logger.level = config.server.logLevel; + +const DOWNLOAD_TYPES = ['pdf', 'png', 'svg', 'jpeg']; + +let baseTemplate; +let baseCSS; +let previewTemplate; +let renderTemplateEjs; +let contentPlaceholder; +let previewPaginationScript; + +async function loadTemplates() { + baseTemplate = fs.readFileSync(join(__dirname, '/assets/basetemplate.ejs'), 'utf8'); + baseCSS = fs.readFileSync(join(__dirname, '/assets/styles.css'), 'utf8'); + previewTemplate = fs.readFileSync(join(__dirname, '/assets/previewtemplate.ejs'), 'utf8'); + renderTemplateEjs = fs.readFileSync(join(__dirname, '/assets/rendertemplate.ejs'), 'utf8'); + contentPlaceholder = fs.readFileSync( + join(__dirname, '/assets/contentplaceholder.ejs'), + 'utf8' + ); + previewPaginationScript = fs.readFileSync( + join(__dirname, '/assets/previewpagination.js'), + 'utf8' + ); +} + +loadTemplates(); + +function getNodeStyles(attributes) { + var styles = ''; + if (attributes?.padding) { + styles += `padding: ${attributes.padding};`; + } + if (attributes?.width) { + styles += `width: ${attributes.width};`; + } + if (attributes?.height) { + styles += `height: ${attributes.height};`; + } + if (attributes?.maxWidth) { + styles += `max-width: ${attributes.maxWidth};`; + } + if (attributes?.maxHeight) { + styles += `max-height: ${attributes.maxHeight};`; + } + if (attributes?.gap && attributes?.vertical != 'true') { + styles += `column-gap: ${attributes.gap};`; + } + if (attributes?.gap && attributes?.vertical == 'true') { + styles += `row-gap: ${attributes.gap};`; + } + if (attributes?.justify) { + styles += `justify-content: ${attributes.justify};`; + } + if (attributes?.align) { + styles += `align-items: ${attributes.align};`; + } + if (attributes?.border) { + styles += `border: ${attributes.border};`; + } + if (attributes?.borderRadius) { + styles += `border-radius: ${attributes.borderRadius};`; + } + if (attributes?.vertical == 'true') { + styles += `flex-direction: column;`; + } + if (attributes?.grow) { + styles += `flex-grow: ${attributes.grow};`; + } + if (attributes?.shrink) { + styles += `flex-shrink: ${attributes.shrink};`; + } + if (attributes?.color) { + styles += `color: ${attributes.color};`; + } + if (attributes?.background) { + styles += `background: ${attributes.background};`; + } + if (attributes?.scale) { + styles += `transform: scale(${attributes.scale});`; + } + if (attributes?.textAlign) { + styles += `text-align: ${attributes.textAlign};`; + } + if (attributes?.textSize) { + styles += `font-size: ${attributes.textSize};`; + } + if (attributes?.wordWrap) { + styles += `word-wrap: ${attributes.wordWrap};`; + } + return styles; +} + +async function transformCustomElements(content) { + const result = await posthtml([ + (tree) => + tree.match({ tag: 'Title1' }, (node) => ({ + ...node, + tag: 'h1', + attrs: { class: 'documentTitle' }, + })), + (tree) => + tree.match({ tag: 'Title2' }, (node) => ({ + ...node, + tag: 'h2', + attrs: { class: 'documentTitle' }, + })), + (tree) => + tree.match({ tag: 'Title3' }, (node) => ({ + ...node, + tag: 'h3', + attrs: { class: 'documentText' }, + })), + (tree) => + tree.match({ tag: 'Title4' }, (node) => ({ + ...node, + tag: 'h4', + attrs: { class: 'documentText' }, + })), + (tree) => + tree.match({ tag: 'Text' }, (node) => ({ + ...node, + tag: 'p', + attrs: { class: 'documentText', style: getNodeStyles(node.attrs) }, + })), + (tree) => + tree.match({ tag: 'Bold' }, (node) => ({ + ...node, + tag: 'strong', + attrs: { + class: 'documentBoldText', + style: getNodeStyles(node.attrs), + }, + })), + (tree) => + tree.match({ tag: 'Barcode' }, (node) => { + return { + tag: 'div', + content: [ + { + tag: 'svg', + attrs: { + class: 'documentBarcode', + 'jsbarcode-displayValue': 'false', + 'jsbarcode-value': node.content.at(0) || '', + 'jsbarcode-format': node.attrs.format || 'code128', + 'jsbarcode-width': node.attrs.barcodeWidth || 2, + 'jsbarcode-margin': 0, + }, + }, + ], + attrs: { + class: 'documentBarcodeContainer', + style: getNodeStyles(node.attrs), + }, + }; + }), + (tree) => + tree.match({ tag: 'Container' }, (node) => ({ + ...node, + tag: 'div', + attrs: { + class: 'documentContainer', + style: getNodeStyles(node.attrs), + }, + })), + (tree) => + tree.match({ tag: 'Flex' }, (node) => { + return { + ...node, + tag: 'div', + attrs: { + class: 'documentFlex', + style: getNodeStyles(node.attrs), + }, + }; + }), + (tree) => + tree.match({ tag: 'Divider' }, (node) => { + return { + ...node, + tag: 'hr', + attrs: { + class: 'documentDivider', + style: getNodeStyles(node.attrs), + }, + }; + }), + (tree) => + tree.match({ tag: 'ProgressBar' }, (node) => { + return { + ...node, + tag: 'div', + attrs: { + class: 'documentProgressBar', + style: getNodeStyles(node.attrs), + }, + content: [ + { + tag: 'div', + attrs: { + class: 'documentProgressBarInner', + style: `width: ${Math.round((node.content[0] || 0) * 100)}%`, + }, + }, + ], + }; + }), + (tree) => + tree.match({ tag: 'DateTime' }, (node) => { + const dateTime = dayjs.utc(node.content[0]); + return { + content: [dateTime.format('YYYY-MM-DD hh:mm:ss')], + tag: 'span', + attrs: { + class: 'documentDateTime', + style: getNodeStyles(node.attrs), + }, + }; + }), + (tree) => + tree.match({ tag: 'Table' }, (node) => { + return { + ...node, + tag: 'table', + attrs: { + class: 'documentTable', + style: getNodeStyles(node.attrs), + }, + }; + }), + (tree) => + tree.match({ tag: 'Row' }, (node) => { + const rowType = node.attrs?.type?.toLowerCase() || ''; + + const transformCols = (content) => { + if (!Array.isArray(content)) return content; + return content.map((child) => { + if (typeof child === 'string' || child == null) { + return child; + } + if (child.tag !== 'Col') { + return child; + } + + const baseAttrs = { + ...child.attrs, + style: getNodeStyles(child.attrs), + }; + + if (rowType === 'header') { + return { + ...child, + tag: 'th', + attrs: baseAttrs, + }; + } + + return { + ...child, + tag: 'td', + attrs: baseAttrs, + }; + }); + }; + + const content = transformCols(node.content); + + if (rowType === 'header') { + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRowHeader', + style: getNodeStyles(node.attrs), + }, + }; + } + + if (rowType === 'footer') { + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRowFooter', + style: getNodeStyles(node.attrs), + }, + }; + } + + return { + ...node, + tag: 'tr', + content, + attrs: { + class: 'documentTableRow', + style: getNodeStyles(node.attrs), + }, + }; + }), + ]).process(content); + + return result.html; +} + +function normalizeDownloadType(type) { + const normalized = String(type || 'pdf').toLowerCase(); + if (normalized === 'jpg') { + return 'jpeg'; + } + return normalized; +} + +function imageToSvg(pngBuffer, widthPx, heightPx, documentWidthMm, documentHeightMm) { + const b64 = pngBuffer.toString('base64'); + const heightAttr = + documentHeightMm == 'auto' || documentHeightMm == null + ? '' + : ` height="${documentHeightMm}mm"`; + return ` + + +`; +} + +export class TemplateManager { + constructor() { + this.fc = { + listObjects: this.listObjects.bind(this), + getObject: this.getObject.bind(this), + formatDate: this.formatDate.bind(this), + }; + } + + async renderTemplate(id, content, data = {}, scale = 1, options = {}, preview = true) { + try { + const defaultOptions = { + async: true, + ...options, + }; + logger.debug('Rendering template:', id); + + const documentTemplate = await getObject({ + model: documentTemplateModel, + id, + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + ], + }); + + if (documentTemplate == null || documentTemplate.error) { + return { error: documentTemplate?.error || 'Document template not found.', code: 404 }; + } + + const documentSize = documentTemplate.documentSize; + if (documentSize == null) { + return { error: 'Document template size not found.', code: 400 }; + } + + const templateContentSource = + content != null && typeof content === 'string' ? content : documentTemplate.content; + + if (templateContentSource == null || typeof templateContentSource !== 'string') { + return { error: 'Template content is required and must be a string.', code: 400 }; + } + + var templateData = {}; + if (documentTemplate.global == true) { + templateData = { content: contentPlaceholder, fc: this.fc }; + } else { + const objectType = documentTemplate?.objectType; + const modelEntry = getModelByName(objectType); + const model = modelEntry?.model || modelEntry; + if (model == null || model.schema == null) { + return { error: `Unknown object type: ${objectType}`, code: 400 }; + } + const defaultKeys = Object.keys(model.schema.obj); + const defaultValues = {}; + for (const key of defaultKeys) { + defaultValues[key] = null; + } + templateData = { ...defaultValues, ...data, fc: this.fc }; + } + + const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions); + + var templateWithParentContent; + var parentTemplate = documentTemplate.parent; + + if (documentTemplate.parent != undefined) { + if (typeof parentTemplate === 'string') { + parentTemplate = await getObject({ + model: documentTemplateModel, + id: parentTemplate, + populate: [ + { path: 'documentSize' }, + { path: 'parent', strictPopulate: false }, + ], + }); + } + if (parentTemplate?.error || parentTemplate.content == null || typeof parentTemplate.content !== 'string') { + logger.error( + 'Parent template content is required and must be a string.', + parentTemplate?.content + ); + return { + error: + 'Parent template content is required and must be a string.' + + (parentTemplate?.content || ''), + code: 400, + }; + } + templateWithParentContent = await ejs.render( + parentTemplate.content, + { content: templateContent, fc: this.fc }, + defaultOptions + ); + } else { + templateWithParentContent = templateContent; + } + + if (templateWithParentContent == null || typeof templateWithParentContent !== 'string') { + return { error: 'Failed to render template content.', code: 500 }; + } + + const templateHtml = await transformCustomElements(templateWithParentContent); + + if (templateHtml == null || typeof templateHtml !== 'string') { + return { error: 'Failed to transform template content.', code: 500 }; + } + + var innerHtml = null; + + if (preview == true) { + innerHtml = await ejs.render(previewTemplate, { content: templateHtml }, defaultOptions); + } else { + innerHtml = await ejs.render(renderTemplateEjs, { content: templateHtml }, defaultOptions); + } + + if (innerHtml == null || typeof innerHtml !== 'string') { + return { error: 'Failed to render inner template content.', code: 500 }; + } + + const infiniteHeight = documentSize.infiniteHeight == true; + + const baseHtml = await ejs.render( + baseTemplate, + { + content: innerHtml, + width: `${documentSize.width}mm`, + height: infiniteHeight ? 'fit-content' : `${documentSize.height}mm`, + scaledWidth: `${documentSize.width * scale}mm`, + scaledHeight: infiniteHeight ? 'auto' : `${documentSize.height * scale}mm`, + scale: `${scale}`, + baseCSS: baseCSS, + previewPaginationScript: preview ? previewPaginationScript : '', + }, + defaultOptions + ); + + return { + html: baseHtml, + width: documentSize.width, + height: infiniteHeight ? 'auto' : documentSize.height, + infiniteHeight: infiniteHeight, + }; + } catch (error) { + console.error(error); + logger.warn('Error whilst previewing template:', error.message); + return { error: error.message, code: 500 }; + } + } + + validateTemplate(templateString) { + try { + ejs.compile(templateString); + return true; + } catch (error) { + return false; + } + } + + async renderPDF(id, content, data = {}, options = {}) { + try { + logger.debug('Rendering PDF for template:', id); + + const renderedTemplate = await this.renderTemplate(id, content, data, 1, options, false); + + if (renderedTemplate.error != undefined) { + return { error: renderedTemplate.error, code: renderedTemplate.code }; + } + const baseHtml = renderedTemplate.html; + + const pdfBuffer = await generatePDF(baseHtml, { + width: renderedTemplate.width, + height: renderedTemplate.height, + infiniteHeight: renderedTemplate.infiniteHeight, + }); + + return { + pdf: pdfBuffer, + width: renderedTemplate.width, + height: renderedTemplate.height, + infiniteHeight: renderedTemplate.infiniteHeight, + }; + } catch (error) { + logger.warn('Error whilst rendering PDF:', error.message); + return { error: error.message, code: 500 }; + } + } + + async renderImages(id, content, data = {}, options = {}, format = 'jpeg') { + try { + logger.debug(`Rendering ${format} for template:`, id); + + const pdfResult = await this.renderPDF(id, content, data, options); + if (pdfResult.error != undefined) { + return { error: pdfResult.error, code: pdfResult.code }; + } + + const images = await convertPDFToImage(pdfResult.pdf, options); + const converted = []; + for (const image of images) { + if (format === 'png') { + converted.push(await sharp(image).png().toBuffer()); + } else { + converted.push(await sharp(image).jpeg().toBuffer()); + } + } + return { images: converted }; + } catch (error) { + logger.warn('Error whilst rendering image:', error.message); + return { error: error.message, code: 500 }; + } + } + + async renderSVG(id, content, data = {}, options = {}) { + try { + logger.debug('Rendering SVG for template:', id); + + const pdfResult = await this.renderPDF(id, content, data, options); + if (pdfResult.error != undefined) { + return { error: pdfResult.error, code: pdfResult.code }; + } + + const images = await convertPDFToImage(pdfResult.pdf, options); + const svgs = []; + for (const image of images) { + const png = await sharp(image).png().toBuffer(); + const meta = await sharp(png).metadata(); + svgs.push( + imageToSvg( + png, + meta.width, + meta.height, + pdfResult.width, + pdfResult.height + ) + ); + } + return { svgs }; + } catch (error) { + logger.warn('Error whilst rendering SVG:', error.message); + return { error: error.message, code: 500 }; + } + } + + async renderDownload(id, content, data = {}, type = 'pdf', options = {}) { + const normalizedType = normalizeDownloadType(type); + if (!DOWNLOAD_TYPES.includes(normalizedType)) { + return { + error: `Unsupported download type: ${type}. Use pdf, png, svg or jpeg.`, + code: 400, + }; + } + + if (normalizedType === 'pdf') { + const result = await this.renderPDF(id, content, data, options); + if (result.error) { + return result; + } + return { + type: 'pdf', + mime: 'application/pdf', + extension: 'pdf', + buffers: [result.pdf], + }; + } + + if (normalizedType === 'svg') { + const result = await this.renderSVG(id, content, data, options); + if (result.error) { + return result; + } + return { + type: 'svg', + mime: 'image/svg+xml', + extension: 'svg', + buffers: result.svgs.map((svg) => Buffer.from(svg, 'utf8')), + }; + } + + const result = await this.renderImages(id, content, data, options, normalizedType); + if (result.error) { + return result; + } + return { + type: normalizedType, + mime: normalizedType === 'png' ? 'image/png' : 'image/jpeg', + extension: normalizedType === 'png' ? 'png' : 'jpeg', + buffers: result.images, + }; + } + + async listObjects(objectType, filter = {}, populate = []) { + const modelEntry = getModelByName(objectType); + const model = modelEntry?.model || modelEntry; + if (model == undefined) { + throw new Error('Farm Control: Object type not found.'); + } + const objects = await listObjects({ + model, + filter, + populate, + pagination: false, + }); + return objects; + } + + formatDate(date, format) { + return dayjs(date).format(format); + } + + async getObject(objectType = undefined, id = undefined, populate = []) { + if (objectType == undefined || objectType == '') { + logger.warn('Object type is required'); + return { error: 'Object type is required' }; + } + if (id == undefined || id == '') { + logger.warn('Object ID is required'); + return { error: 'Object ID is required' }; + } + const modelEntry = getModelByName(objectType); + const model = modelEntry?.model || modelEntry; + if (model == undefined) { + logger.warn('Object type not found:', objectType); + } + const object = await getObject({ model, id, populate }); + return object; + } +} + +export const templateManager = new TemplateManager(); +export { DOWNLOAD_TYPES, normalizeDownloadType };