diff --git a/package-lock.json b/package-lock.json index 468131e..ac0215e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", "react": "^19.0.0", @@ -31,6 +32,7 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tsx": "^4.23.12", "typescript": "^5.7.2" } }, @@ -1763,6 +1765,24 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -6135,6 +6155,12 @@ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-latin": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", @@ -6196,6 +6222,24 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -7430,6 +7474,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 682e650..5a5414d 100644 --- a/package.json +++ b/package.json @@ -8,21 +8,23 @@ "build": "astro build", "start": "node ./dist/server/entry.mjs", "migrate": "node --loader tsx ./scripts/migrate.mjs", - "astro": "astro" + "astro": "astro", + "test": "node --import tsx --test tests/*.test.ts" }, "dependencies": { "@astrojs/node": "^9.1.3", "@astrojs/react": "^4.2.1", - "@fontsource/space-grotesk": "^5.1.0", - "@fontsource/space-mono": "^5.1.0", "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", + "@fontsource/space-grotesk": "^5.1.0", + "@fontsource/space-mono": "^5.1.0", "archiver": "^7.0.1", "argon2": "^0.41.1", "astro": "^5.5.0", "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", "react": "^19.0.0", @@ -34,6 +36,7 @@ "@types/pg": "^8.11.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tsx": "^4.23.12", "typescript": "^5.7.2" } } diff --git a/src/lib/printrender.ts b/src/lib/printrender.ts new file mode 100644 index 0000000..242c960 --- /dev/null +++ b/src/lib/printrender.ts @@ -0,0 +1,158 @@ +// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib). +// Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis +// vorhersagbar und wiederholbar ist. +import sharp from 'sharp'; +import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; +import { mmToPt, mmToPx } from './paper'; +import type { Line, Page, SheetSpec } from './printlayout'; + +/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */ +export interface CropRel { x: number; y: number; w: number; h: number } + +export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 }; + +/** + * Bringt einen Bildausschnitt exakt auf ein physisches Maß. + * `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt + * exakt der gewählte Ausschnitt. Fehlende Randpixel werden aus der Kante + * fortgeschrieben (extendWith 'copy'), statt das Motiv zu stauchen. + */ +export async function renderCell( + input: Buffer, + crop: CropRel | null, + wMm: number, + hMm: number, + dpi: number, + opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {}, +): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> { + const bleed = Math.max(0, opt.bleedMm || 0); + const meta = await sharp(input, { failOn: 'none' }).metadata(); + const nw = meta.width || 0, nh = meta.height || 0; + if (!nw || !nh) throw new Error('Bildmaße unbekannt.'); + + // Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis + // („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle. + const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm); + // Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox + // exakt das bleibt, was in der Vorschau gerahmt wurde. + const fx = (wMm + 2 * bleed) / wMm; + const fy = (hMm + 2 * bleed) / hMm; + const cw = c.w * fx, ch = c.h * fy; + const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2; + + const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh }; + const left = Math.round(want.left), top = Math.round(want.top); + const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height)); + + const ex = { + left: Math.min(Math.max(0, left), nw - 1), + top: Math.min(Math.max(0, top), nh - 1), + }; + const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left)); + const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top)); + const padLeft = Math.max(0, ex.left - left); + const padTop = Math.max(0, ex.top - top); + const padRight = Math.max(0, width - exW - padLeft); + const padBottom = Math.max(0, height - exH - padTop); + + const targetW = mmToPx(wMm + 2 * bleed, dpi); + const targetH = mmToPx(hMm + 2 * bleed, dpi); + + let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH }); + if (padLeft || padTop || padRight || padBottom) { + img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' }); + } + img = img.resize(targetW, targetH, { fit: 'fill' }); + if (opt.rotate) img = img.rotate(90); + + const hasAlpha = !!meta.hasAlpha; + const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; + const flat = hasAlpha && ext === 'jpg'; + if (flat) img = img.flatten({ background: opt.background || '#ffffff' }); + + const out = await (ext === 'png' + ? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) + : img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }) + ).toBuffer({ resolveWithObject: true }); + + return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] }; +} + +/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */ +export function coverCrop(natW: number, natH: number, aspect: number): CropRel { + const imgA = natW / natH; + const w = imgA > aspect ? aspect / imgA : 1; + const h = imgA > aspect ? 1 : imgA / aspect; + return { x: (1 - w) / 2, y: (1 - h) / 2, w, h }; +} + +function normCrop(c: CropRel | null): CropRel { + if (!c) return FULL_CROP; + const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0)); + let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1); + let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h); + return { x, y, w, h }; +} + +export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' } + +export interface SheetPdfInput { + sheet: SheetSpec; + pages: Page[]; + marksPerPage: Line[][]; + /** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */ + images: Record; + title?: string; + footer?: string | null; + markWidthPt?: number; +} + +/** + * Baut das Druck-PDF. Seitengröße = Bogengröße in mm, 1:1 — beim Drucken + * unbedingt „Tatsächliche Größe / 100 %" wählen, nicht „an Seite anpassen". + */ +export async function buildSheetPdf(input: SheetPdfInput): Promise { + const { sheet, pages, marksPerPage, images } = input; + const pdf = await PDFDocument.create(); + pdf.setTitle(input.title || 'Klarbild Druckbogen'); + pdf.setProducer('Klarbild'); + pdf.setCreator('Klarbild — Druckbogen'); + + const embedded: Record = {}; + for (const [id, img] of Object.entries(images)) { + embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes); + } + const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null; + const bleed = sheet.bleedMm || 0; + const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm); + + pages.forEach((pg, i) => { + const page = pdf.addPage([W, H]); + for (const p of pg.placements) { + const im = embedded[p.specId]; + if (!im) continue; + // Bild inkl. Beschnittzugabe: ragt je Seite um `bleed` über die Trimmbox. + const x = mmToPt(p.x - bleed); + const yTop = p.y - bleed; // von oben gezählt + const w = mmToPt(p.w + 2 * bleed); + const h = mmToPt(p.h + 2 * bleed); + page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h }); + } + for (const l of marksPerPage[i] || []) { + page.drawLine({ + start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) }, + end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) }, + thickness: input.markWidthPt ?? 0.25, + color: rgb(0, 0, 0), + }); + } + if (font && input.footer) { + page.drawText(input.footer, { + x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42), + rotate: degrees(0), + }); + } + }); + + return pdf.save(); +} diff --git a/src/lib/printsource.ts b/src/lib/printsource.ts new file mode 100644 index 0000000..288080f --- /dev/null +++ b/src/lib/printsource.ts @@ -0,0 +1,44 @@ +// Auflösen der Bildquellen für den Druckbogen: frisch hochgeladen oder aus der Bibliothek. +import { one } from './db'; +import { getObject } from './storage'; + +export type PrintSource = + | { kind: 'upload'; path: string } + | { kind: 'item'; id: string }; + +export interface SessionUser { uid: string | null; role: string } + +/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */ +export async function loadSource(src: PrintSource, user: SessionUser): Promise { + if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.'); + + if (src.kind === 'upload') { + // Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc. + const p = String(src.path || ''); + if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.'); + return getObject(p); + } + + if (src.kind === 'item') { + const item = await one( + `SELECT i.result_path, i.filename, j.created_by, j.private + FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]); + if (!item?.result_path) throw new Error('Bild nicht gefunden.'); + const isAdmin = user.role === 'admin'; + const own = item.created_by === user.uid; + if (!isAdmin && !own) { + const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1'); + if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.'); + } + return getObject(item.result_path); + } + + throw new Error('Unbekannte Quellenart.'); +} + +/** Sprechender Dateiname für den Download. */ +export function sheetFilename(prefix: string, ext: string): string { + const date = new Date().toISOString().slice(0, 10); + const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + return `${date}_${slug || 'druckbogen'}.${ext}`; +}