Compare commits

..
21 Commits
Author SHA1 Message Date
alam00000 55915a2901 Release v2.8.7 2026-07-27 13:03:20 +05:30
Alam c8d04e51b9 Merge pull request #794 from alam00000/dependabot/npm_and_yarn/brace-expansion-2.1.2
build(deps-dev): bump brace-expansion from 2.0.3 to 2.1.2
2026-07-27 12:51:03 +05:30
Alam b003355a0c Merge pull request #801 from alam00000/dependabot/npm_and_yarn/undici-7.29.0
build(deps-dev): bump undici from 7.24.5 to 7.29.0
2026-07-27 12:50:45 +05:30
Alam 79ae42d73a Merge pull request #802 from alam00000/dependabot/npm_and_yarn/linkify-it-5.0.2
build(deps): bump linkify-it from 5.0.1 to 5.0.2
2026-07-27 12:50:28 +05:30
dependabot[bot] 2835e7e5f9 build(deps): bump linkify-it from 5.0.1 to 5.0.2
Bumps [linkify-it](https://github.com/markdown-it/linkify-it) from 5.0.1 to 5.0.2.
- [Changelog](https://github.com/markdown-it/linkify-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/linkify-it/compare/5.0.1...5.0.2)

---
updated-dependencies:
- dependency-name: linkify-it
  dependency-version: 5.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-24 18:35:18 +00:00
dependabot[bot] a59a1f41cd build(deps-dev): bump undici from 7.24.5 to 7.29.0
Bumps [undici](https://github.com/nodejs/undici) from 7.24.5 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.5...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-24 12:58:58 +00:00
alam00000 0447cfc032 feat(port-validation): add validation for PORT variable to ensure it is a number and within the valid range 2026-07-23 18:24:46 +05:30
alam00000 7070bae927 feat(cache): enhance cache validation by checking trusted origins for cached files 2026-07-22 13:57:05 +05:30
alam00000 a3e64f6340 docs(cors-proxy): clarify DNS rebinding risks and egress filtering requirements for self-hosting 2026-07-22 12:56:36 +05:30
alam00000 45496fcd2e feat(security): add warnings for missing PROXY_SECRET and document DNS rebinding risks 2026-07-21 14:12:41 +05:30
dependabot[bot] e59ce4feac build(deps-dev): bump brace-expansion from 2.0.3 to 2.1.2
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 2.0.3 to 2.1.2.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.3...v2.1.2)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-21 03:59:44 +00:00
Alam 21c924a3e6 Merge pull request #785 from PeterDaveHello/zh-TW
Update and improve zh-TW Traditional Chinese locale
2026-07-15 00:20:51 +05:30
alam00000 58cd1a8893 feat(security): enhance security policies and implement input validation across multiple files 2026-07-14 19:46:40 +05:30
Peter Dave Hello 57a6422e61 Update and improve zh-TW Traditional Chinese locale 2026-07-14 03:25:53 +08:00
github-actions[bot] 0ef279e0da @PeterDaveHello has signed the CLA in alam00000/bentopdf#785 2026-07-13 18:21:24 +00:00
alam00000 8dcd936873 fix(email): validate and escape unwrapped SafeLinks URLs before rendering 2026-07-13 10:07:58 +05:30
github-actions[bot] 5ef0b40dea @cevdetta has signed the CLA in alam00000/bentopdf#783 2026-07-10 13:13:04 +00:00
alam00000 6f02f2d2b7 chore: update dependencies and improve validation 2026-07-02 00:28:48 +05:30
alam00000 b21f602cca feat(timestamp): add TSA URL validation and sanitization methods 2026-07-01 23:23:37 +05:30
Alam b09fc21e66 Merge pull request #768 from alam00000/dependabot/npm_and_yarn/linkify-it-5.0.1
build(deps): bump linkify-it from 5.0.0 to 5.0.1
2026-06-30 11:35:35 +05:30
dependabot[bot] fa6355b98a build(deps): bump linkify-it from 5.0.0 to 5.0.1
Bumps [linkify-it](https://github.com/markdown-it/linkify-it) from 5.0.0 to 5.0.1.
- [Changelog](https://github.com/markdown-it/linkify-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/linkify-it/compare/5.0.0...5.0.1)

---
updated-dependencies:
- dependency-name: linkify-it
  dependency-version: 5.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-28 06:28:30 +00:00
36 changed files with 1538 additions and 846 deletions
+6
View File
@@ -12,8 +12,14 @@ Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'"
</IfModule>
# Block source maps from being served
<FilesMatch "\.map$">
Require all denied
</FilesMatch>
# ============================================
# 2. BROWSER CACHING
# ============================================
+4 -2
View File
@@ -1,3 +1,5 @@
# Dockerfile.nonroot intentionally starts as root to support PUID/PGID (LSIO pattern).
# The entrypoint.sh creates the user at runtime and drops privileges via su-exec.
DS-0002
KSV-0125
DS-0013
+2 -2
View File
@@ -7,10 +7,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.5
version: 1.0.6
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: '2.8.6'
appVersion: '2.8.7'
+2
View File
@@ -75,6 +75,8 @@ securityContext:
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
resources: {}
+87 -10
View File
@@ -13,13 +13,11 @@
*/
const ALLOWED_PATH_PATTERNS = [
/\.crt$/i,
/\.cer$/i,
/\.pem$/i,
/\/certs\//i,
/\/ocsp/i,
/\/crl/i,
/caIssuers/i,
/\.(crt|cer|pem|der|p7c|p7b)$/i,
/(^|\/)certs(\/|$)/i,
/(^|\/)ocsp(\/|$)/i,
/(^|\/)crl(\/|$)/i,
/(^|\/)caissuers(\/|$)/i,
];
const ALLOWED_TSA_HOSTS = new Set([
@@ -49,6 +47,8 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
let warnedNoProxySecret = false;
async function verifySignature(message, signature, secret) {
try {
const encoder = new TextEncoder();
@@ -123,6 +123,31 @@ function isPrivateOrReservedHost(hostname) {
return false;
}
async function hostnameResolvesToPrivate(hostname) {
const clean = hostname.replace(/^\[|\]$/g, '');
if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) {
return isPrivateOrReservedHost(clean);
}
const ips = [];
for (const type of ['A', 'AAAA']) {
try {
const res = await fetch(
`https://1.1.1.1/dns-query?name=${encodeURIComponent(clean)}&type=${type}`,
{ headers: { accept: 'application/dns-json' } }
);
if (!res.ok) return true;
const data = await res.json();
for (const ans of data.Answer || []) {
if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, ''));
}
} catch {
return true;
}
}
if (ips.length === 0) return true;
return ips.some((ip) => isPrivateOrReservedHost(ip));
}
function isValidCertificateUrl(urlString) {
try {
const url = new URL(urlString);
@@ -181,7 +206,7 @@ function handleOptions(request) {
}
export default {
async fetch(request, env, ctx) {
async fetch(request, env) {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
@@ -190,6 +215,10 @@ export default {
}
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
// SECURITY: The Origin allow-list and the optional PROXY_SECRET HMAC are anti-abuse controls, NOT a security boundary — the Origin
// header is forgeable by non-browser clients and the HMAC secret ships in the public client bundle. The real SSRF defense is the
// private/reserved-IP resolution check (hostnameResolvesToPrivate). Deployments running this off Cloudflare cannot fully close the
// DNS-rebinding gap in code and MUST add network egress filtering (see docs/self-hosting/cors-proxy.md).
if (!isAllowedOrigin(origin)) {
return new Response(
JSON.stringify({
@@ -293,7 +322,8 @@ export default {
const now = Date.now();
if (
isNaN(requestTime) ||
Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS
requestTime > now + 60000 ||
now - requestTime > MAX_TIMESTAMP_AGE_MS
) {
return new Response(
JSON.stringify({
@@ -332,6 +362,13 @@ export default {
}
);
}
} else if (!warnedNoProxySecret) {
warnedNoProxySecret = true;
console.warn(
'[CORS Proxy] PROXY_SECRET is not set — request signatures are not verified, so the proxy is callable by any client that can ' +
'send an allowed Origin header. Set PROXY_SECRET to deter casual abuse (note: it ships in the public client bundle, so it is ' +
'not a real authentication boundary), and add network egress filtering if running off Cloudflare.'
);
}
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
@@ -384,9 +421,33 @@ export default {
);
}
let targetHostname;
try {
targetHostname = new URL(targetUrl).hostname;
} catch {
targetHostname = '';
}
if (!targetHostname || (await hostnameResolvesToPrivate(targetHostname))) {
return new Response(
JSON.stringify({
error: 'Forbidden destination',
message: 'The requested host is not permitted',
}),
{
status: 403,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
try {
const upstreamInit = {
method: request.method,
redirect: 'manual',
signal: AbortSignal.timeout(10000),
headers: {
'User-Agent': 'BentoPDF-CertProxy/1.0',
},
@@ -399,6 +460,22 @@ export default {
const response = await fetch(targetUrl, upstreamInit);
if (response.status >= 300 && response.status < 400) {
return new Response(
JSON.stringify({
error: 'Redirect blocked',
message: 'Upstream redirects are not allowed',
}),
{
status: 502,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
if (!response.ok) {
return new Response(
JSON.stringify({
@@ -480,7 +557,7 @@ export default {
response.headers.get('Content-Type')
),
'Content-Length': totalSize.toString(),
'Cache-Control': 'public, max-age=86400',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
+8
View File
@@ -126,6 +126,14 @@ The included Cloudflare Worker has several security measures:
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
### DNS rebinding (self-hosting off Cloudflare)
The worker rejects any URL that resolves to a private or internal IP. It can't use a fixed allowlist of hosts, since certificate-chain URLs point to whatever CA issued the cert (FNMT, corporate CAs, and so on), so the destinations aren't known up front. That leaves a small rebinding window: someone running the DNS for their own domain could hand a public IP to the safety check and a private one to the real request a moment later.
On Cloudflare this isn't exploitable — Workers can't reach private IPs or the `169.254.169.254` metadata endpoint, so a rebind lands on nothing. Just don't give the worker a way in: don't attach a Tunnel, Service, or private-network binding (it only needs `RATE_LIMIT_KV`).
If you run this off Cloudflare (workerd, Node, and the like) inside a VPC, the runtime _can_ reach internal IPs. Put an egress firewall in front of it and block the private ranges — `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`, and their IPv6 equivalents. App code can't enforce that once you're off Cloudflare.
## Disabling the Proxy
If you don't want to use a CORS proxy, set the environment variable to an empty string:
+7
View File
@@ -35,6 +35,13 @@ chown -R "$PUID:$PGID" \
/etc/nginx/nginx.conf
PORT=${PORT:-8080}
case "$PORT" in
''|*[!0-9]*) echo "ERROR: PORT must be a number, got '$PORT'" >&2; exit 1 ;;
esac
if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "ERROR: PORT must be between 1 and 65535, got '$PORT'" >&2
exit 1
fi
if [ "$PORT" != "8080" ]; then
echo "Changing Nginx listen port to $PORT"
sed -i "s/listen 8080/listen $PORT/g; s/listen \[::\]:8080/listen [::]:$PORT/g" /etc/nginx/nginx.conf
+7
View File
@@ -10,6 +10,13 @@ entrypoint_log() {
}
PORT=${PORT:-8080}
case "$PORT" in
''|*[!0-9]*) echo "ERROR: PORT must be a number, got '$PORT'" >&2; exit 1 ;;
esac
if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "ERROR: PORT must be between 1 and 65535, got '$PORT'" >&2
exit 1
fi
if [ "$PORT" != "8080" ]; then
entrypoint_log "Changing Nginx listen port to $PORT"
sed -i "s/listen 8080/listen $PORT/g; s/listen \[::\]:8080/listen [::]:$PORT/g" /etc/nginx/nginx.conf
+179 -159
View File
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
"version": "2.8.4",
"version": "2.8.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bento-pdf",
"version": "2.8.4",
"version": "2.8.6",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7",
@@ -31,7 +31,7 @@
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.2",
"diff": "^8.0.3",
"dompurify": "^3.4.0",
"dompurify": "^3.4.11",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
"fast-xml-parser": "^5.7.1",
"heic2any": "^0.0.4",
@@ -45,7 +45,7 @@
"jszip": "^3.10.1",
"lit": "^3.3.2",
"lucide": "^0.575.0",
"markdown-it": "^14.1.1",
"markdown-it": "^14.2.0",
"markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0",
@@ -57,7 +57,7 @@
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"markdown-it-toc-done-right": "^4.2.0",
"mermaid": "^11.15.0",
"mermaid": "^11.16.0",
"microdiff": "^1.5.0",
"node-forge": "^1.4.0",
"papaparse": "^5.5.3",
@@ -104,7 +104,7 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.10",
"vite": "^8.0.16",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.26.0",
@@ -1284,13 +1284,13 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
@@ -1302,9 +1302,9 @@
"optional": true
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1319,9 +1319,9 @@
"optional": true
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1754,12 +1754,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
"license": "MIT",
"dependencies": {
"@chevrotain/types": "~11.1.1"
"@chevrotain/types": "~11.1.2"
}
},
"node_modules/@napi-rs/canvas": {
@@ -2013,13 +2013,13 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@@ -2049,9 +2049,9 @@
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -2120,9 +2120,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
"cpu": [
"arm64"
],
@@ -2136,9 +2136,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
"cpu": [
"arm64"
],
@@ -2152,9 +2152,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
"cpu": [
"x64"
],
@@ -2168,9 +2168,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
"cpu": [
"x64"
],
@@ -2184,9 +2184,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
"cpu": [
"arm"
],
@@ -2200,9 +2200,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
"cpu": [
"arm64"
],
@@ -2216,9 +2216,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
"cpu": [
"arm64"
],
@@ -2232,9 +2232,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
"cpu": [
"ppc64"
],
@@ -2248,9 +2248,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
"cpu": [
"s390x"
],
@@ -2264,9 +2264,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
"cpu": [
"x64"
],
@@ -2280,9 +2280,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
"cpu": [
"x64"
],
@@ -2296,9 +2296,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
"cpu": [
"arm64"
],
@@ -2312,27 +2312,27 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
"cpu": [
"arm64"
],
@@ -2346,9 +2346,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
"cpu": [
"x64"
],
@@ -2362,9 +2362,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@rollup/plugin-inject": {
@@ -3232,9 +3232,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4889,9 +4889,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5718,9 +5718,9 @@
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
"license": "MIT",
"engines": {
"node": ">=0.10"
@@ -6428,9 +6428,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -7297,9 +7297,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -8244,9 +8244,9 @@
}
},
"node_modules/katex": {
"version": "0.16.40",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz",
"integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==",
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
@@ -8629,9 +8629,19 @@
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
@@ -8978,14 +8988,24 @@
"license": "MIT"
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
@@ -9134,26 +9154,26 @@
"license": "MIT"
},
"node_modules/mermaid": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"version": "11.16.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.1",
"@mermaid-js/parser": "^1.2.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"katex": "^0.16.45",
"khroma": "^2.1.0",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
@@ -9389,9 +9409,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
@@ -10080,9 +10100,9 @@
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"funding": [
{
"type": "opencollective",
@@ -10099,7 +10119,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -10347,9 +10367,9 @@
"license": "MIT"
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -10649,13 +10669,13 @@
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
"@oxc-project/types": "=0.138.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -10664,21 +10684,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
"@rolldown/binding-android-arm64": "1.1.4",
"@rolldown/binding-darwin-arm64": "1.1.4",
"@rolldown/binding-darwin-x64": "1.1.4",
"@rolldown/binding-freebsd-x64": "1.1.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
"@rolldown/binding-linux-arm64-musl": "1.1.4",
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
"@rolldown/binding-linux-x64-gnu": "1.1.4",
"@rolldown/binding-linux-x64-musl": "1.1.4",
"@rolldown/binding-openharmony-arm64": "1.1.4",
"@rolldown/binding-wasm32-wasi": "1.1.4",
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
"@rolldown/binding-win32-x64-msvc": "1.1.4"
}
},
"node_modules/rollup": {
@@ -11629,9 +11649,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -11876,9 +11896,9 @@
}
},
"node_modules/undici": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12070,16 +12090,16 @@
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vfile": {
@@ -12113,16 +12133,16 @@
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
"postcss": "^8.5.16",
"rolldown": "~1.1.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -12138,7 +12158,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+8 -6
View File
@@ -1,7 +1,7 @@
{
"name": "bento-pdf",
"private": true,
"version": "2.8.6",
"version": "2.8.7",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -37,7 +37,8 @@
"lint:security": "eslint . --no-inline-config --rule 'no-unsanitized/method:error' --rule 'no-unsanitized/property:error' --rule 'security/detect-eval-with-expression:error'",
"security:codeql": "codeql database create ./codeql-db --language=javascript-typescript --source-root=. --overwrite --threads=0 && codeql database analyze ./codeql-db --format=sarif-latest --output=codeql-results.sarif --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls",
"security:codeql:quick": "codeql database analyze ./codeql-db --format=csv --output=codeql-results.csv --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls",
"security:audit": "npm audit --audit-level=high && npm run lint:security",
"security:patterns": "node scripts/security-patterns-check.mjs",
"security:audit": "npm audit --audit-level=high && npm run lint:security && npm run security:patterns",
"prepare": "husky"
},
"devDependencies": {
@@ -62,7 +63,7 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.10",
"vite": "^8.0.16",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.26.0",
@@ -93,7 +94,7 @@
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.2",
"diff": "^8.0.3",
"dompurify": "^3.4.0",
"dompurify": "^3.4.11",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
"fast-xml-parser": "^5.7.1",
"heic2any": "^0.0.4",
@@ -107,7 +108,7 @@
"jszip": "^3.10.1",
"lit": "^3.3.2",
"lucide": "^0.575.0",
"markdown-it": "^14.1.1",
"markdown-it": "^14.2.0",
"markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0",
@@ -119,7 +120,7 @@
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"markdown-it-toc-done-right": "^4.2.0",
"mermaid": "^11.15.0",
"mermaid": "^11.16.0",
"microdiff": "^1.5.0",
"node-forge": "^1.4.0",
"papaparse": "^5.5.3",
@@ -154,6 +155,7 @@
]
},
"overrides": {
"uuid": "^14",
"node-forge": "^1.4.0",
"flatted": "^3.4.2",
"react": "^18.3.1",
+128 -128
View File
@@ -5,21 +5,21 @@
"contact": "聯絡我們",
"licensing": "產品授權",
"allTools": "所有工具",
"docs": "Docs",
"docs": "說明文件",
"openMainMenu": "開啟主選單",
"language": "語言",
"searchLanguage": "搜尋語言…",
"noLanguagesFound": "找不到語言"
},
"donation": {
"message": "喜歡 BentoPDF?幫助我們保持免費和開源!",
"button": "捐贈"
"message": "喜歡 BentoPDF 嗎?請協助我們繼續免費提供 BentoPDF,並維持原始碼開放!",
"button": "贊助"
},
"hero": {
"title": "專為隱私打造的",
"pdfToolkit": "PDF 工具箱",
"builtForPrivacy": " ",
"noSignups": "不須註冊",
"noSignups": "免註冊",
"unlimitedUse": "無限使用",
"worksOffline": "離線可用",
"startUsing": "立刻開始使用"
@@ -28,51 +28,51 @@
"title": "被下列公司及其員工採用"
},
"features": {
"title": "為何你該選擇",
"title": "為何您該選擇",
"bentoPdf": "BentoPDF?",
"noSignup": {
"title": "不須註冊",
"description": "立即可用,不須帳號或電子郵件。"
"title": "免註冊",
"description": "立即可用,無需帳號或電子郵件。"
},
"noUploads": {
"title": "不須上傳",
"description": "所有文件都在用戶端處理,永遠不會離開你的裝置。"
"title": "免上傳",
"description": "完全在本機處理,您的檔案絕不離開您的裝置。"
},
"foreverFree": {
"title": "永遠免費",
"description": "所有工具免費使用,沒有試用期,也沒有付費牆。"
},
"noLimits": {
"title": "沒有限制",
"description": "隨心所欲的使用,沒有任何隱藏限制。"
"title": "無使用限制",
"description": "隨心所欲地使用,沒有任何隱藏限制。"
},
"batchProcessing": {
"title": "批量處理",
"description": "一次處理無限量的 PDF 檔案。"
"title": "批次處理",
"description": "可同時處理多個 PDF 檔案,無數量限制。"
},
"lightningFast": {
"title": "快如閃電",
"description": "瞬間處理 PDF,無須忍受任何等待或延遲。"
"title": "極速處理",
"description": "瞬間處理 PDF,無需任何等待或延遲。"
}
},
"tools": {
"title": "開始使用",
"toolsLabel": "工具",
"subtitle": "點擊任意工具以開始上傳檔案",
"searchPlaceholder": "搜尋工具(例如「合併」或「分割」...)",
"backToTools": "返回工具列表",
"title": "立即開始使用",
"toolsLabel": "PDF 工具",
"subtitle": "點選任意工具即可開啟檔案上傳器",
"searchPlaceholder": "搜尋工具(例如「分割」或「整理」…)",
"backToTools": "回到工具清單",
"firstLoadNotice": "首次載入需要一點時間,因為我們正在下載轉換引擎。之後所有載入將即時完成。"
},
"upload": {
"clickToSelect": "點擊以選擇檔案",
"orDragAndDrop": "或將檔案拖放到此處",
"clickToSelect": "點選以選擇檔案",
"orDragAndDrop": "或將檔案拖曳至此處",
"pdfOrImages": "PDF 或圖片",
"filesNeverLeave": "你的檔案永遠不會離開你的裝置。",
"addMore": "添加更多檔案",
"filesNeverLeave": "您的檔案絕不會離開您的裝置。",
"addMore": "新增更多檔案",
"clearAll": "清除全部",
"clearFiles": "清除檔案",
"hints": {
"singlePdf": "單個 PDF 檔案",
"singlePdf": "單一 PDF 檔案",
"pdfFile": "PDF 檔案",
"multiplePdfs2": "多個 PDF 檔案(至少 2 個)",
"bmpImages": "BMP 圖片",
@@ -80,20 +80,20 @@
"pdfDocuments": "PDF 文件",
"oneOrMoreCsv": "一個或多個 CSV 檔案",
"multiplePdfsSupported": "支援多個 PDF 檔案",
"singleOrMultiplePdfs": "支援單個或多個 PDF 檔案",
"singlePdfFile": "單個 PDF 檔案",
"singleOrMultiplePdfs": "支援單一或多個 PDF 檔案",
"singlePdfFile": "單一 PDF 檔案",
"pdfWithForms": "含有表單欄位的 PDF 檔案",
"heicImages": "HEIC/HEIF 圖片",
"jpgImages": "JPG、JPEG、JP2、JPX 圖片",
"pdfsOrImages": "PDF 或圖片",
"oneOrMoreOdt": "一個或多個 ODT 檔案",
"singlePdfOnly": "僅限單個 PDF 檔案",
"singlePdfOnly": "僅限單一 PDF 檔案",
"pdfFiles": "PDF 檔案",
"multiplePdfs": "多個 PDF 檔案",
"pngImages": "PNG 圖片",
"pdfFilesOneOrMore": "PDF 檔案(一個或多個)",
"oneOrMoreRtf": "一個或多個 RTF 檔案",
"svgGraphics": "SVG 圖形",
"svgGraphics": "SVG 向量圖形",
"tiffImages": "TIFF 圖片",
"webpImages": "WebP 圖片"
}
@@ -101,21 +101,21 @@
"howItWorks": {
"title": "使用方式",
"step1Title": "上傳檔案",
"step1": "點擊或拖放您的檔案到此處",
"step2Title": "處理",
"step2": "點擊處理按鈕開始",
"step3Title": "下載",
"step1": "點選或拖曳檔案至此處以開始",
"step2Title": "開始處理",
"step2": "點選處理按鈕以開始",
"step3Title": "下載檔案",
"step3": "立即儲存處理後的檔案"
},
"relatedTools": {
"title": "相關 PDF 工具"
},
"loader": {
"processing": "正在處理..."
"processing": "正在處理…"
},
"alert": {
"title": "提示",
"ok": "確認"
"ok": "確定"
},
"preview": {
"title": "文件預覽",
@@ -126,36 +126,36 @@
"title": "設定",
"shortcuts": "快捷鍵",
"preferences": "偏好設定",
"displayPreferences": "顯示設定",
"searchShortcuts": "搜尋快捷鍵...",
"shortcutsInfo": "按下並按住按鍵以設定快捷鍵。變更將自動儲存。",
"shortcutsWarning": "⚠️ 避免使用瀏覽器常用快捷鍵(Cmd/Ctrl+W、Cmd/Ctrl+T、Cmd/Ctrl+N 等),它們可能無法穩定運作。",
"displayPreferences": "顯示偏好設定",
"searchShortcuts": "搜尋快捷鍵…",
"shortcutsInfo": "請按住按鍵組合以設定快捷鍵。系統會自動儲存變更。",
"shortcutsWarning": "⚠️ 請避免使用瀏覽器常見的快捷鍵(如 Cmd/Ctrl+W、Cmd/Ctrl+T、Cmd/Ctrl+N 等),以免無法正常運作。",
"import": "匯入",
"export": "匯出",
"resetToDefaults": "恢復預設值",
"resetToDefaults": "還原為預設值",
"fullWidthMode": "全寬模式",
"fullWidthDescription": "使用全螢幕寬度而非置中容器顯示所有工具",
"compactMode": "精簡模式",
"compactModeDescription": "以精簡列表而非卡片顯示工具",
"compactModeDescription": "以精簡清單取代卡片顯示工具",
"advancedSettings": "進階設定",
"advancedSettingsDescription": "設定外部處理模組(PyMuPDF、Ghostscript、CoherentPDF)",
"settingsAutoSaved": "設定會自動儲存",
"clickToSet": "點擊以設定",
"pressKeys": "按下按鍵...",
"settingsAutoSaved": "系統會自動儲存設定",
"clickToSet": "點選以設定",
"pressKeys": "請按下按鍵…",
"warnings": {
"alreadyInUse": "快捷鍵已被占用",
"assignedTo": "已被指定為:",
"chooseDifferent": "請選擇一個不同的快捷鍵。",
"reserved": "保留快捷鍵警告",
"commonlyUsed": "常被用於:",
"unreliable": "這個快捷鍵可能與系統/瀏覽器行為衝突或無法穩定運作。",
"alreadyInUse": "快捷鍵已被使用",
"assignedTo": "已指定至:",
"chooseDifferent": "請選擇其他快捷鍵組合。",
"reserved": "系統保留快捷鍵警告",
"commonlyUsed": "通常用於:",
"unreliable": "此快捷鍵可能與系統或瀏覽器行為衝突,或無法穩定運作。",
"useAnyway": "仍要使用嗎?",
"resetTitle": "重設快捷鍵",
"resetMessage": "確定要將所有快捷鍵恢復為預設值嗎?<br><br>這個操作無法被撤回。",
"resetMessage": "確定要將所有快捷鍵還原為預設值嗎?<br><br>此操作無法復原。",
"importSuccessTitle": "匯入成功",
"importSuccessMessage": "快捷鍵匯入成功!",
"importFailTitle": "匯入失敗",
"importFailMessage": "匯入快捷鍵失敗。無效的檔案格式。"
"importFailMessage": "匯入快捷鍵失敗。檔案格式無效。"
}
},
"warning": {
@@ -164,22 +164,22 @@
"proceed": "繼續"
},
"compliance": {
"title": "你的資料永遠不會離開你的裝置",
"weKeep": "我們確保",
"yourInfoSafe": "你的資訊安全",
"byFollowingStandards": "遵循全球安全標準。",
"processingLocal": "所有處理過程都在你的裝置上進行。",
"title": "您的資料絕不會離開您的裝置",
"weKeep": "我們透過遵循全球安全標準,",
"yourInfoSafe": "確保您的資訊安全",
"byFollowingStandards": "。",
"processingLocal": "所有處理過程都在您的裝置上進行。",
"gdpr": {
"title": "符合 GDPR 規範",
"description": "保護歐盟境內個人的數據及隱私。"
"description": "保護歐盟境內個人的個人資料與隱私。"
},
"ccpa": {
"title": "符合 CCPA 規範",
"description": "賦予加州居民對其個人資訊如何被蒐集、使用及分享的權利。"
"description": "賦予加州居民對其個人資料如何被蒐集、使用及分享的權利。"
},
"hipaa": {
"title": "符合 HIPAA 規範",
"description": "制定處理美國健保系統中敏感健康資訊的規範。"
"description": "制定處理美國醫療保健系統中敏感健康資訊的安全規範。"
}
},
"faq": {
@@ -188,35 +188,35 @@
"sectionTitle": "常見問題",
"isFree": {
"question": "BentoPDF 真的是免費的嗎?",
"answer": "沒錯,完全免費。BentoPDF 上的所有工具均為 100% 免費使用,並且沒有檔案限制、無須註冊且無浮水印。我們相信每個人都值得免費使用簡單且強大的 PDF 工具。"
"answer": "是的,完全免費。BentoPDF 上的所有工具皆為 100% 免費使用,並且沒有檔案大小或數量限制、無需註冊,也沒有任何浮水印。我們相信每個人都應該享有簡單且強大的免費 PDF 工具。"
},
"areFilesSecure": {
"question": "我的檔案都是安全的嗎?它們都在哪裡被處理?",
"answer": "你的檔案都非常安全,因為它們從未離開你的電腦。所有處理過程都直接在你的網頁瀏覽器中進行(用戶端)。我們永遠不會將你的檔案上傳到伺服器,因此你對你的文件保有完全的隱私與控制權。"
"question": "我的檔案安全嗎?它們是在哪裡進行處理的?",
"answer": "檔案會受到妥善保護,因為它們絕不會離開電腦。所有處理都直接在網頁瀏覽器中執行,也就是在本機處理。我們絕不會將檔案上傳到任何伺服器,因此文件內容能完全保密,並始終由您掌控。"
},
"platforms": {
"question": "我能在 Mac、Windows 和行動裝置上使用嗎?",
"answer": "可以!由於 BentoPDF 完全在你的瀏覽器中運作,它在任何有著現代網頁瀏覽器的系統中都能運行,包含 Windows、macOS、Linux、iOS 和 Android。"
"answer": "當然可以!BentoPDF 完全在瀏覽器中執行,因此可用於任何具備現代網頁瀏覽器的作業系統,包括 Windows、macOS、Linux、iOS 與 Android。"
},
"gdprCompliant": {
"question": "BentoPDF 符合 GDPR 規範嗎?",
"answer": "是的。BentoPDF 完全符合 GDPR 規範。由於所有檔案處理都在你的瀏覽器本地發生且我們永不蒐集或傳輸你的檔案至任何伺服器,我們無法存取你的資料。這確保你的文件永遠都在你的控制之中。"
"answer": "是的。BentoPDF 完全符合 GDPR 規範。由於所有檔案處理都在您的瀏覽器本機進行,且我們絕不蒐集或傳送您的檔案到任何伺服器,我們完全無法存取您的資料。這確保您的文件永遠處於您的掌控之中。"
},
"dataStorage": {
"question": "你會保存或追蹤我的檔案嗎?",
"answer": "不。我們永不儲存、追蹤或記錄你的檔案。你在 BentoPDF 上進行的任何操作都發生在你的瀏覽器記憶體中,並且會在你關閉頁面後立即消失。沒有上傳、沒有歷史紀錄且無伺服器參與。"
"question": "你們會儲存或追蹤我的檔案嗎?",
"answer": "不會。我們絕不儲存、追蹤或記錄您的檔案。您在 BentoPDF 進行的所有操作皆在瀏覽器記憶體中完成,關閉網頁後便會立即消失。沒有上傳、沒有歷史紀錄,也完全不經手任何伺服器。"
},
"different": {
"question": "BentoPDF 跟其他的 PDF 工具有何不同之處?",
"answer": "大多數 PDF 工具都透過將你的檔案上傳至伺服器好進行處理。BentoPDF 永遠不會那麼做。我們使用安全且現代的網頁科技以在你的瀏覽器中直接處理檔案。這意味著更快的性能、更強的隱私與完全的安心。"
"question": "BentoPDF 與其他 PDF 工具有何不同?",
"answer": "大多數 PDF 工具都會將檔案上傳到伺服器進行處理,而 BentoPDF 絕不會這麼做。我們採用安全且先進的網頁技術,直接在瀏覽器中處理檔案,因此處理速度更快、隱私保護更完善,也更令人安心。"
},
"browserBased": {
"question": "瀏覽器端處理如何保障我的安全?",
"answer": "透過完全在你的瀏覽器內運作,BentoPDF 確保你的文件從未離開你的裝置。這消除了伺服器遭駭、資料外洩與未授權訪問的風險。你的檔案永遠都屬於你。"
"question": "瀏覽器端(本機)處理如何確保我的安全?",
"answer": "透過完全在您的瀏覽器內運作,BentoPDF 可確保您的檔案絕不離開您的裝置。這徹底消除了伺服器遭駭、資料外洩與未授權存取的風險。您的檔案永遠只屬於您自己。"
},
"analytics": {
"question": "你會使用 Cookies 或網站分析來追蹤我嗎?",
"answer": "我們在乎你的隱私。BentoPDF 並不追蹤個人資訊。我們僅使用 Simple Analytics 來查看匿名訪問次數。這代表我們能知道有多少使用者造訪過我們的網站,但我們永遠都不會知道你是誰。Simple Analytics 完全符合 GDPR 規範且尊重你的隱私。"
"question": "BentoPDF 會使用 Cookie 或網站分析工具追蹤我嗎?",
"answer": "我們非常重視您的隱私。BentoPDF 不會追蹤任何個人資料。我們僅使用 Simple Analytics 來統計匿名造訪次數。這代表我們只會知道有多少使用者造訪過我們的網站,但永遠不會知道您是誰。Simple Analytics 完全符合 GDPR 規範並尊重您的隱私權。"
}
},
"testimonials": {
@@ -226,35 +226,35 @@
},
"support": {
"title": "喜歡我的作品嗎?",
"description": "BentoPDF 是一個出於熱情開發的專案,旨在為每個人提供一個免費、注重隱私且強大的 PDF 工具組。如果有幫上你的忙,請考慮支持它的開發。每杯咖啡都意義重大!",
"description": "BentoPDF 是一個出於熱情開發的專案,為每個人提供一個免費、注重隱私且強大的 PDF 工具組。如果有幫上您的忙,請考慮支援它的開發。每杯咖啡都意義重大!",
"buyMeCoffee": "買杯咖啡給我"
},
"footer": {
"copyright": "© 2026 BentoPDF。版權所有。",
"copyright": "© 2026 BentoPDF,版權所有。",
"version": "版本",
"company": "公司",
"aboutUs": "關於我們",
"faqLink": "常見問題",
"contactUs": "聯絡我們",
"legal": "法律",
"legal": "法律聲明",
"termsAndConditions": "服務條款",
"privacyPolicy": "隱私政策",
"followUs": "關注我們"
"privacyPolicy": "隱私權政策",
"followUs": "追蹤我們"
},
"merge": {
"title": "合併 PDF",
"description": "合併整個檔案,或選擇特定頁面合併為新文件。",
"description": "合併整個檔案,或選取特定頁面合併為新文件。",
"fileMode": "檔案模式",
"pageMode": "頁面模式",
"howItWorks": "使用說明:",
"fileModeInstructions": [
"點擊並抓取圖標來改變檔案順序。",
"在每個文件的「頁碼」框中,你可以僅指定想要合併的頁面範圍(例如「1-3, 5」)。",
"將「頁碼」框留空以包含該檔案的所有頁面。"
"拖曳圖示以調整檔案順序。",
"在每個檔案的「頁數」輸入框中,您可以指定要合併的特定頁面範圍(例如「1-3, 5」)。",
"留空則會包含該檔案的所有頁面。"
],
"pageModeInstructions": [
"下列是你上傳的 PDF 中的所有頁面。",
"只要將個別頁面縮圖拖放到指定位置,即可為新檔案建立您想要的精確排序。"
"下方顯示您上傳的 PDF 檔案中的所有頁面。",
"拖曳個別頁面縮圖到指定位置,即可自由調整新檔案的頁面順序。"
],
"mergePdfs": "合併 PDF"
},
@@ -267,10 +267,10 @@
"save": "儲存",
"delete": "刪除",
"edit": "編輯",
"add": "添加",
"add": "新增",
"remove": "移除",
"loading": "載入中...",
"loadingPageCount": "正在加載頁面...",
"loading": "載入中…",
"loadingPageCount": "正在載入頁面…",
"error": "錯誤",
"success": "成功",
"file": "檔案",
@@ -286,102 +286,102 @@
"noCompromises": "絕不妥協。"
},
"mission": {
"title": "我們的任務",
"description": "在尊重你的隱私且從不要求收費的同時提供最全面的 PDF 工具箱。我們相信核心文件工具應讓任何人隨時隨地不受限的使用。"
"title": "我們的使命",
"description": "在尊重您的隱私且完全不收費的同時,提供最全面的 PDF 工具箱。我們相信人人都應該能隨時隨地、不受限制地使用必備的文件工具。"
},
"philosophy": {
"label": "我們的核心理念",
"title": "永遠以隱私為重。",
"description": "在數據被商品化的時代,我們採取截然不同的做法。所有 BentoPDF 工具的處理流程皆在你的瀏覽器本地完成。這意味著你的檔案絕不觸及我們的伺服器,我們從未看見你的文件內容,更不會追蹤你的行為。你的文件將始終保持無可置疑的私密性。這不僅是功能,更是我們的立身之本。"
"title": "隱私至上,始終如一。",
"description": "在個人資料被商品化的時代,我們採取截然不同的做法。所有 BentoPDF 工具的處理流程皆在您的瀏覽器本機完成。這代表您的檔案絕不會觸及我們的伺服器,我們無法查看您的文件內容,更不會追蹤您的行為。您的文件將始終保持絕對的私密性。這不僅是一項功能,更是我們的立身之本。"
},
"whyBentopdf": {
"title": "為何選擇",
"speed": {
"title": "生來迅捷",
"description": "無需等待與伺服器間的上傳和下載。透過在你的瀏覽器中使用 WebAssembly 等現代網路科技處理檔案,我們得以為所有工具提供無與倫比的速度。"
"title": "極速處理",
"description": "無需等待檔案上傳與下載至伺服器。透過在您的瀏覽器中使用 WebAssembly 等現代網頁技術來處理檔案,我們得以提供所有工具無與倫比的處理速度。"
},
"free": {
"title": "完全免費",
"description": "沒有試用期、訂閱、隱藏費用與所謂的「高級」功能。我們相信強大的 PDF 工具應該是一種公共設施,而非以營利為重。"
"description": "沒有試用期、訂閱制、隱藏費用,也不會將「進階」功能封鎖在付費牆後。我們相信強大的 PDF 工具應該是每個人都能享有的公共設施,而非營利手段。"
},
"noAccount": {
"title": "無須帳號",
"description": "立即開始使用任何工具。我們不需要你的電子郵件、密碼或任何個人資訊。你的工作流程應當匿名且不受阻礙。"
"title": "無需註冊帳號",
"description": "立即開始使用任何工具。我們不需要您的電子郵件、密碼或任何個人資料。您的工作流程應當匿名且順暢無阻。"
},
"openSource": {
"title": "開源精神",
"description": "將透明性視為核心打造。我們使用了如 PDF-lib 和 PDF.js 等優秀的開源庫,並且相信社群驅動力能讓強大的工具惠及每一個人。"
"title": "開放原始碼精神",
"description": "以透明度為核心打造。我們採用了如 PDF-lib 和 PDF.js 等優秀的開放原始碼函式庫,並深信社群驅動的力量能讓強大的工具造福每一個人。"
}
},
"cta": {
"title": "準備好開始了嗎?",
"description": "加入成千上萬信任 BentoPDF 能勝任他們日常文件需求的使用者們。體驗隱私與性能所帶來的差距。",
"description": "加入成千上萬信任 BentoPDF 的使用者行列,用它來處理日常文件需求。親自體驗隱私與效能所帶來的全新感受。",
"button": "探索所有工具"
}
},
"contact": {
"title": "保持聯絡",
"subtitle": "我們很樂意收到你的訊息。無論你想提出的是問題、回饋或功能請求,都請隨時聯繫我們。",
"email": "你可以直接透過電子郵件聯繫我們:"
"title": "聯絡我們",
"subtitle": "我們非常樂意收到您的訊息。無論您有任何問題、意見或功能需求,都歡迎隨時與我們聯絡。",
"email": "您也可以直接透過電子郵件與我們聯絡:"
},
"licensing": {
"title": "授權使用",
"subtitle": "選擇適合需求的產品授權。"
"title": "產品授權",
"subtitle": "選擇符合您需求的授權方式。"
},
"multiTool": {
"uploadPdfs": "上傳 PDF",
"upload": "上傳",
"addBlankPage": "添加空白頁面",
"addBlankPage": "新增空白頁面",
"edit": "編輯:",
"undo": "復原",
"redo": "取消復原",
"redo": "重做",
"reset": "重設",
"selection": "選取:",
"selectAll": "選取全部",
"deselectAll": "取消選取全部",
"selectAll": "全選",
"deselectAll": "取消全選",
"rotate": "旋轉:",
"rotateLeft": "左",
"rotateRight": "右",
"transform": "變換:",
"rotateLeft": "向左",
"rotateRight": "向右",
"transform": "轉換:",
"duplicate": "複製",
"split": "分割",
"clear": "清除:",
"delete": "刪除",
"download": "下載:",
"downloadSelected": "下載選取的項目",
"downloadSelected": "下載選取的頁面",
"exportPdf": "匯出 PDF",
"uploadPdfFiles": "選擇 PDF 或圖片檔案",
"dragAndDrop": "拖放 PDF 或圖片檔案至此處,或是點擊以選取",
"selectFiles": "選擇檔案",
"dropToAdd": "拖放 PDF 或圖片檔案以新增",
"convertingImages": "正在將圖片轉換為 PDF...",
"uploadPdfFiles": "選取 PDF 或圖片檔案",
"dragAndDrop": "拖曳 PDF 或圖片檔案至此,或點選以選取",
"selectFiles": "選取檔案",
"dropToAdd": "拖曳 PDF 或圖片檔案至此以新增",
"convertingImages": "正在將圖片轉換為 PDF…",
"failedToConvertImages": "圖片轉換為 PDF 失敗。",
"renderingPages": "渲染頁面...",
"renderingPages": "正在產生頁面預覽…",
"actions": {
"duplicatePage": "複製此頁",
"deletePage": "刪除此頁",
"insertPdf": "在此頁後插入 PDF",
"toggleSplit": "在此頁後切換分割"
"toggleSplit": "切換此頁後的分割標記"
},
"pleaseWait": "請稍後",
"pagesRendering": "正在渲染頁面。請稍後...",
"noPagesSelected": "未選擇頁面",
"selectOnePage": "請至少選擇一頁以開始下載。",
"noPages": "無頁面",
"pleaseWait": "請稍候",
"pagesRendering": "正在產生頁面預覽,請稍候…",
"noPagesSelected": "未選取任何頁面",
"selectOnePage": "請至少選取一個頁面以開始下載。",
"noPages": "沒有頁面",
"noPagesToExport": "無可匯出的頁面。",
"renderingTitle": "正在渲染頁面預覽",
"errorRendering": "無法渲染頁面縮圖",
"renderingTitle": "正在產生頁面預覽",
"errorRendering": "無法產生頁面預覽縮圖",
"error": "錯誤",
"failedToLoad": "載入失敗"
},
"simpleMode": {
"title": "PDF 工具",
"subtitle": "選擇一個工具開始使用"
"subtitle": "選取工具以開始使用"
},
"disabledTool": {
"title": "工具無法使用",
"heading": "此工具已被停用",
"message": "此工具在您的部署環境中無法使用。請聯絡管理員以取得更多資訊。",
"backHome": "返回首頁"
"backHome": "回到首頁"
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
## Main toolbar buttons (tooltips and alt text for images)
pdfjs-previous-button =
@@ -11,17 +10,21 @@ pdfjs-previous-button-label = 上一頁
pdfjs-next-button =
.title = 下一頁
pdfjs-next-button-label = 下一頁
# .title: Tooltip for the pageNumber input.
pdfjs-page-input =
.title = 第
# Variables:
# $pagesCount (Number) - the total number of pages in the document
# This string follows an input field with the number of the page currently displayed.
pdfjs-of-pages = 頁,共 { $pagesCount } 頁
# Variables:
# $pageNumber (Number) - the currently visible page
# $pagesCount (Number) - the total number of pages in the document
pdfjs-page-of-pages = (第 { $pageNumber } 頁,共 { $pagesCount } 頁)
pdfjs-zoom-out-button =
.title = 縮小
pdfjs-zoom-out-button-label = 縮小
@@ -42,26 +45,24 @@ pdfjs-print-button-label = 列印
pdfjs-save-button =
.title = 儲存
pdfjs-save-button-label = 儲存
# Used in Firefox for Android as a tooltip for the download button (“download” is a verb).
pdfjs-download-button =
.title = 下載
# Used in Firefox for Android as a label for the download button (“download” is a verb).
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-download-button-label = 下載
pdfjs-bookmark-button =
.title = 目前頁面(含目前檢視頁面的網址)
pdfjs-bookmark-button-label = 目前頁面
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = 在應用程式中開啟
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = 用程式開啟
## Secondary toolbar and context menu
pdfjs-tools-button =
.title = 工具
pdfjs-tools-button-label = 工具
pdfjs-first-page-button =
.title = 跳到第一頁
@@ -110,24 +111,29 @@ pdfjs-document-properties-button =
pdfjs-document-properties-button-label = 文件內容…
pdfjs-document-properties-file-name = 檔案名稱:
pdfjs-document-properties-file-size = 檔案大小:
# Variables:
# $size_kb (Number) - the PDF file size in kilobytes
# $size_b (Number) - the PDF file size in bytes
pdfjs-document-properties-kb = { $size_kb } KB({ $size_b } 位元組)
# Variables:
# $size_mb (Number) - the PDF file size in megabytes
# $size_b (Number) - the PDF file size in bytes
pdfjs-document-properties-mb = { $size_mb } MB({ $size_b } 位元組)
pdfjs-document-properties-title = 標題:
pdfjs-document-properties-author = 作者:
pdfjs-document-properties-subject = 主旨:
pdfjs-document-properties-keywords = 關鍵字:
pdfjs-document-properties-creation-date = 建立日期:
pdfjs-document-properties-modification-date = 修改日期:
# Variables:
# $date (Date) - the creation/modification date of the PDF file
# $time (Time) - the creation/modification time of the PDF file
pdfjs-document-properties-date-string = { $date } { $time }
pdfjs-document-properties-creator = 建立者:
pdfjs-document-properties-producer = PDF 產生器:
pdfjs-document-properties-version = PDF 版本:
@@ -164,9 +170,11 @@ pdfjs-document-properties-close-button = 關閉
## Print
pdfjs-print-progress-message = 正在準備列印文件…
# Variables:
# $progress (Number) - percent value
pdfjs-print-progress-percent = { $progress }%
pdfjs-print-progress-close-button = 取消
pdfjs-printing-not-supported = 警告: 此瀏覽器未完整支援列印功能。
pdfjs-printing-not-ready = 警告: 此 PDF 未完成下載以供列印。
@@ -204,6 +212,7 @@ pdfjs-additional-layers = 其他圖層
# $page (Number) - the page number
pdfjs-thumb-page-title =
.title = 第 { $page } 頁
# Variables:
# $page (Number) - the page number
pdfjs-thumb-page-canvas =
@@ -226,13 +235,24 @@ pdfjs-find-match-diacritics-checkbox-label = 符合變音符號
pdfjs-find-entire-word-checkbox-label = 符合整個字
pdfjs-find-reached-top = 已搜尋至文件頂端,自底端繼續搜尋
pdfjs-find-reached-bottom = 已搜尋至文件底端,自頂端繼續搜尋
# Variables:
# $current (Number) - the index of the currently active find result
# $total (Number) - the total number of matches in the document
pdfjs-find-match-count = 第 { $current } 筆符合,共符合 { $total } 筆
pdfjs-find-match-count =
{ $total ->
[one] 第 { $current } 筆符合,共符合 { $total } 筆
*[other] 第 { $current } 筆符合,共符合 { $total } 筆
}
# Variables:
# $limit (Number) - the maximum number of matches
pdfjs-find-match-count-limit = 符合超過 { $limit } 項
pdfjs-find-match-count-limit =
{ $limit ->
[one] 符合超過 { $limit } 項
*[other] 符合超過 { $limit } 項
}
pdfjs-find-not-found = 找不到指定文字
## Predefined zoom values
@@ -241,6 +261,7 @@ pdfjs-page-scale-width = 頁面寬度
pdfjs-page-scale-fit = 縮放至頁面大小
pdfjs-page-scale-auto = 自動縮放
pdfjs-page-scale-actual = 實際大小
# Variables:
# $scale (Number) - percent value for page scale
pdfjs-page-scale-percent = { $scale }%
@@ -266,6 +287,7 @@ pdfjs-rendering-error = 描繪頁面時發生錯誤。
# $date (Date) - the modification date of the annotation
# $time (Time) - the modification time of the annotation
pdfjs-annotation-date-string = { $date } { $time }
# .alt: This is used as a tooltip.
# Variables:
# $type (String) - an annotation type from a list defined in the PDF spec
@@ -296,8 +318,6 @@ pdfjs-editor-stamp-button-label = 新增或編輯圖片
pdfjs-editor-highlight-button =
.title = 強調
pdfjs-editor-highlight-button-label = 強調
pdfjs-highlight-floating-button =
.title = 強調
pdfjs-highlight-floating-button1 =
.title = 強調
.aria-label = 強調
@@ -329,6 +349,7 @@ pdfjs-editor-stamp-add-image-button-label = 新增圖片
pdfjs-editor-free-highlight-thickness-input = 線條粗細
pdfjs-editor-free-highlight-thickness-title =
.title = 更改強調文字以外的項目時的線條粗細
pdfjs-free-text =
.aria-label = 文本編輯器
pdfjs-free-text-default-content = 開始打字…
@@ -341,6 +362,7 @@ pdfjs-ink-canvas =
# Alternative text (alt text) helps when people can't see the image.
pdfjs-editor-alt-text-button-label = 替代文字
pdfjs-editor-alt-text-edit-button-label = 編輯替代文字
pdfjs-editor-alt-text-dialog-label = 挑選一種
pdfjs-editor-alt-text-dialog-description = 替代文字可協助盲人,或於圖片無法載入時提供說明。
@@ -351,6 +373,7 @@ pdfjs-editor-alt-text-mark-decorative-description = 這是裝飾性圖片,例
pdfjs-editor-alt-text-cancel-button = 取消
pdfjs-editor-alt-text-save-button = 儲存
pdfjs-editor-alt-text-decorative-tooltip = 已標示為裝飾性內容
# .placeholder: This is a placeholder for the alt text input area
pdfjs-editor-alt-text-textarea =
.placeholder = 例如:「有一位年輕男人坐在桌子前面吃飯」
@@ -371,6 +394,7 @@ pdfjs-editor-resizer-label-middle-left = 中間左方 — 調整大小
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = 強調色彩
pdfjs-editor-colorpicker-button =
.title = 更改色彩
pdfjs-editor-colorpicker-dropdown =
+1
View File
@@ -220,6 +220,7 @@
// Listen for messages from parent window
window.addEventListener('message', async (event) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'loadPDF') {
const { data } = event.data;
loadPDF(data);
+126 -31
View File
@@ -2,7 +2,6 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
## Main toolbar buttons (tooltips and alt text for images)
pdfjs-previous-button =
@@ -11,17 +10,21 @@ pdfjs-previous-button-label = 上一頁
pdfjs-next-button =
.title = 下一頁
pdfjs-next-button-label = 下一頁
# .title: Tooltip for the pageNumber input.
pdfjs-page-input =
.title = 第
# Variables:
# $pagesCount (Number) - the total number of pages in the document
# This string follows an input field with the number of the page currently displayed.
pdfjs-of-pages = 頁,共 { $pagesCount } 頁
# Variables:
# $pageNumber (Number) - the currently visible page
# $pagesCount (Number) - the total number of pages in the document
pdfjs-page-of-pages = (第 { $pageNumber } 頁,共 { $pagesCount } 頁)
pdfjs-zoom-out-button =
.title = 縮小
pdfjs-zoom-out-button-label = 縮小
@@ -42,12 +45,15 @@ pdfjs-print-button-label = 列印
pdfjs-save-button =
.title = 儲存
pdfjs-save-button-label = 儲存
# Used in Firefox for Android as a tooltip for the download button (“download” is a verb).
pdfjs-download-button =
.title = 下載
# Used in Firefox for Android as a label for the download button (“download” is a verb).
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-download-button-label = 下載
pdfjs-bookmark-button =
.title = 目前頁面(含目前檢視頁面的網址)
pdfjs-bookmark-button-label = 目前頁面
@@ -56,6 +62,7 @@ pdfjs-bookmark-button-label = 目前頁面
pdfjs-tools-button =
.title = 工具
pdfjs-tools-button-label = 工具
pdfjs-first-page-button =
.title = 跳到第一頁
@@ -104,23 +111,28 @@ pdfjs-document-properties-button =
pdfjs-document-properties-button-label = 文件內容…
pdfjs-document-properties-file-name = 檔案名稱:
pdfjs-document-properties-file-size = 檔案大小:
# Variables:
# $kb (Number) - the PDF file size in kilobytes
# $b (Number) - the PDF file size in bytes
pdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB({ $b } 位元組)
# Variables:
# $mb (Number) - the PDF file size in megabytes
# $b (Number) - the PDF file size in bytes
pdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB({ $b } 位元組)
pdfjs-document-properties-title = 標題:
pdfjs-document-properties-author = 作者:
pdfjs-document-properties-subject = 主旨:
pdfjs-document-properties-keywords = 關鍵字:
pdfjs-document-properties-creation-date = 建立日期:
pdfjs-document-properties-modification-date = 修改日期:
# Variables:
# $dateObj (Date) - the creation/modification date and time of the PDF file
pdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
pdfjs-document-properties-creator = 建立者:
pdfjs-document-properties-producer = PDF 產生器:
pdfjs-document-properties-version = PDF 版本:
@@ -157,9 +169,11 @@ pdfjs-document-properties-close-button = 關閉
## Print
pdfjs-print-progress-message = 正在準備列印文件…
# Variables:
# $progress (Number) - percent value
pdfjs-print-progress-percent = { $progress }%
pdfjs-print-progress-close-button = 取消
pdfjs-printing-not-supported = 警告:此瀏覽器未完整支援列印功能。
pdfjs-printing-not-ready = 警告:此 PDF 未完成下載以供列印。
@@ -197,6 +211,7 @@ pdfjs-additional-layers = 其他圖層
# $page (Number) - the page number
pdfjs-thumb-page-title =
.title = 第 { $page } 頁
# Variables:
# $page (Number) - the page number
pdfjs-thumb-page-canvas =
@@ -219,13 +234,24 @@ pdfjs-find-match-diacritics-checkbox-label = 符合變音符號
pdfjs-find-entire-word-checkbox-label = 符合整個字
pdfjs-find-reached-top = 已搜尋至文件頂端,自底端繼續搜尋
pdfjs-find-reached-bottom = 已搜尋至文件底端,自頂端繼續搜尋
# Variables:
# $current (Number) - the index of the currently active find result
# $total (Number) - the total number of matches in the document
pdfjs-find-match-count = 第 { $current } 筆符合,共符合 { $total } 筆
pdfjs-find-match-count =
{ $total ->
[one] { $current } / { $total } 筆相符
*[other] { $current } / { $total } 筆相符
}
# Variables:
# $limit (Number) - the maximum number of matches
pdfjs-find-match-count-limit = 符合超過 { $limit } 項
pdfjs-find-match-count-limit =
{ $limit ->
[one] 符合超過 { $limit } 項
*[other] 符合超過 { $limit } 項
}
pdfjs-find-not-found = 找不到指定文字
## Predefined zoom values
@@ -234,6 +260,7 @@ pdfjs-page-scale-width = 頁面寬度
pdfjs-page-scale-fit = 縮放至頁面大小
pdfjs-page-scale-auto = 自動縮放
pdfjs-page-scale-actual = 實際大小
# Variables:
# $scale (Number) - percent value for page scale
pdfjs-page-scale-percent = { $scale }%
@@ -255,6 +282,10 @@ pdfjs-rendering-error = 描繪頁面時發生錯誤。
## Annotations
# Variables:
# $dateObj (Date) - the modification date and time of the annotation
pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
# .alt: This is used as a tooltip.
# Variables:
# $type (String) - an annotation type from a list defined in the PDF spec
@@ -262,9 +293,6 @@ pdfjs-rendering-error = 描繪頁面時發生錯誤。
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
pdfjs-text-annotation-type =
.alt = [{ $type } 註解]
# Variables:
# $dateObj (Date) - the modification date and time of the annotation
pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
## Password
@@ -300,6 +328,10 @@ pdfjs-comment-floating-button =
.title = 註解
.aria-label = 註解
pdfjs-comment-floating-button-label = 註解
pdfjs-editor-comment-button =
.title = 註解
.aria-label = 註解
pdfjs-editor-comment-button-label = 註解
pdfjs-editor-signature-button =
.title = 加入簽章
pdfjs-editor-signature-button-label = 加入簽章
@@ -312,11 +344,13 @@ pdfjs-editor-highlight-editor =
# “Drawing” is a noun, the string is used on the editor for drawings.
pdfjs-editor-ink-editor =
.aria-label = 繪圖編輯器
# Used when a signature editor is selected/hovered.
# Variables:
# $description (String) - a string describing/labeling the signature.
pdfjs-editor-signature-editor1 =
.aria-description = 簽章編輯器:{ $description }
pdfjs-editor-stamp-editor =
.aria-label = 圖片編輯器
@@ -353,19 +387,43 @@ pdfjs-editor-add-signature-container =
pdfjs-editor-signature-add-signature-button =
.title = 新增簽章
pdfjs-editor-signature-add-signature-button-label = 新增簽章
# Used on the button to use an already saved signature.
# Variables:
# $description (String) - a string describing/labeling the signature.
pdfjs-editor-add-saved-signature-button =
.title = 已儲存簽章:{ $description }
# .default-content is used as a placeholder in an empty text editor.
pdfjs-free-text2 =
.aria-label = 文字編輯器
.default-content = 請打字…
# Used to show how many comments are present in the pdf file.
# Variables:
# $count (Number) - the number of comments.
pdfjs-editor-comments-sidebar-title =
{ $count ->
[one] 註解
*[other] 註解
}
pdfjs-editor-comments-sidebar-close-button =
.title = 關閉側邊欄
.aria-label = 關閉側邊欄
pdfjs-editor-comments-sidebar-close-button-label = 關閉側邊欄
# Instructional copy to add a comment by selecting text or an annotations.
pdfjs-editor-comments-sidebar-no-comments1 = 看到值得注意的內容嗎?將其標註並留下註解。
pdfjs-editor-comments-sidebar-no-comments-link = 進一步了解
## Alt-text dialog
# Alternative text (alt text) helps when people can't see the image.
pdfjs-editor-alt-text-button =
.aria-label = 替代文字
pdfjs-editor-alt-text-button-label = 替代文字
pdfjs-editor-alt-text-edit-button =
.aria-label = 編輯替代文字
pdfjs-editor-alt-text-dialog-label = 挑選一種
@@ -377,12 +435,10 @@ pdfjs-editor-alt-text-mark-decorative-description = 這是裝飾性圖片,例
pdfjs-editor-alt-text-cancel-button = 取消
pdfjs-editor-alt-text-save-button = 儲存
pdfjs-editor-alt-text-decorative-tooltip = 已標示為裝飾性內容
# .placeholder: This is a placeholder for the alt text input area
pdfjs-editor-alt-text-textarea =
.placeholder = 例如:「有一位年輕男人坐在桌子前面吃飯」
# Alternative text (alt text) helps when people can't see the image.
pdfjs-editor-alt-text-button =
.aria-label = 替代文字
## Editor resizers
## This is used in an aria label to help to understand the role of the resizer.
@@ -408,6 +464,7 @@ pdfjs-editor-resizer-middle-left =
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = 強調色彩
pdfjs-editor-colorpicker-button =
.title = 更改色彩
pdfjs-editor-colorpicker-dropdown =
@@ -435,37 +492,47 @@ pdfjs-editor-highlight-show-all-button =
# Modal header positioned above a text box where users can edit the alt text.
pdfjs-editor-new-alt-text-dialog-edit-label = 編輯替代文字(圖片描述)
# Modal header positioned above a text box where users can add the alt text.
pdfjs-editor-new-alt-text-dialog-add-label = 新增替代文字(圖片描述)
pdfjs-editor-new-alt-text-textarea =
.placeholder = 在此寫下您的描述文字…
# This text refers to the alt text box above this description. It offers a definition of alt text.
pdfjs-editor-new-alt-text-description = 為看不到圖片的讀者,或圖片無法載入時顯示的簡短描述。
# This is a required legal disclaimer that refers to the automatically created text inside the alt text box above this text. It disappears if the text is edited by a human.
pdfjs-editor-new-alt-text-disclaimer1 = 此替代文字是自動產生的,可能不夠精確。
pdfjs-editor-new-alt-text-disclaimer-learn-more-url = 更多資訊
pdfjs-editor-new-alt-text-create-automatically-button-label = 自動產生替代文字
pdfjs-editor-new-alt-text-not-now-button = 暫時不要
pdfjs-editor-new-alt-text-error-title = 無法自動產生替代文字
pdfjs-editor-new-alt-text-error-description = 請自行填寫替代文字,或稍後再試一次。
pdfjs-editor-new-alt-text-error-close-button = 關閉
# Variables:
# $totalSize (Number) - the total size (in MB) of the AI model.
# $downloadedSize (Number) - the downloaded size (in MB) of the AI model.
pdfjs-editor-new-alt-text-ai-model-downloading-progress = 正在下載替代文字 AI 模型({ $downloadedSize } / { $totalSize } MB)
.aria-valuetext = 正在下載替代文字 AI 模型({ $downloadedSize } / { $totalSize } MB)
# This is a button that users can click to edit the alt text they have already added.
pdfjs-editor-new-alt-text-added-button =
.aria-label = 已新增替代文字
pdfjs-editor-new-alt-text-added-button-label = 已新增替代文字
# This is a button that users can click to open the alt text editor and add alt text when it is not present.
pdfjs-editor-new-alt-text-missing-button =
.aria-label = 缺少替代文字
pdfjs-editor-new-alt-text-missing-button-label = 缺少替代文字
# This is a button that opens up the alt text modal where users should review the alt text that was automatically generated.
pdfjs-editor-new-alt-text-to-review-button =
.aria-label = 確認替代文字
pdfjs-editor-new-alt-text-to-review-button-label = 確認替代文字
# "Created automatically" is a prefix that will be added to the beginning of any alt text that has been automatically generated. After the colon, the user will see/hear the actual alt text description. If the alt text has been edited by a human, this prefix will not appear.
# Variables:
# $generatedAltText (String) - the generated alt-text.
@@ -476,17 +543,21 @@ pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = 自動產生:{
pdfjs-image-alt-text-settings-button =
.title = 圖片替代文字設定
pdfjs-image-alt-text-settings-button-label = 圖片替代文字設定
pdfjs-editor-alt-text-settings-dialog-label = 圖片替代文字設定
pdfjs-editor-alt-text-settings-automatic-title = 自動化替代文字
pdfjs-editor-alt-text-settings-create-model-button-label = 自動產生替代文字
pdfjs-editor-alt-text-settings-create-model-description = 為您建議圖片描述,幫助看不到圖片的讀者,或於圖片無法載入時顯示。
# Variables:
# $totalSize (Number) - the total size (in MB) of the AI model.
pdfjs-editor-alt-text-settings-download-model-label = 替代文字 AI 模型({ $totalSize } MB)
pdfjs-editor-alt-text-settings-ai-model-description = 在您的本機裝置上運作,以確保您的資料隱私。必須下載此模型才可以自動產生替代文字。
pdfjs-editor-alt-text-settings-delete-model-button = 刪除
pdfjs-editor-alt-text-settings-download-model-button = 下載
pdfjs-editor-alt-text-settings-downloading-model-button = 下載中…
pdfjs-editor-alt-text-settings-editor-title = 替代文字編輯器
pdfjs-editor-alt-text-settings-show-dialog-button-label = 新增圖片後立即顯示替代文字編輯器
pdfjs-editor-alt-text-settings-show-dialog-description = 幫助您確保所有圖片都有替代文字。
@@ -509,7 +580,12 @@ pdfjs-editor-undo-bar-message-stamp = 已移除圖片
pdfjs-editor-undo-bar-message-signature = 已移除簽章
# Variables:
# $count (Number) - the number of removed annotations.
pdfjs-editor-undo-bar-message-multiple = 已移除 { $count } 筆註解
pdfjs-editor-undo-bar-message-multiple =
{ $count ->
[one] { $count } 個註解已移除
*[other] { $count } 個註解已移除
}
pdfjs-editor-undo-bar-undo-button =
.title = 還原
pdfjs-editor-undo-bar-undo-button-label = 還原
@@ -540,10 +616,12 @@ pdfjs-editor-add-signature-type-input =
.placeholder = 輸入簽章
pdfjs-editor-add-signature-draw-placeholder = 手繪簽章
pdfjs-editor-add-signature-draw-thickness-range-label = 線條粗細
# Variables:
# $thickness (Number) - the thickness (in pixels) of the line used to draw a signature.
pdfjs-editor-add-signature-draw-thickness-range =
.title = 繪製時的線條粗細:{ $thickness }
pdfjs-editor-add-signature-image-placeholder = 將檔案拖曳到此處即可上傳
pdfjs-editor-add-signature-image-browse-link =
{ PLATFORM() ->
@@ -557,6 +635,8 @@ pdfjs-editor-add-signature-description-label = 描述(替代文字)
pdfjs-editor-add-signature-description-input =
.title = 描述(替代文字)
pdfjs-editor-add-signature-description-default-when-drawing = 簽章
pdfjs-editor-add-signature-clear-button-label = 清除簽章
pdfjs-editor-add-signature-clear-button =
.title = 清除簽章
@@ -572,27 +652,6 @@ pdfjs-editor-add-signature-error-close-button = 關閉
pdfjs-editor-add-signature-cancel-button = 取消
pdfjs-editor-add-signature-add-button = 新增
pdfjs-editor-edit-signature-update-button = 更新
## Edit a comment dialog
pdfjs-editor-edit-comment-actions-button-label = 動作
pdfjs-editor-edit-comment-actions-button =
.title = 動作
pdfjs-editor-edit-comment-close-button-label = 關閉
pdfjs-editor-edit-comment-close-button =
.title = 關閉
pdfjs-editor-edit-comment-actions-edit-button-label = 編輯
pdfjs-editor-edit-comment-actions-delete-button-label = 刪除
pdfjs-editor-edit-comment-manager-text-input =
.placeholder = 輸入您的註解
pdfjs-editor-edit-comment-manager-cancel-button = 取消
pdfjs-editor-edit-comment-manager-save-button = 儲存
## Edit a comment button in the editor toolbar
pdfjs-editor-edit-comment-button =
.title = 編輯註解
## Main menu for adding/removing signatures
@@ -607,3 +666,39 @@ pdfjs-editor-add-signature-edit-button-label = 編輯描述
## Edit signature description dialog
pdfjs-editor-edit-signature-dialog-title = 編輯描述
## Dialog buttons
pdfjs-editor-edit-signature-update-button = 更新
## Comment popup
pdfjs-show-comment-button =
.title = 顯示註解
pdfjs-editor-edit-comment-popup-button-label = 編輯註解
pdfjs-editor-edit-comment-popup-button =
.title = 編輯註解
pdfjs-editor-delete-comment-popup-button-label = 移除註解
pdfjs-editor-delete-comment-popup-button =
.title = 移除註解
## Edit a comment dialog
# An existing comment is edited
pdfjs-editor-edit-comment-dialog-title-when-editing = 編輯註解
pdfjs-editor-edit-comment-dialog-save-button-when-editing = 更新
# No existing comment
pdfjs-editor-edit-comment-dialog-title-when-adding = 新增註解
pdfjs-editor-edit-comment-dialog-save-button-when-adding = 新增
pdfjs-editor-edit-comment-dialog-text-input =
.placeholder = 開始輸入…
pdfjs-editor-edit-comment-dialog-cancel-button = 取消
## Edit a comment button in the editor toolbar
pdfjs-editor-edit-comment-button =
.title = 編輯註解
+1
View File
@@ -239,6 +239,7 @@
// Listen for messages from parent window
window.addEventListener('message', async (event) => {
if (event.origin !== window.location.origin) return;
console.log('Sign viewer received message:', event.data.type);
if (event.data.type === 'loadPDF') {
+4 -2
View File
@@ -201,7 +201,9 @@ async function findCachedFile(fileName, requestUrl) {
const requests = await cache.keys();
for (const req of requests) {
const reqUrl = new URL(req.url);
if (reqUrl.pathname.endsWith(fileName)) {
const trustedOrigin =
reqUrl.origin === location.origin || trustedCdnOrigins.has(reqUrl.origin);
if (trustedOrigin && reqUrl.pathname.split('/').pop() === fileName) {
const response = await cache.match(req);
if (response) {
const clone = response.clone();
@@ -221,7 +223,7 @@ async function removeDuplicateCache(cache, fileName, isCDN) {
for (const req of requests) {
const reqUrl = new URL(req.url);
if (reqUrl.pathname.endsWith(fileName)) {
if (reqUrl.pathname.split('/').pop() === fileName) {
const reqIsCDN = trustedCdnOrigins.has(reqUrl.origin);
if (reqIsCDN !== isCDN) {
await cache.delete(req);
+121
View File
@@ -0,0 +1,121 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SCAN_DIRS = ['src/js', 'bentopdf-pymupdf-wasm/src', 'cloudflare'];
const SKIP = ['node_modules', 'dist', '.git', 'coverage', 'tests', 'test'];
const findings = [];
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (
SKIP.some(
(s) =>
full.includes(`${path.sep}${s}${path.sep}`) ||
full.endsWith(`${path.sep}${s}`)
)
) {
continue;
}
if (e.isDirectory()) walk(full);
else if (/\.(ts|js|mjs)$/.test(e.name) && !/\.test\.|\.d\.ts$/.test(e.name))
checkFile(full);
}
}
function checkFile(file) {
const rel = path.relative(ROOT, file);
const src = fs.readFileSync(file, 'utf-8');
const lines = src.split('\n');
// Rule 1: no raw string interpolation into a Python/eval template.
const codeSink = /(runPython|\.eval)\s*\(\s*`/;
for (let i = 0; i < lines.length; i++) {
if (codeSink.test(lines[i])) {
for (let j = i; j < Math.min(i + 60, lines.length); j++) {
const interps = lines[j].match(/\$\{[^}]+\}/g) || [];
for (const it of interps) {
const danger =
/^\$\{\s*(text|name|uri|url|format|password|passwd|title|label|filename|content|search|searchText|query|font|fontname|intent|usage|replaceText)\s*(\}|\.|\))/.test(
it
);
const safe = /pyStr|JSON\.stringify|escapeHtml/.test(it);
if (danger && !safe) {
findings.push({
rule: 'code-injection',
file: rel,
line: j + 1,
detail: `Unescaped user-string interpolation ${it} into a Python/eval template — pass it via pyStr()/JSON.stringify()/globals.set().`,
});
}
}
if (lines[j].includes('`)') || lines[j].trim().endsWith('`);')) break;
}
}
}
// Rule 2: string mutation applied AFTER a sanitize call (sanitize-then-mutate).
const sanitizeLine = lines.findIndex((l) =>
/DOMPurify\.sanitize|sanitizeEmailHtml|sanitize\(/.test(l)
);
if (sanitizeLine !== -1) {
for (
let j = sanitizeLine + 1;
j < Math.min(sanitizeLine + 40, lines.length);
j++
) {
if (
/\.replace\(|innerHTML\s*=|\+=\s*`/.test(lines[j]) &&
!/escapeHtml|escapeHTML|DOMPurify/.test(lines[j])
) {
findings.push({
rule: 'sanitize-then-mutate',
file: rel,
line: j + 1,
detail: `Possible mutation of sanitized HTML. Escape/validate at the final sink, or re-sanitize after mutation.`,
});
break;
}
}
}
}
for (const d of SCAN_DIRS) walk(path.join(ROOT, d));
const blocking = findings.filter((f) => f.rule === 'code-injection');
const warnings = findings.filter((f) => f.rule !== 'code-injection');
for (const f of warnings) {
console.warn(` ⚠ [${f.rule}] ${f.file}:${f.line}\n ${f.detail}`);
}
if (warnings.length) {
console.warn(
`\n${warnings.length} sanitize-then-mutate warning(s) — review each: escape/validate at the final sink.\n`
);
}
if (blocking.length === 0) {
console.log('✅ security-patterns-check: no code-injection patterns found');
process.exit(0);
}
console.error(
`\n❌ security-patterns-check found ${blocking.length} code-injection issue(s):\n`
);
for (const f of blocking) {
console.error(` [${f.rule}] ${f.file}:${f.line}\n ${f.detail}\n`);
}
console.error(
'This pattern caused a prior CVE (pymupdf runPython injection). Pass values via pyStr()/JSON.stringify()/globals.set().\n'
);
process.exit(1);
+16
View File
@@ -495,6 +495,22 @@
"created_at": "2026-06-22T19:17:49Z",
"repoId": 1074785178,
"pullRequestNo": 761
},
{
"name": "cevdetta",
"id": 76725496,
"comment_id": 4935679969,
"created_at": "2026-07-10T13:12:50Z",
"repoId": 1074785178,
"pullRequestNo": 783
},
{
"name": "PeterDaveHello",
"id": 3691490,
"comment_id": 4961209505,
"created_at": "2026-07-13T18:21:14Z",
"repoId": 1074785178,
"pullRequestNo": 785
}
]
}
+23
View File
@@ -12,3 +12,26 @@ export const TIMESTAMP_TSA_PRESETS: TimestampTsaPreset[] = [
{ label: 'FreeTSA', url: 'https://freetsa.org/tsr' },
{ label: 'MeSign', url: 'http://tsa.mesign.com' },
];
const ALLOWED_TSA_HOSTS: ReadonlySet<string> = new Set(
TIMESTAMP_TSA_PRESETS.map((preset) => new URL(preset.url).hostname)
);
export function isValidTsaRequestUrl(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
export function isAllowedTsaUrl(value: unknown): value is string {
if (!isValidTsaRequestUrl(value)) {
return false;
}
return ALLOWED_TSA_HOSTS.has(new URL(value).hostname);
}
+42 -6
View File
@@ -273,7 +273,7 @@ placeholder="${escapeHTML(field.placeholder || '')}" />
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-600">Page</label>
<input type="number" id="modal-dest-page" min="1" max="${field.maxPages || 1}" value="${defaultValues.destPage || field.page || 1}"
<input type="number" id="modal-dest-page" min="1" max="${escapeHTML(String(field.maxPages || 1))}" value="${escapeHTML(String(defaultValues.destPage || field.page || 1))}"
class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" step="1" />
</div>
<div>
@@ -1688,10 +1688,13 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
const titleDiv = document.createElement('div');
titleDiv.className = 'flex-1 min-w-0 cursor-pointer';
const customColorStyle =
node.color && typeof node.color === 'string' && node.color.startsWith('#')
? `style="color: ${node.color}"`
const safeCustomColor =
typeof node.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(node.color)
? node.color
: '';
const customColorStyle = safeCustomColor
? `style="color: ${safeCustomColor}"`
: '';
const hasDestination =
node.destX !== null || node.destY !== null || node.zoom !== null;
const destinationIcon = hasDestination
@@ -1700,7 +1703,7 @@ function createNodeElement(node: BookmarkNode, level = 0): HTMLLIElement {
titleDiv.innerHTML = `
<span class="text-sm block ${styleClass} ${textColorClass}" ${customColorStyle}>${escapeHTML(node.title)}${destinationIcon}</span>
<span class="text-xs text-gray-500">Page ${node.page}</span>
<span class="text-xs text-gray-500">Page ${escapeHTML(String(node.page))}</span>
`;
titleDiv.addEventListener('click', async () => {
@@ -1948,7 +1951,14 @@ exportCsvBtn?.addEventListener('click', () => {
const csv =
'title,page,level\n' +
flat
.map((b) => `"${b.title.replace(/"/g, '""')}",${b.page},${b.level}`)
.map((b) => {
const first = b.title.charAt(0);
const t =
/[=+\-@]/.test(first) || first === '\t' || first === '\r'
? `'${b.title}`
: b.title;
return `"${t.replace(/"/g, '""')}",${b.page},${b.level}`;
})
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
@@ -2004,8 +2014,34 @@ jsonImportHidden?.addEventListener('change', async (e: Event) => {
const imported = JSON.parse(text) as BookmarkTree;
function cleanImportedTree(nodes: BookmarkNode[]): void {
if (!nodes) return;
const validStyles = ['bold', 'italic', 'bold-italic'];
for (const node of nodes) {
if (node.title) node.title = cleanTitle(node.title);
node.page = Number.isFinite(Number(node.page))
? Math.max(1, Math.trunc(Number(node.page)))
: 1;
if (
typeof node.color === 'string' &&
node.color.startsWith('#') &&
!/^#[0-9a-fA-F]{3,8}$/.test(node.color)
) {
node.color = '';
}
if (!validStyles.includes(node.style as string)) {
node.style = null;
}
node.destX =
node.destX != null && Number.isFinite(Number(node.destX))
? Number(node.destX)
: null;
node.destY =
node.destY != null && Number.isFinite(Number(node.destY))
? Number(node.destY)
: null;
node.zoom =
node.zoom != null && /^-?\d*\.?\d+$/.test(String(node.zoom))
? String(node.zoom)
: null;
if (node.children) cleanImportedTree(node.children);
}
}
+18
View File
@@ -134,9 +134,27 @@ async function convertCbzToPdf(file: File): Promise<Blob> {
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
);
const MAX_CBZ_PAGES = 2000;
const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
const MAX_TOTAL_BYTES = 500 * 1024 * 1024;
if (imageFiles.length > MAX_CBZ_PAGES) {
throw new Error(`Archive has too many images (max ${MAX_CBZ_PAGES}).`);
}
let totalBytes = 0;
for (const filename of imageFiles) {
const zipEntry = zip.files[filename];
const declared = (
zipEntry as unknown as { _data?: { uncompressedSize?: number } }
)._data?.uncompressedSize;
if (typeof declared === 'number' && declared > MAX_ENTRY_BYTES) {
throw new Error('Archive contains an oversized image entry.');
}
const imageData = await zipEntry.async('arraybuffer');
totalBytes += imageData.byteLength;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error('Archive is too large when decompressed.');
}
const dataArray = new Uint8Array(imageData);
const actualFormat = detectImageFormat(dataArray);
+7
View File
@@ -1,6 +1,7 @@
import { PdfSigner, type SignOption } from 'zgapdfsigner';
import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types';
import { isValidTsaRequestUrl } from '../config/timestamp-tsa.js';
export function parsePfxFile(
pfxBytes: ArrayBuffer,
@@ -349,6 +350,12 @@ export async function timestampPdf(
pdfBytes: Uint8Array,
tsaUrl: string
): Promise<Uint8Array> {
if (!isValidTsaRequestUrl(tsaUrl)) {
throw new Error(
`Invalid TSA URL. The timestamp authority must be a valid http:// or https:// URL.`
);
}
const pageIsHttps =
typeof window !== 'undefined' && window.location?.protocol === 'https:';
const tsaIsHttp = /^http:\/\//i.test(tsaUrl);
+7 -1
View File
@@ -217,7 +217,13 @@ function processInlineImages(
const att = cidMap.get(cid);
if (att && att.content) {
const base64 = uint8ArrayToBase64(att.content);
return `src="data:${att.contentType};base64,${base64}"`;
const safeType =
/^[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*$/.test(
att.contentType
)
? att.contentType
: 'application/octet-stream';
return `src="data:${safeType};base64,${base64}"`;
}
return match;
});
+20 -1
View File
@@ -85,8 +85,27 @@ worker.onmessage = function (e) {
const zip = new JSZip();
let totalSize = 0;
const usedNames = new Set<string>();
for (const attachment of attachments) {
zip.file(attachment.name, new Uint8Array(attachment.data));
const base =
(attachment.name || 'attachment')
.split(/[/\\]/)
.pop()
?.replace(/^\.+/, '')
.replace(/\p{Cc}/gu, '')
.trim() || 'attachment';
let safeName = base;
let counter = 1;
while (usedNames.has(safeName)) {
const dot = base.lastIndexOf('.');
safeName =
dot > 0
? `${base.slice(0, dot)}_${counter}${base.slice(dot)}`
: `${base}_${counter}`;
counter++;
}
usedNames.add(safeName);
zip.file(safeName, new Uint8Array(attachment.data));
totalSize += attachment.data.byteLength;
}
+7 -19
View File
@@ -90,9 +90,6 @@ const pdfFileInput = document.getElementById(
'pdfFileInput'
) as HTMLInputElement;
const blankPdfBtn = document.getElementById('blankPdfBtn') as HTMLButtonElement;
const pdfUploadInput = document.getElementById(
'pdfUploadInput'
) as HTMLInputElement;
const pageSizeSelector = document.getElementById(
'pageSizeSelector'
) as HTMLDivElement;
@@ -117,9 +114,6 @@ const nextPageBtn = document.getElementById('nextPageBtn') as HTMLButtonElement;
const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement;
const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement;
const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement;
const backToToolsBtn = document.getElementById(
'back-to-tools'
) as HTMLButtonElement | null;
const gotoPageInput = document.getElementById(
'gotoPageInput'
) as HTMLInputElement;
@@ -794,14 +788,12 @@ function renderField(field: FormField): void {
});
// Touch events for moving fields
let touchMoveStarted = false;
fieldWrapper.addEventListener(
'touchstart',
(e) => {
if ((e.target as HTMLElement).classList.contains('resize-handle')) {
return;
}
touchMoveStarted = false;
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
offsetX = touch.clientX - rect.left - field.x;
@@ -813,7 +805,6 @@ function renderField(field: FormField): void {
fieldWrapper.addEventListener('touchmove', (e) => {
e.preventDefault();
touchMoveStarted = true;
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
let newX = touch.clientX - rect.left - offsetX;
@@ -829,10 +820,6 @@ function renderField(field: FormField): void {
field.y = newY;
});
fieldWrapper.addEventListener('touchend', () => {
touchMoveStarted = false;
});
// Add resize handles to the container - hidden by default
const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'];
handles.forEach((pos) => {
@@ -2381,7 +2368,6 @@ downloadBtn.addEventListener('click', async () => {
else if (field.options && field.options.length > 0)
dropdown.select(field.options[0]);
const rgbColor = hexToRgb(field.textColor);
dropdown.acroField.setFontSize(field.fontSize);
dropdown.acroField.setDefaultAppearance(
`0 0 0 rg /Helv ${field.fontSize} Tf`
@@ -2417,7 +2403,6 @@ downloadBtn.addEventListener('click', async () => {
else if (field.options && field.options.length > 0)
optionList.select(field.options[0]);
const rgbColor = hexToRgb(field.textColor);
optionList.acroField.setFontSize(field.fontSize);
optionList.acroField.setDefaultAppearance(
`0 0 0 rg /Helv ${field.fontSize} Tf`
@@ -2547,7 +2532,10 @@ downloadBtn.addEventListener('click', async () => {
});
// Add Date Format and Keystroke Actions to the FIELD (not widget)
const dateFormat = field.dateFormat || 'mm/dd/yyyy';
const dateFormat = (field.dateFormat || 'mm/dd/yyyy').replace(
/[^a-zA-Z0-9/:.,\- ]/g,
''
);
const formatAction = pdfDoc.context.obj({
Type: 'Action',
@@ -2757,9 +2745,9 @@ downloadBtn.addEventListener('click', async () => {
});
// Back to tools button
const backToToolsBtns = document.querySelectorAll(
const backToToolsBtns = document.querySelectorAll<HTMLButtonElement>(
'[id^="back-to-tools"]'
) as NodeListOf<HTMLButtonElement>;
);
backToToolsBtns.forEach((btn) => {
btn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
@@ -3129,7 +3117,7 @@ let modalCloseCallback: (() => void) | null = null;
function showModal(
title: string,
message: string,
type: 'error' | 'warning' | 'info' = 'error',
_type: 'error' | 'warning' | 'info' = 'error',
onClose?: () => void,
buttonText: string = 'Close'
) {
+12 -3
View File
@@ -322,7 +322,7 @@ function displayResults(): void {
'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(
(r) => r.isValid && !r.isExpired
(r) => r.isValid && !r.isExpired && r.isTrusted
).length;
const trustVerified = state.trustedCert
? state.results.filter((r) => r.isTrusted).length
@@ -373,6 +373,13 @@ function createSignatureCard(
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Unverified — Unsupported Signature Algorithm';
} else if (
result.cryptoVerified === true &&
result.coverageStatus === 'partial'
) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid — Modified After Signing (partial coverage)';
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
@@ -389,10 +396,12 @@ function createSignatureCard(
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
} else if (!result.isTrusted) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
statusText = result.isSelfSigned
? 'Self-Signed — Signer Identity Not Verified'
: 'Signature Intact — Signer Identity Not Verified';
}
const formatDate = (date: Date) => {
+20 -15
View File
@@ -145,25 +145,30 @@ export async function validateSignature(
result.isSelfSigned = signerCert.isIssuer(signerCert);
// Check trust against provided certificate
// Check trust against provided certificate (cryptographic, not name/serial match)
if (trustedCert) {
try {
const isTrustedIssuer = trustedCert.isIssuer(signerCert);
const isSameCert = signerCert.serialNumber === trustedCert.serialNumber;
const trustedPem = forge.pki.certificateToPem(trustedCert);
const isSameCert =
forge.pki.certificateToPem(signerCert) === trustedPem;
let chainTrusted = false;
for (const cert of p7.certificates) {
if (
trustedCert.isIssuer(cert) ||
(cert as forge.pki.Certificate).serialNumber ===
trustedCert.serialNumber
) {
chainTrusted = true;
break;
let cryptoIssued = false;
const chain = [
signerCert,
...(p7.certificates as forge.pki.Certificate[]),
];
for (const cert of chain) {
try {
if (trustedCert.verify(cert)) {
cryptoIssued = true;
break;
}
} catch {
continue;
}
}
result.isTrusted = isTrustedIssuer || isSameCert || chainTrusted;
result.isTrusted = isSameCert || cryptoIssued;
} catch {
result.isTrusted = false;
}
@@ -223,7 +228,7 @@ export async function validateSignature(
}
if (signature.byteRange && signature.byteRange.length === 4) {
const [, len1, start2, len2] = signature.byteRange;
const [, , start2, len2] = signature.byteRange;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
@@ -252,7 +257,7 @@ export async function validateSignature(
result.isValid =
verification.status === 'verified' &&
result.coverageStatus !== 'unknown' &&
result.coverageStatus === 'full' &&
!result.usesInsecureDigest;
} catch (e) {
result.errorMessage =
+10 -1
View File
@@ -331,7 +331,16 @@ export function sanitizeEmailHtml(html: string): string {
(match, encodedUrl) => {
try {
const decodedUrl = decodeURIComponent(encodedUrl);
return `href="${decodedUrl}"`;
let parsed: URL;
try {
parsed = new URL(decodedUrl);
} catch {
return match;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return match;
}
return `href="${escapeHtml(decodedUrl)}"`;
} catch {
return match;
}
+15
View File
@@ -28,4 +28,19 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
description: this.description,
};
}
sanitizeControlValue(key: string, value: unknown): unknown {
const control = this.controls[key];
if (!control || typeof control !== 'object' || !('value' in control)) {
return value;
}
const current = (control as { value: unknown }).value;
if (current === null || current === undefined) {
return value;
}
if (typeof value !== typeof current) {
return current;
}
return value;
}
}
+3 -1
View File
@@ -67,7 +67,9 @@ export class RedactNode extends BaseWorkflowNode {
const pdfInputs = requirePdfInput(inputs, 'Redact');
const mode = this.getText('redactMode', 'text');
const searchText = this.getText('text', '');
const searchText = this.getText('text', '')
.replace(/\\/g, '')
.replace(/\p{Cc}/gu, '');
const fill = hexToRgb(this.getText('fillColor', '#000000'));
if (mode === 'text' && !searchText) {
+12 -1
View File
@@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { wfError } from '../errors';
import { TIMESTAMP_TSA_PRESETS } from '../../config/timestamp-tsa.js';
import {
TIMESTAMP_TSA_PRESETS,
isAllowedTsaUrl,
} from '../../config/timestamp-tsa.js';
import { timestampPdf } from '../../logic/digital-sign-pdf.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
@@ -32,6 +35,14 @@ export class TimestampNode extends BaseWorkflowNode {
return TIMESTAMP_TSA_PRESETS;
}
sanitizeControlValue(key: string, value: unknown): unknown {
const sanitized = super.sanitizeControlValue(key, value);
if (key === 'tsaUrl' && !isAllowedTsaUrl(sanitized)) {
return TIMESTAMP_TSA_PRESETS[0].url;
}
return sanitized;
}
async data(
inputs: Record<string, SocketData[]>
): Promise<Record<string, SocketData>> {
+4 -1
View File
@@ -98,7 +98,10 @@ async function deserializeWorkflow(
for (const [key, value] of Object.entries(serializedNode.controls || {})) {
const control = node.controls[key];
if (control && 'value' in control) {
(control as { value: unknown }).value = value;
(control as { value: unknown }).value = node.sanitizeControlValue(
key,
value
);
}
}
+51
View File
@@ -44,6 +44,20 @@ vi.mock('@/js/workflow/nodes/base-node', () => ({
addOutput() {}
addControl() {}
controls: Record<string, unknown> = {};
sanitizeControlValue(key: string, value: unknown): unknown {
const control = this.controls[key];
if (!control || typeof control !== 'object' || !('value' in control)) {
return value;
}
const current = (control as { value: unknown }).value;
if (current === null || current === undefined) {
return value;
}
if (typeof value !== typeof current) {
return current;
}
return value;
}
},
}));
@@ -174,3 +188,40 @@ describe('TimestampNode', () => {
expect(caught?.cause).toBe(originalError);
});
});
describe('TimestampNode.sanitizeControlValue (workflow import hardening)', () => {
it('keeps a preset tsaUrl from an imported workflow', () => {
const node = new TimestampNode();
expect(
node.sanitizeControlValue('tsaUrl', TIMESTAMP_TSA_PRESETS[0].url)
).toBe(TIMESTAMP_TSA_PRESETS[0].url);
expect(node.sanitizeControlValue('tsaUrl', 'https://freetsa.org/tsr')).toBe(
'https://freetsa.org/tsr'
);
});
it('resets an untrusted imported tsaUrl to the default preset', () => {
const node = new TimestampNode();
expect(
node.sanitizeControlValue(
'tsaUrl',
'https://attacker.example.com/steal-tsr'
)
).toBe(TIMESTAMP_TSA_PRESETS[0].url);
});
it('resets non-string tsaUrl values injected via import', () => {
const node = new TimestampNode();
expect(node.sanitizeControlValue('tsaUrl', { evil: true })).toBe(
TIMESTAMP_TSA_PRESETS[0].url
);
expect(node.sanitizeControlValue('tsaUrl', null)).toBe(
TIMESTAMP_TSA_PRESETS[0].url
);
});
it('does not constrain non-tsaUrl controls', () => {
const node = new TimestampNode();
expect(node.sanitizeControlValue('other', 'anything')).toBe('anything');
});
});
+56
View File
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import {
TIMESTAMP_TSA_PRESETS,
isAllowedTsaUrl,
isValidTsaRequestUrl,
type TimestampTsaPreset,
} from '@/js/config/timestamp-tsa';
@@ -47,3 +49,57 @@ describe('Timestamp TSA Presets', () => {
expect(preset).toHaveProperty('url');
});
});
describe('isAllowedTsaUrl', () => {
it('accepts every built-in preset URL', () => {
for (const preset of TIMESTAMP_TSA_PRESETS) {
expect(isAllowedTsaUrl(preset.url)).toBe(true);
}
});
it('rejects arbitrary attacker-controlled hosts', () => {
expect(isAllowedTsaUrl('https://attacker.example.com/steal-tsr')).toBe(
false
);
expect(isAllowedTsaUrl('http://attacker.example.com')).toBe(false);
});
it('rejects look-alike hosts that merely contain a preset host', () => {
expect(isAllowedTsaUrl('https://timestamp.digicert.com.attacker.com')).toBe(
false
);
expect(isAllowedTsaUrl('https://freetsa.org.attacker.com/tsr')).toBe(false);
});
it('rejects userinfo tricks that resolve to another host', () => {
expect(isAllowedTsaUrl('https://[email protected]')).toBe(false);
});
it('rejects non-http(s) schemes and non-string values', () => {
expect(isAllowedTsaUrl('javascript:alert(1)')).toBe(false);
expect(isAllowedTsaUrl('file:///etc/passwd')).toBe(false);
expect(isAllowedTsaUrl('data:text/plain,hi')).toBe(false);
expect(isAllowedTsaUrl('not a url')).toBe(false);
expect(isAllowedTsaUrl(null)).toBe(false);
expect(isAllowedTsaUrl(undefined)).toBe(false);
expect(isAllowedTsaUrl(1234)).toBe(false);
expect(isAllowedTsaUrl({ url: 'https://freetsa.org/tsr' })).toBe(false);
});
});
describe('isValidTsaRequestUrl', () => {
it('accepts well-formed http and https URLs', () => {
expect(isValidTsaRequestUrl('http://timestamp.digicert.com')).toBe(true);
expect(isValidTsaRequestUrl('https://tsa.example.org/tsr')).toBe(true);
});
it('rejects dangerous schemes, malformed input, and non-strings', () => {
expect(isValidTsaRequestUrl('javascript:alert(1)')).toBe(false);
expect(isValidTsaRequestUrl('data:text/plain,hi')).toBe(false);
expect(isValidTsaRequestUrl('file:///etc/passwd')).toBe(false);
expect(isValidTsaRequestUrl('')).toBe(false);
expect(isValidTsaRequestUrl(' ')).toBe(false);
expect(isValidTsaRequestUrl(42)).toBe(false);
expect(isValidTsaRequestUrl(null)).toBe(false);
});
});
+44
View File
@@ -262,6 +262,50 @@ describe('XSS replay — sanitizeEmailHtml (DOMPurify-backed)', () => {
expect(out.toLowerCase()).not.toContain('<style');
expect(out.toLowerCase()).not.toContain('<link');
});
it('does not produce a javascript: href when unwrapping SafeLinks URLs', () => {
const out = sanitizeEmailHtml(
'<a href="https://nam12.safelinks.protection.outlook.com/?url=javascript%3Aalert%28document.cookie%29&data=05%7C01">Quarterly Report</a>'
);
expect(out).not.toMatch(/href=["']\s*javascript:/i);
expect(out.toLowerCase()).not.toContain('javascript:alert');
const doc = new DOMParser().parseFromString(out, 'text/html');
for (const el of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
if (
['href', 'src'].includes(attr.name.toLowerCase()) &&
/^\s*javascript:/i.test(attr.value)
) {
throw new Error(`unexpected javascript: href after unwrap:\n${out}`);
}
}
}
});
it('unwraps a safe https SafeLinks target for readability', () => {
const out = sanitizeEmailHtml(
'<a href="https://nam12.safelinks.protection.outlook.com/?url=https%3A%2F%2Fexample.com%2Freport&data=05">Report</a>'
);
expect(out).toMatch(/href=["']https:\/\/example\.com\/report["']/i);
});
it('escapes special characters when unwrapping a SafeLinks target', () => {
const out = sanitizeEmailHtml(
'<a href="https://nam12.safelinks.protection.outlook.com/?url=https%3A%2F%2Fx.com%2F%22%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E">x</a>'
);
const doc = new DOMParser().parseFromString(out, 'text/html');
expect(
doc.querySelector('img'),
`unexpected element after unwrap:\n${out}`
).toBeNull();
for (const el of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
expect(/^on/i.test(attr.name), `event handler survived:\n${out}`).toBe(
false
);
}
}
});
});
describe('XSS replay — PDF signature cryptographic verification', () => {