Compare 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
alam00000 53bcf09566 Release v2.8.6 2026-06-28 11:55:59 +05:30
alam00000 d2dc459ba9 fix(split): extract pages with qpdf and decouple ZIP from output mode 2026-06-26 00:32:27 +05:30
alam00000 71ced499e7 fix(split): extract pages with qpdf and decouple ZIP from output mode 2026-06-26 00:32:27 +05:30
github-actions[bot] fc264f796a @nine-dev9 has signed the CLA in alam00000/bentopdf#761 2026-06-22 19:18:04 +00:00
github-actions[bot] e55c2ed405 @r0ckarong has signed the CLA in alam00000/bentopdf#752 2026-06-16 14:07:19 +00:00
github-actions[bot] 4b875a638c @upmcplanetracker has signed the CLA in alam00000/bentopdf#746 2026-06-09 14:25:52 +00:00
Alam b71d6c9b96 Merge pull request #730 from alam00000/dependabot/npm_and_yarn/vite-8.0.10
build(deps-dev): bump vite from 8.0.9 to 8.0.10
2026-06-04 23:46:56 +05:30
Alam d63d1683c7 Merge pull request #737 from marek-mraz/main
chore(i18n): add Slovak language
2026-06-04 23:45:38 +05:30
Alam 0347013df1 Merge pull request #739 from mondeep31/fix/issue-738
fix(md to pdf): restore mermaid flowchart node labels. Fixes #738
2026-06-04 23:43:57 +05:30
github-actions[bot] 412f2f1acb @mdevolde has signed the CLA in alam00000/bentopdf#740 2026-06-04 15:36:57 +00:00
github-actions[bot] a471bbcf4d @mondeep31 has signed the CLA in alam00000/bentopdf#739 2026-06-04 11:56:03 +00:00
Mondeep Chetry e1843e17b3 fix(md to pdf): restore mermaid flowchart node labels. Fixes #738 2026-06-04 17:18:51 +05:30
alam00000 ac3ad68b56 chore: remove FOSS demo section from README 2026-06-03 23:35:34 +05:30
marek-mraz 745698a5ba feat(i18n): add support for Arabic, Dutch, Danish, Swedish, and Japanese languages 2026-06-03 18:26:43 +02:00
github-actions[bot] dc1dbb036c @marek-mraz has signed the CLA in alam00000/bentopdf#736 2026-06-03 14:14:28 +00:00
marek-mraz 175c52bb1f chore(i18n): add Slovak language 2026-06-03 15:56:26 +02:00
alam00000 1d9b7c2a07 feat(multi-pdf): enhance PDF multi-tool functionality to allow drag n drop of PDF and images
fix(ocr): pin font CDN to rawcdn.githack.com with commit SHAs
2026-05-29 20:05:50 +05:30
alam00000 1534ce4d85 fix(ocr): change text layer color to black and set opacity to zero 2026-05-26 18:46:41 +05:30
alam00000 72e8c9f7b3 Release v2.8.5 2026-05-23 14:57:57 +05:30
dependabot[bot] 3cab1d37c7 build(deps-dev): bump vite from 8.0.9 to 8.0.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.9 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-05-23 07:33:47 +00:00
Alam 14e2c53030 Merge pull request #697 from alam00000/dependabot/npm_and_yarn/i18next-http-backend-3.0.6
build(deps): bump i18next-http-backend from 3.0.5 to 3.0.6
2026-05-23 13:02:09 +05:30
Alam 65335a2b88 Merge pull request #718 from Stephan-P/patch-2
Update tools.json
2026-05-23 13:01:38 +05:30
Stephan Paternotte 9215a5a943 Update tools.json
Peculiarly sloppy typo spotted and corrected.
I can't imagine me or any other human translator having made the same silly typo 4 times! 
Ergo; I am not at all happy with the fact that AI translation is now the default for BentoPDF
2026-05-14 08:17:06 +02:00
dependabot[bot] 7a248ba882 build(deps): bump i18next-http-backend from 3.0.5 to 3.0.6
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 3.0.5 to 3.0.6.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v3.0.5...v3.0.6)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 3.0.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-05-03 09:06:54 +00:00
81 changed files with 4650 additions and 1142 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
+1 -1
View File
@@ -3,7 +3,7 @@
ARG BASE_URL=
# Build stage
FROM public.ecr.aws/docker/library/node:26-alpine AS builder
FROM public.ecr.aws/docker/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY vendor ./vendor
+1 -1
View File
@@ -5,7 +5,7 @@
ARG BASE_URL=
# Build stage (identical to main Dockerfile)
FROM public.ecr.aws/docker/library/node:26-alpine AS builder
FROM public.ecr.aws/docker/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY vendor ./vendor
-4
View File
@@ -13,10 +13,6 @@
![BentoPDF Tools](public/images/bentopdf-tools.png)
### FOSS Hack 2026
[![Watch Demo](https://img.shields.io/badge/Watch%20Demo-FOSS%20Hack%202026-red?style=for-the-badge&logo=googledrive)](https://drive.google.com/file/d/14Vf62PvHiuf1RKFKtMlAzpbf4QQPLpRF/view?usp=sharing)
---
## Table of Contents
+15 -1
View File
@@ -33,6 +33,13 @@ BentoPDF uses **i18next** for internationalization (i18n). Currently supported l
- **Traditional Chinese (Taiwan)** (`zh-TW`)
- **Korean** (`ko`)
- **Russian** (`ru`)
- **Ukrainian** (`uk`)
- **Slovak** (`sk`)
- **Arabic** (`ar`)
- **Dutch** (`nl`)
- **Danish** (`da`)
- **Swedish** (`sv`)
- **Japanese** (`ja`)
The app automatically detects the language from the URL path:
@@ -603,8 +610,15 @@ Current translation coverage:
| Traditional Chinese | `zh-TW` | ✅ Complete | Community |
| Korean | `ko` | ✅ Complete | Community |
| Russian | `ru` | ✅ Complete | Community |
| Ukrainian | `uk` | ✅ Complete | Community |
| Slovak | `sk` | ✅ Complete | Community |
| Arabic | `ar` | ✅ Complete | Community |
| Dutch | `nl` | ✅ Complete | Community |
| Danish | `da` | ✅ Complete | Community |
| Swedish | `sv` | ✅ Complete | Community |
| Japanese | `ja` | ✅ Complete | Community |
| Your Language | `??` | 🚧 In Progress | You? |
---
**Last Updated**: January 2026
**Last Updated**: June 2026
+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.3
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.4'
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
+183 -163
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",
@@ -39,13 +39,13 @@
"html2canvas": "^1.4.1",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.5",
"i18next-http-backend": "^3.0.6",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.2",
"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.9",
"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.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"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.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"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.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"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.9.2",
"@emnapi/runtime": "1.9.2",
"@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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"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.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"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"
@@ -7678,9 +7678,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz",
"integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.1.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.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"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.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
"@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.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
"@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.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"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.16",
"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",
+9 -7
View File
@@ -1,7 +1,7 @@
{
"name": "bento-pdf",
"private": true,
"version": "2.8.4",
"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.9",
"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",
@@ -101,13 +102,13 @@
"html2canvas": "^1.4.1",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.5",
"i18next-http-backend": "^3.0.6",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.2",
"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",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "تنزيل:",
"downloadSelected": "تنزيل المحدد",
"exportPdf": "تصدير PDF",
"uploadPdfFiles": "اختر ملفات PDF",
"dragAndDrop": "اسحب وأفلت ملفات PDF هنا، أو انقر للاختيار",
"uploadPdfFiles": "اختر ملفات PDF أو صور",
"dragAndDrop": "اسحب وأفلت ملفات PDF أو الصور هنا، أو انقر للاختيار",
"selectFiles": "اختر الملفات",
"dropToAdd": "أفلت ملفات PDF أو الصور للإضافة",
"convertingImages": "جارٍ تحويل الصور إلى PDF...",
"failedToConvertImages": "فشل تحويل الصور إلى PDF.",
"renderingPages": "جارٍ عرض الصفحات...",
"actions": {
"duplicatePage": "تكرار هذه الصفحة",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Спампаваць:",
"downloadSelected": "Спампаваць выбранае",
"exportPdf": "Экспартаваць PDF",
"uploadPdfFiles": "Выбраць PDF файлы",
"dragAndDrop": "Перацягніце PDF файлы сюды або націсніце, каб выбраць",
"uploadPdfFiles": "Выбраць PDF або выявы",
"dragAndDrop": "Перацягніце PDF або выявы сюды або націсніце, каб выбраць",
"selectFiles": "Выбраць файлы",
"dropToAdd": "Адпусціце PDF або выявы для дабаўлення",
"convertingImages": "Канвертацыя выяў у PDF...",
"failedToConvertImages": "Не ўдалося канвертаваць выявы ў PDF.",
"renderingPages": "Апрацоўка старонак...",
"actions": {
"duplicatePage": "Дубляваць гэту старонку",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Download:",
"downloadSelected": "Download valgte",
"exportPdf": "Eksportér PDF",
"uploadPdfFiles": "Vælg PDF-filer",
"dragAndDrop": "Træk og slip PDF-filer her, eller klik for at vælge",
"uploadPdfFiles": "Vælg PDF- eller billedfiler",
"dragAndDrop": "Træk og slip PDF- eller billedfiler her, eller klik for at vælge",
"selectFiles": "Vælg filer",
"dropToAdd": "Slip PDF- eller billedfiler for at tilføje",
"convertingImages": "Konverterer billeder til PDF...",
"failedToConvertImages": "Kunne ikke konvertere billeder til PDF.",
"renderingPages": "Renderer sider...",
"actions": {
"duplicatePage": "Duplikér denne side",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Download:",
"downloadSelected": "Auswahl herunterladen",
"exportPdf": "PDF exportieren",
"uploadPdfFiles": "PDF-Dateien auswählen",
"dragAndDrop": "PDF-Dateien hierhin ziehen oder klicken zum Auswählen",
"uploadPdfFiles": "PDF- oder Bilddateien auswählen",
"dragAndDrop": "PDF- oder Bilddateien hierhin ziehen oder klicken zum Auswählen",
"selectFiles": "Dateien auswählen",
"dropToAdd": "PDF- oder Bilddateien zum Hinzufügen ablegen",
"convertingImages": "Bilder werden in PDF konvertiert...",
"failedToConvertImages": "Bilder konnten nicht in PDF konvertiert werden.",
"renderingPages": "Seiten werden gerendert...",
"actions": {
"duplicatePage": "Diese Seite duplizieren",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Download:",
"downloadSelected": "Download Selected",
"exportPdf": "Export PDF",
"uploadPdfFiles": "Select PDF Files",
"dragAndDrop": "Drag and drop PDF files here, or click to select",
"uploadPdfFiles": "Select PDF or image files",
"dragAndDrop": "Drag and drop PDF or image files here, or click to select",
"selectFiles": "Select Files",
"dropToAdd": "Drop PDF or image files to add",
"convertingImages": "Converting images to PDF...",
"failedToConvertImages": "Failed to convert images to PDF.",
"renderingPages": "Rendering pages...",
"actions": {
"duplicatePage": "Duplicate this page",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Descargar:",
"downloadSelected": "Descargar Seleccionados",
"exportPdf": "Exportar PDF",
"uploadPdfFiles": "Seleccionar Archivos PDF",
"dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar",
"uploadPdfFiles": "Seleccionar archivos PDF o imágenes",
"dragAndDrop": "Arrastra y suelta archivos PDF o imágenes aquí, o haz clic para seleccionar",
"selectFiles": "Seleccionar Archivos",
"dropToAdd": "Suelta archivos PDF o imágenes para añadir",
"convertingImages": "Convirtiendo imágenes a PDF...",
"failedToConvertImages": "No se pudieron convertir las imágenes a PDF.",
"renderingPages": "Renderizando páginas...",
"actions": {
"duplicatePage": "Duplicar esta página",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Téléchargement :",
"downloadSelected": "Télécharger la sélection",
"exportPdf": "Exporter en PDF",
"uploadPdfFiles": "Sélectionner des fichiers PDF",
"dragAndDrop": "Glissez-déposez vos fichiers PDF ici ou cliquez pour sélectionner",
"uploadPdfFiles": "Sélectionner des fichiers PDF ou des images",
"dragAndDrop": "Glissez-déposez vos fichiers PDF ou images ici, ou cliquez pour sélectionner",
"selectFiles": "Sélectionner des fichiers",
"dropToAdd": "Déposez des fichiers PDF ou images à ajouter",
"convertingImages": "Conversion des images en PDF...",
"failedToConvertImages": "Échec de la conversion des images en PDF.",
"renderingPages": "Rendu des pages...",
"actions": {
"duplicatePage": "Dupliquer cette page",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Unduh:",
"downloadSelected": "Unduh yang Dipilih",
"exportPdf": "Ekspor PDF",
"uploadPdfFiles": "Pilih File PDF",
"dragAndDrop": "Seret dan jatuhkan file PDF di sini, atau klik untuk memilih",
"uploadPdfFiles": "Pilih File PDF atau Gambar",
"dragAndDrop": "Seret dan jatuhkan file PDF atau gambar di sini, atau klik untuk memilih",
"selectFiles": "Pilih File",
"dropToAdd": "Jatuhkan file PDF atau gambar untuk menambahkan",
"convertingImages": "Mengonversi gambar ke PDF...",
"failedToConvertImages": "Gagal mengonversi gambar ke PDF.",
"renderingPages": "Merender halaman...",
"actions": {
"duplicatePage": "Duplikat halaman ini",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Scarica:",
"downloadSelected": "Scarica selezionati",
"exportPdf": "Esporta PDF",
"uploadPdfFiles": "Seleziona file PDF",
"dragAndDrop": "Trascina e rilascia i file PDF qui, oppure clicca per selezionare",
"uploadPdfFiles": "Seleziona file PDF o immagini",
"dragAndDrop": "Trascina e rilascia file PDF o immagini qui, oppure clicca per selezionare",
"selectFiles": "Seleziona file",
"dropToAdd": "Rilascia file PDF o immagini per aggiungere",
"convertingImages": "Conversione delle immagini in PDF...",
"failedToConvertImages": "Impossibile convertire le immagini in PDF.",
"renderingPages": "Generazione anteprime pagine...",
"actions": {
"duplicatePage": "Duplica questa pagina",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "ダウンロード:",
"downloadSelected": "選択したものをダウンロード",
"exportPdf": "PDFをエクスポート",
"uploadPdfFiles": "PDFファイルを選択",
"dragAndDrop": "ここにPDFファイルをドラッグ&ドロップするかクリックして選択します",
"uploadPdfFiles": "PDFまたは画像ファイルを選択",
"dragAndDrop": "ここにPDFまたは画像ファイルをドラッグ&ドロップするかクリックして選択します",
"selectFiles": "ファイルの選択",
"dropToAdd": "PDFまたは画像ファイルをドロップして追加",
"convertingImages": "画像をPDFに変換中...",
"failedToConvertImages": "画像をPDFに変換できませんでした。",
"renderingPages": "ページをレンダリング中...",
"actions": {
"duplicatePage": "このページを複製",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "다운로드:",
"downloadSelected": "선택 항목 다운로드",
"exportPdf": "PDF 내보내기",
"uploadPdfFiles": "PDF 파일 선택",
"dragAndDrop": "PDF 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요",
"uploadPdfFiles": "PDF 또는 이미지 파일 선택",
"dragAndDrop": "PDF 또는 이미지 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요",
"selectFiles": "파일 선택",
"dropToAdd": "PDF 또는 이미지 파일을 놓아 추가",
"convertingImages": "이미지를 PDF로 변환 중...",
"failedToConvertImages": "이미지를 PDF로 변환하지 못했습니다.",
"renderingPages": "페이지 렌더링 중...",
"actions": {
"duplicatePage": "이 페이지 복제",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Laden:",
"downloadSelected": "Selectie laden",
"exportPdf": "PDF exporteren",
"uploadPdfFiles": "PDF-bestanden selecteren",
"dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen",
"uploadPdfFiles": "PDF- of afbeeldingsbestanden selecteren",
"dragAndDrop": "Klik om een bestand te selecteren, of sleep een PDF of afbeelding hierheen",
"selectFiles": "Bestanden selecteren",
"dropToAdd": "Sleep PDF- of afbeeldingsbestanden om toe te voegen",
"convertingImages": "Afbeeldingen omzetten naar PDF...",
"failedToConvertImages": "Afbeeldingen omzetten naar PDF mislukt.",
"renderingPages": "Pagina's verwerken…",
"actions": {
"duplicatePage": "Deze pagina dupliceren",
+4 -4
View File
@@ -112,7 +112,7 @@
"subtitle": "Paginanummers aan een document toevoegen."
},
"addPageLabels": {
"name": "Paginelabels toevoegen",
"name": "Paginalabels toevoegen",
"subtitle": "Pas PDF-paginalabels toe met Romeinse cijfers, voorvoegsels en aangepaste startwaarden.",
"uploadHint": "PDF-bestand",
"howLabelsWorkTitle": "Hoe labels werken",
@@ -124,7 +124,7 @@
"addRule": "Regel toevoegen",
"removeExistingLabels": "Bestaande paginalabels verwijderen voordat deze regels worden toegepast",
"cpdfNote": "Deze tool gebruikt CoherentPDF in je browser. Als CoherentPDF niet is geconfigureerd, word je gevraagd dit in te stellen in de WASM-instellingen.",
"processButton": "Paginelabels toevoegen",
"processButton": "Paginalabels toevoegen",
"howItWorksUploadTitle": "Bestand uploaden",
"howItWorksUploadDescription": "Kies de PDF die je opnieuw wilt labelen.",
"howItWorksDefineTitle": "Regels definiëren",
@@ -155,11 +155,11 @@
"continueNumbering": "Nummering voortzetten over onderbroken bereiken",
"examplesNote": "Voorbeelden: 1-4 voor voorwerk met Romeinse cijfers, 15-20 met voorvoegsel A- en startwaarde 0, of oneven pagina's met voortgang ingeschakeld.",
"uploadFirstMessage": "Upload eerst een PDF-bestand.",
"applyingLabels": "Paginelabels toepassen...",
"applyingLabels": "Paginalabels toepassen...",
"invalidRangeMessage": "Regel {{number}} heeft een ongeldig paginabereik: {{range}}",
"allPages": "alle pagina's",
"emptyOutputMessage": "CoherentPDF heeft een leeg bestand geproduceerd.",
"successMessage": "Paginelabels zijn succesvol toegevoegd!",
"successMessage": "Paginalabels zijn succesvol toegevoegd!",
"processErrorMessage": "Kon de paginalabels niet toevoegen.",
"styleOptions": {
"DecimalArabic": "Decimale cijfers",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Baixar:",
"downloadSelected": "Baixar Selecionadas",
"exportPdf": "Exportar PDF",
"uploadPdfFiles": "Selecionar Arquivos PDF",
"dragAndDrop": "Arraste arquivos PDF aqui ou clique para selecionar",
"uploadPdfFiles": "Selecionar Arquivos PDF ou Imagens",
"dragAndDrop": "Arraste arquivos PDF ou imagens aqui ou clique para selecionar",
"selectFiles": "Selecionar Arquivos",
"dropToAdd": "Solte arquivos PDF ou imagens para adicionar",
"convertingImages": "Convertendo imagens em PDF...",
"failedToConvertImages": "Falha ao converter imagens em PDF.",
"renderingPages": "Renderizando páginas...",
"actions": {
"duplicatePage": "Duplicar esta página",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Скачать:",
"downloadSelected": "Скачать выбранное",
"exportPdf": "Экспортировать PDF",
"uploadPdfFiles": "Выбрать PDF-файлы",
"dragAndDrop": "Перетащите PDF-файлы сюда или нажмите, чтобы выбрать",
"uploadPdfFiles": "Выбрать PDF-файлы или изображения",
"dragAndDrop": "Перетащите PDF-файлы или изображения сюда или нажмите, чтобы выбрать",
"selectFiles": "Выбрать файлы",
"dropToAdd": "Отпустите PDF-файлы или изображения, чтобы добавить",
"convertingImages": "Преобразование изображений в PDF...",
"failedToConvertImages": "Не удалось преобразовать изображения в PDF.",
"renderingPages": "Отрисовка страниц...",
"actions": {
"duplicatePage": "Дублировать эту страницу",
+387
View File
@@ -0,0 +1,387 @@
{
"nav": {
"home": "Domov",
"about": "O nás",
"contact": "Kontakt",
"licensing": "Licencie",
"allTools": "Všetky nástroje",
"docs": "Dokumentácia",
"openMainMenu": "Otvoriť hlavné menu",
"language": "Jazyk",
"searchLanguage": "Hľadať jazyk…",
"noLanguagesFound": "Nenašli sa žiadne jazyky"
},
"donation": {
"message": "Páči sa vám BentoPDF? Pomôžte nám udržať ho bezplatný a open source!",
"button": "Prispieť"
},
"hero": {
"title": "Sada",
"pdfToolkit": "PDF nástrojov",
"builtForPrivacy": "vytvorená pre súkromie",
"noSignups": "Žiadne registrácie",
"unlimitedUse": "Neobmedzené používanie",
"worksOffline": "Funguje offline",
"startUsing": "Začať používať teraz"
},
"usedBy": {
"title": "Používajú spoločnosti a ľudia, ktorí pracujú v"
},
"features": {
"title": "Prečo si vybrať",
"bentoPdf": "BentoPDF?",
"noSignup": {
"title": "Žiadna registrácia",
"description": "Začnite okamžite, nepotrebujete žiadne účty ani e-maily."
},
"noUploads": {
"title": "Žiadne nahrávanie na server",
"description": "100% lokálne spracovanie, vaše súbory nikdy neopustia vaše zariadenie."
},
"foreverFree": {
"title": "Navždy zadarmo",
"description": "Všetky nástroje, žiadne skúšobné verzie, žiadne skryté poplatky."
},
"noLimits": {
"title": "Bez obmedzení",
"description": "Používajte koľko chcete, bez skrytých limitov."
},
"batchProcessing": {
"title": "Dávkové spracovanie",
"description": "Spracujte neobmedzené množstvo PDF dokumentov naraz."
},
"lightningFast": {
"title": "Bleskovo rýchle",
"description": "Spracujte PDF okamžite, bez zbytočného čakania."
}
},
"tools": {
"title": "Začnite s",
"toolsLabel": "Nástroje",
"subtitle": "Kliknutím na nástroj otvoríte nahrávanie súborov",
"searchPlaceholder": "Vyhľadajte nástroj (napr. 'rozdeliť', 'usporiadať'...)",
"backToTools": "Späť na nástroje",
"firstLoadNotice": "Prvé načítanie chvíľu trvá, kým sa stiahne náš konverzný nástroj. Potom už budú všetky procesy okamžité."
},
"upload": {
"clickToSelect": "Kliknite pre výber súboru",
"orDragAndDrop": "alebo ho potiahnite a pusťte",
"pdfOrImages": "PDF alebo obrázky",
"filesNeverLeave": "Vaše súbory nikdy neopustia vaše zariadenie.",
"addMore": "Pridať ďalšie súbory",
"clearAll": "Vymazať všetko",
"clearFiles": "Vymazať súbory",
"hints": {
"singlePdf": "Jeden PDF súbor",
"pdfFile": "PDF súbor",
"multiplePdfs2": "Viacero PDF súborov (aspoň 2)",
"bmpImages": "Obrázky BMP",
"oneOrMorePdfs": "Jeden alebo viac PDF súborov",
"pdfDocuments": "PDF dokumenty",
"oneOrMoreCsv": "Jeden alebo viac CSV súborov",
"multiplePdfsSupported": "Podporuje sa viacero PDF súborov",
"singleOrMultiplePdfs": "Podporovaný jeden alebo viacero PDF súborov",
"singlePdfFile": "Jeden PDF súbor",
"pdfWithForms": "PDF súbor s poľami formulára",
"heicImages": "Obrázky HEIC/HEIF",
"jpgImages": "Obrázky JPG, JPEG, JP2, JPX",
"pdfsOrImages": "PDF alebo obrázky",
"oneOrMoreOdt": "Jeden alebo viac ODT súborov",
"singlePdfOnly": "Iba jeden PDF súbor",
"pdfFiles": "PDF súbory",
"multiplePdfs": "Viacero PDF súborov",
"pngImages": "Obrázky PNG",
"pdfFilesOneOrMore": "PDF súbory (jeden alebo viac)",
"oneOrMoreRtf": "Jeden alebo viac RTF súborov",
"svgGraphics": "Grafika SVG",
"tiffImages": "Obrázky TIFF",
"webpImages": "Obrázky WebP"
}
},
"howItWorks": {
"title": "Ako to funguje",
"step1Title": "Nahrať súbor",
"step1": "Kliknite alebo presuňte súbor pre začiatok",
"step2Title": "Spracovať",
"step2": "Kliknutím na tlačidlo spracovať začnete",
"step3Title": "Stiahnuť",
"step3": "Okamžite si uložte svoj spracovaný súbor"
},
"relatedTools": {
"title": "Súvisiace PDF nástroje"
},
"loader": {
"processing": "Spracováva sa..."
},
"alert": {
"title": "Upozornenie",
"ok": "OK"
},
"preview": {
"title": "Náhľad dokumentu",
"downloadAsPdf": "Stiahnuť ako PDF",
"close": "Zavrieť"
},
"settings": {
"title": "Nastavenia",
"shortcuts": "Klávesové skratky",
"preferences": "Predvoľby",
"displayPreferences": "Predvoľby zobrazenia",
"searchShortcuts": "Hľadať skratky...",
"shortcutsInfo": "Stlačením a podržaním klávesov nastavíte skratku. Zmeny sa automaticky ukladajú.",
"shortcutsWarning": "⚠️ Vyhnite sa bežným skratkám prehliadača (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N atď.), pretože nemusia fungovať spoľahlivo.",
"import": "Importovať",
"export": "Exportovať",
"resetToDefaults": "Obnoviť predvolené",
"fullWidthMode": "Režim celej šírky",
"fullWidthDescription": "Použiť celú šírku obrazovky pre všetky nástroje namiesto vycentrovaného rozloženia",
"compactMode": "Kompaktný režim",
"compactModeDescription": "Zobraziť nástroje ako kompaktný zoznam namiesto kariet",
"advancedSettings": "Pokročilé nastavenia",
"advancedSettingsDescription": "Konfigurácia externých modulov pre spracovanie (PyMuPDF, Ghostscript, CoherentPDF)",
"settingsAutoSaved": "Nastavenia sa automaticky ukladajú",
"clickToSet": "Kliknutím nastavte",
"pressKeys": "Stlačte klávesy...",
"warnings": {
"alreadyInUse": "Skratka sa už používa",
"assignedTo": "je už priradená k:",
"chooseDifferent": "Zvoľte si prosím inú skratku.",
"reserved": "Upozornenie na vyhradenú skratku",
"commonlyUsed": "sa bežne používa pre:",
"unreliable": "Táto skratka nemusí fungovať spoľahlivo alebo môže spôsobovať konflikt s prehliadačom/systémom.",
"useAnyway": "Chcete ju napriek tomu použiť?",
"resetTitle": "Obnoviť skratky",
"resetMessage": "Naozaj chcete obnoviť všetky skratky na predvolené?<br><br>Túto akciu nie je možné vrátiť späť.",
"importSuccessTitle": "Import úspešný",
"importSuccessMessage": "Skratky boli úspešne importované!",
"importFailTitle": "Import zlyhal",
"importFailMessage": "Nepodarilo sa importovať skratky. Neplatný formát súboru."
}
},
"warning": {
"title": "Varovanie",
"cancel": "Zrušiť",
"proceed": "Pokračovať"
},
"compliance": {
"title": "Vaše dáta nikdy neopustia vaše zariadenie",
"weKeep": "Udržujeme",
"yourInfoSafe": "vaše informácie v bezpečí",
"byFollowingStandards": "dodržiavaním globálnych bezpečnostných štandardov.",
"processingLocal": "Všetko spracovanie prebieha lokálne priamo na vašom zariadení.",
"gdpr": {
"title": "Súlad s GDPR",
"description": "Chráni osobné údaje a súkromie jednotlivcov v rámci Európskej únie."
},
"ccpa": {
"title": "Súlad s CCPA",
"description": "Dáva obyvateľom Kalifornie práva týkajúce sa toho, ako sa zhromažďujú, používajú a zdieľajú ich osobné informácie."
},
"hipaa": {
"title": "Súlad s HIPAA",
"description": "Stanovuje ochranné opatrenia pre nakladanie s citlivými zdravotnými informáciami v systéme zdravotnej starostlivosti v USA."
}
},
"faq": {
"title": "Často kladené",
"questions": "otázky",
"sectionTitle": "Často kladené otázky",
"isFree": {
"question": "Je BentoPDF naozaj zadarmo?",
"answer": "Áno, absolútne. Všetky nástroje na BentoPDF sú na 100% bezplatné, bez obmedzení súborov, bez registrácie a bez vodotlače. Veríme, že každý si zaslúži prístup k jednoduchým a výkonným nástrojom PDF bez poplatkov."
},
"areFilesSecure": {
"question": "Sú moje súbory v bezpečí? Kde sa spracúvajú?",
"answer": "Vaše súbory sú v maximálnom bezpečí, pretože nikdy neopustia váš počítač. Všetko spracovanie prebieha priamo vo vašom webovom prehliadači (na strane klienta). Nikdy nenahrávame vaše súbory na server, takže máte úplné súkromie a kontrolu nad svojimi dokumentmi."
},
"platforms": {
"question": "Funguje to na Macu, vo Windows a na mobilných zariadeniach?",
"answer": "Áno! Keďže BentoPDF beží výlučne vo vašom prehliadači, funguje na akomkoľvek operačnom systéme s moderným webovým prehliadačom vrátane Windows, macOS, Linux, iOS a Android."
},
"gdprCompliant": {
"question": "Je BentoPDF v súlade s GDPR?",
"answer": "Áno. BentoPDF je plne v súlade s GDPR. Keďže všetko spracovanie súborov prebieha lokálne vo vašom prehliadači a my vaše súbory nikdy nezhromažďujeme ani neprenášame na žiadny server, nemáme prístup k vašim údajom. To zaisťuje, že máte svoje dokumenty vždy pod kontrolou."
},
"dataStorage": {
"question": "Ukladáte alebo sledujete nejaké moje súbory?",
"answer": "Nie. Vaše súbory nikdy neukladáme, nesledujeme ani nezaznamenávame. Všetko, čo v BentoPDF urobíte, sa odohráva v pamäti vášho prehliadača a po zatvorení stránky zmizne. Nedochádza k žiadnemu nahrávaniu, logovaniu ani sa nevyužívajú žiadne servery."
},
"different": {
"question": "V čom je BentoPDF odlišné od iných PDF nástrojov?",
"answer": "Väčšina PDF nástrojov nahráva vaše súbory na spracovanie na server. BentoPDF to nerobí. Na spracovanie vašich súborov priamo vo vašom prehliadači používame bezpečné moderné webové technológie. Znamená to vyšší výkon, silnejšiu ochranu súkromia a úplný pokoj v duši."
},
"browserBased": {
"question": "Ako ma spracovanie založené na prehliadači chráni?",
"answer": "Tým, že BentoPDF beží výlučne vnútri vášho prehliadača, zabezpečuje, že vaše súbory nikdy neopustia vaše zariadenie. Tým sa eliminujú riziká napadnutia serverov, úniku dát alebo neoprávneného prístupu. Vaše súbory zostávajú vždy vaše."
},
"analytics": {
"question": "Používate súbory cookie alebo analytiku na moje sledovanie?",
"answer": "Záleží nám na vašom súkromí. BentoPDF nesleduje osobné údaje. Nástroj Simple Analytics využívame výhradne na zobrazenie anonymného počtu návštev. Znamená to, že môžeme vedieť, koľko používateľov navštívilo našu stránku, ale nikdy nevieme, kto ste. Simple Analytics je plne v súlade s GDPR a rešpektuje vaše súkromie."
}
},
"testimonials": {
"title": "Čo hovoria",
"users": "naši",
"say": "používatelia"
},
"support": {
"title": "Páči sa vám moja práca?",
"description": "BentoPDF je srdcový projekt, vytvorený s cieľom poskytnúť bezplatnú, súkromnú a výkonnú sadu PDF nástrojov pre každého. Ak sa vám zdá užitočný, zvážte podporu jeho vývoja. Každá káva pomôže!",
"buyMeCoffee": "Kúpte mi kávu"
},
"footer": {
"copyright": "© 2026 BentoPDF. Všetky práva vyhradené.",
"version": "Verzia",
"company": "Spoločnosť",
"aboutUs": "O nás",
"faqLink": "Časté otázky",
"contactUs": "Kontaktujte nás",
"legal": "Právne informácie",
"termsAndConditions": "Obchodné podmienky",
"privacyPolicy": "Zásady ochrany osobných údajov",
"followUs": "Sledujte nás"
},
"merge": {
"title": "Zlúčiť PDF",
"description": "Spojte celé súbory alebo vyberte konkrétne strany na zlúčenie do nového dokumentu.",
"fileMode": "Režim súborov",
"pageMode": "Režim strán",
"howItWorks": "Ako to funguje:",
"fileModeInstructions": [
"Kliknite a potiahnite ikonu pre zmenu poradia súborov.",
"V poli \"Strany\" pri každom súbore môžete zadať rozsahy (napr. \"1-3, 5\") na zlúčenie iba týchto strán.",
"Ak chcete zahrnúť všetky strany z daného súboru, nechajte pole \"Strany\" prázdne."
],
"pageModeInstructions": [
"Všetky strany z vašich nahratých PDF dokumentov sú zobrazené nižšie.",
"Jednoducho potiahnite a pusťte jednotlivé miniatúry strán a vytvorte presné poradie pre váš nový súbor."
],
"mergePdfs": "Zlúčiť PDF"
},
"common": {
"page": "Strana",
"pages": "Strany",
"of": "z",
"download": "Stiahnuť",
"cancel": "Zrušiť",
"save": "Uložiť",
"delete": "Zmazať",
"edit": "Upraviť",
"add": "Pridať",
"remove": "Odstrániť",
"loading": "Načítava sa...",
"loadingPageCount": "Načítavajú sa strany...",
"error": "Chyba",
"success": "Úspech",
"file": "Súbor",
"files": "Súbory",
"close": "Zavrieť",
"convert": "Konvertovať",
"unknownError": "Neznáma chyba"
},
"about": {
"hero": {
"title": "Veríme, že PDF nástroje by mali byť",
"subtitle": "rýchle, súkromné a bezplatné.",
"noCompromises": "Bez kompromisov."
},
"mission": {
"title": "Naša misia",
"description": "Poskytnúť najkomplexnejšiu sadu PDF nástrojov, ktorá rešpektuje vaše súkromie a nikdy nepožaduje platbu. Veríme, že základné nástroje pre prácu s dokumentmi by mali byť dostupné pre každého, všade a bez bariér."
},
"philosophy": {
"label": "Naša hlavná filozofia",
"title": "Súkromie na prvom mieste. Vždy.",
"description": "V dobe, kedy sú dáta komoditou, k tomu pristupujeme inak. Všetko spracovanie nástrojov BentoPDF prebieha lokálne vo vašom prehliadači. To znamená, že vaše súbory sa nikdy nedotknú našich serverov, nikdy nevidíme vaše dokumenty a nesledujeme, čo robíte. Vaše dokumenty zostávajú úplne a jednoznačne súkromné. Nie je to len funkcia, je to náš základ."
},
"whyBentopdf": {
"title": "Prečo",
"speed": {
"title": "Zostavené pre rýchlosť",
"description": "Žiadne čakanie na nahrávanie alebo sťahovanie zo servera. Spracovaním súborov priamo vo vašom prehliadači pomocou moderných webových technológií ako WebAssembly, ponúkame bezkonkurenčnú rýchlosť pre všetky naše nástroje."
},
"free": {
"title": "Úplne zadarmo",
"description": "Žiadne skúšobné verzie, žiadne predplatné, žiadne skryté poplatky a žiadne „prémiové“ funkcie držané ako rukojemníci. Veríme, že výkonné PDF nástroje by mali byť verejnou službou, nie centrom zisku."
},
"noAccount": {
"title": "Nevyžaduje sa žiadny účet",
"description": "Začnite používať akýkoľvek nástroj okamžite. Nepotrebujeme váš e-mail, heslo ani žiadne osobné údaje. Váš pracovný postup by mal byť plynulý a anonymný."
},
"openSource": {
"title": "Duch open-source",
"description": "Vytvorené s ohľadom na transparentnosť. Využívame neuveriteľné open-source knižnice ako PDF-lib a PDF.js, a veríme v úsilie poháňané komunitou s cieľom sprístupniť výkonné nástroje všetkým."
}
},
"cta": {
"title": "Ste pripravení začať?",
"description": "Pridajte sa k tisícom používateľov, ktorí dôverujú BentoPDF vo svojich každodenných potrebách s dokumentmi. Vyskúšajte, aký rozdiel môže urobiť súkromie a výkon.",
"button": "Preskúmať všetky nástroje"
}
},
"contact": {
"title": "Kontaktujte nás",
"subtitle": "Radi o vás budeme počuť. Či už máte otázku, spätnú väzbu, alebo návrh na novú funkciu, neváhajte sa ozvať.",
"email": "Môžete nás kontaktovať priamo e-mailom na:"
},
"licensing": {
"title": "Licencovanie pre",
"subtitle": "Vyberte si licenciu, ktorá vyhovuje vašim potrebám."
},
"multiTool": {
"uploadPdfs": "Nahrať PDF",
"upload": "Nahrať",
"addBlankPage": "Pridať prázdnu stranu",
"edit": "Upraviť:",
"undo": "Späť",
"redo": "Znova",
"reset": "Obnoviť",
"selection": "Výber:",
"selectAll": "Vybrať všetko",
"deselectAll": "Zrušiť výber",
"rotate": "Otočiť:",
"rotateLeft": "Doľava",
"rotateRight": "Doprava",
"transform": "Transformovať:",
"duplicate": "Duplikovať",
"split": "Rozdeliť",
"clear": "Vyčistiť:",
"delete": "Zmazať",
"download": "Stiahnuť:",
"downloadSelected": "Stiahnuť vybrané",
"exportPdf": "Exportovať PDF",
"uploadPdfFiles": "Vyberte PDF alebo obrázky",
"dragAndDrop": "Potiahnite a pusťte PDF alebo obrázky sem, alebo kliknite pre výber",
"selectFiles": "Vybrať súbory",
"dropToAdd": "Pusťte PDF alebo obrázky pre pridanie",
"convertingImages": "Konvertujú sa obrázky do PDF...",
"failedToConvertImages": "Nepodarilo sa konvertovať obrázky do PDF.",
"renderingPages": "Vykresľujú sa strany...",
"actions": {
"duplicatePage": "Duplikovať túto stranu",
"deletePage": "Zmazať túto stranu",
"insertPdf": "Vložiť PDF za túto stranu",
"toggleSplit": "Prepnúť rozdelenie za touto stranou"
},
"pleaseWait": "Prosím čakajte",
"pagesRendering": "Strany sa stále vykresľujú. Prosím čakajte...",
"noPagesSelected": "Žiadne vybrané strany",
"selectOnePage": "Prosím vyberte aspoň jednu stranu na stiahnutie.",
"noPages": "Žiadne strany",
"noPagesToExport": "Nie sú tu žiadne strany na export.",
"renderingTitle": "Vykresľujú sa náhľady strán",
"errorRendering": "Nepodarilo sa vykresliť miniatúry strán",
"error": "Chyba",
"failedToLoad": "Nepodarilo sa načítať"
},
"simpleMode": {
"title": "Nástroje PDF",
"subtitle": "Vyberte si nástroj pre začiatok"
},
"disabledTool": {
"title": "Nástroj nedostupný",
"heading": "Tento nástroj bol zakázaný",
"message": "Tento nástroj nie je vo vašom nasadení dostupný. Pre viac informácií kontaktujte svojho administrátora.",
"backHome": "Späť domov"
}
}
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Ladda ner:",
"downloadSelected": "Ladda ner markerade",
"exportPdf": "Exportera PDF",
"uploadPdfFiles": "Välj PDF-filer",
"dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja",
"uploadPdfFiles": "Välj PDF- eller bildfiler",
"dragAndDrop": "Dra och släpp PDF- eller bildfiler här, eller klicka för att välja",
"selectFiles": "Välj filer",
"dropToAdd": "Släpp PDF- eller bildfiler för att lägga till",
"convertingImages": "Konverterar bilder till PDF...",
"failedToConvertImages": "Kunde inte konvertera bilder till PDF.",
"renderingPages": "Renderar sidor...",
"actions": {
"duplicatePage": "Duplicera denna sida",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "İndir:",
"downloadSelected": "Seçilenleri İndir",
"exportPdf": "PDF Olarak Dışa Aktar",
"uploadPdfFiles": "PDF Dosyalarını Seçin",
"dragAndDrop": "PDF dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın",
"uploadPdfFiles": "PDF veya Görsel Dosyalarını Seçin",
"dragAndDrop": "PDF veya görsel dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın",
"selectFiles": "Dosya Seç",
"dropToAdd": "Eklemek için PDF veya görsel dosyalarını bırakın",
"convertingImages": "Görseller PDF'e dönüştürülüyor...",
"failedToConvertImages": "Görseller PDF'e dönüştürülemedi.",
"renderingPages": "Sayfalar oluşturuluyor...",
"actions": {
"duplicatePage": "Bu sayfayı çoğalt",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Завантажити:",
"downloadSelected": "Завантажити вибране",
"exportPdf": "Експортувати PDF",
"uploadPdfFiles": "Виберіть PDF-файли",
"dragAndDrop": "Перетягніть PDF або натисніть для вибору",
"uploadPdfFiles": "Виберіть PDF-файли або зображення",
"dragAndDrop": "Перетягніть PDF або зображення сюди, або натисніть для вибору",
"selectFiles": "Вибрати файли",
"dropToAdd": "Відпустіть PDF або зображення для додавання",
"convertingImages": "Перетворення зображень на PDF...",
"failedToConvertImages": "Не вдалося перетворити зображення на PDF.",
"renderingPages": "Рендеринг сторінок...",
"actions": {
"duplicatePage": "Дублювати сторінку",
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "Tải xuống:",
"downloadSelected": "Tải xuống đã chọn",
"exportPdf": "Xuất PDF",
"uploadPdfFiles": "Chọn tệp PDF",
"dragAndDrop": "Kéo và thả tệp PDF vào đây, hoặc nhấp để chọn",
"uploadPdfFiles": "Chọn tệp PDF hoặc hình ảnh",
"dragAndDrop": "Kéo và thả tệp PDF hoặc hình ảnh vào đây, hoặc nhấp để chọn",
"selectFiles": "Chọn tệp",
"dropToAdd": "Thả tệp PDF hoặc hình ảnh để thêm",
"convertingImages": "Đang chuyển đổi hình ảnh sang PDF...",
"failedToConvertImages": "Không thể chuyển đổi hình ảnh sang PDF.",
"renderingPages": "Đang kết xuất trang...",
"actions": {
"duplicatePage": "Nhân bản trang này",
+129 -126
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,99 +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": "選擇檔案",
"renderingPages": "渲染頁面...",
"uploadPdfFiles": "選取 PDF 或圖片檔案",
"dragAndDrop": "拖曳 PDF 或圖片檔案至此,或點選以選取",
"selectFiles": "選取檔案",
"dropToAdd": "拖曳 PDF 或圖片檔案至此以新增",
"convertingImages": "正在將圖片轉換為 PDF…",
"failedToConvertImages": "圖片轉換為 PDF 失敗。",
"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
+5 -2
View File
@@ -350,9 +350,12 @@
"download": "下载:",
"downloadSelected": "下载选中",
"exportPdf": "导出 PDF",
"uploadPdfFiles": "选择 PDF 文件",
"dragAndDrop": "将 PDF 文件拖放到此处,或点击选择",
"uploadPdfFiles": "选择 PDF 或图片文件",
"dragAndDrop": "将 PDF 或图片文件拖放到此处,或点击选择",
"selectFiles": "选择文件",
"dropToAdd": "拖放 PDF 或图片文件以添加",
"convertingImages": "正在将图片转换为 PDF...",
"failedToConvertImages": "图片转换为 PDF 失败。",
"renderingPages": "正在渲染页面...",
"actions": {
"duplicatePage": "复制此页",
@@ -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);
+4 -1
View File
@@ -27,6 +27,7 @@ const DEFAULT_WASM_ORIGINS = {
};
const DEFAULT_CORS_PROXY_ORIGIN =
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com';
const wasmOrigins = [
originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf,
@@ -43,13 +44,15 @@ const tesseractOrigins = uniq([
const corsProxyOrigin =
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
const ocrFontOrigin = originOf(process.env.VITE_OCR_FONT_BASE_URL);
const ocrFontOrigin =
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
ocrFontOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
+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);
+1 -1
View File
@@ -1,4 +1,4 @@
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; 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' 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-ancestors 'self'; form-action 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
+64
View File
@@ -447,6 +447,70 @@
"created_at": "2026-04-10T12:20:50Z",
"repoId": 1074785178,
"pullRequestNo": 650
},
{
"name": "marek-mraz",
"id": 193615612,
"comment_id": 4613282157,
"created_at": "2026-06-03T14:14:11Z",
"repoId": 1074785178,
"pullRequestNo": 736
},
{
"name": "mondeep31",
"id": 97219431,
"comment_id": 4621895746,
"created_at": "2026-06-04T11:55:51Z",
"repoId": 1074785178,
"pullRequestNo": 739
},
{
"name": "mdevolde",
"id": 73021358,
"comment_id": 4623697031,
"created_at": "2026-06-04T15:36:43Z",
"repoId": 1074785178,
"pullRequestNo": 740
},
{
"name": "upmcplanetracker",
"id": 219436948,
"comment_id": 4660768210,
"created_at": "2026-06-09T14:25:39Z",
"repoId": 1074785178,
"pullRequestNo": 746
},
{
"name": "r0ckarong",
"id": 4920542,
"comment_id": 4719643792,
"created_at": "2026-06-16T14:07:01Z",
"repoId": 1074785178,
"pullRequestNo": 752
},
{
"name": "nine-dev9",
"id": 82376723,
"comment_id": 4772117353,
"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
}
]
}
+27 -27
View File
@@ -160,59 +160,59 @@ export const languageToFontFamily: Record<string, string> = {
export const fontFamilyToUrl: Record<string, string> = {
'Noto Sans JP':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
'Noto Sans SC':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
'Noto Sans TC':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
'Noto Sans KR':
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
'Noto Naskh Arabic':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
'Noto Sans Devanagari':
'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
'Noto Sans Bengali':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
'Noto Sans Gujarati':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
'Noto Sans Kannada':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
'Noto Sans Malayalam':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
'Noto Sans Oriya':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
'Noto Sans Gurmukhi':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
'Noto Sans Tamil':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
'Noto Sans Telugu':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
'Noto Sans Sinhala':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
'Noto Sans Thai':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
'Noto Sans Khmer':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
'Noto Sans Lao':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
'Noto Sans Myanmar':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
'Noto Sans Hebrew':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
'Noto Sans Georgian':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
'Noto Sans Ethiopic':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
'Noto Serif Tibetan':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
'Noto Sans Cherokee':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
'Noto Sans Armenian':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
'Noto Sans Syriac':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
'Noto Sans':
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
};
export function getFontUrlForFamily(fontFamily: string): string {
+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);
}
+5 -3
View File
@@ -23,6 +23,7 @@ export const supportedLanguages = [
'ko',
'ja',
'uk',
'sk',
] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
@@ -47,6 +48,7 @@ export const languageNames: Record<SupportedLanguage, string> = {
ko: '한국어',
ja: '日本語',
uk: 'Українська',
sk: 'Slovenčina',
};
export const getLanguageFromUrl = (): SupportedLanguage => {
@@ -62,7 +64,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
}
const langMatch = path.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(?:\/|$)/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(?:\/|$)/
);
if (
langMatch &&
@@ -152,7 +154,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
let pagePathWithoutLang = relativePath;
const langPrefixMatch = relativePath.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(\/.*)?$/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(\/.*)?$/
);
if (langPrefixMatch) {
pagePathWithoutLang = langPrefixMatch[2] || '/';
@@ -247,7 +249,7 @@ export const rewriteLinks = (): void => {
}
const langPrefixRegex = new RegExp(
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(/|$)`
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(/|$)`
);
if (langPrefixRegex.test(href)) {
return;
+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'
) {
+142 -48
View File
@@ -12,6 +12,8 @@ import {
} from '../utils/render-utils';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { repairPdfFile } from './repair-pdf.js';
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -101,7 +103,11 @@ function showModal(
success: 'text-green-400',
};
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
modalIcon.replaceChildren();
const iconEl = document.createElement('i');
iconEl.dataset.lucide = iconMap[type];
iconEl.className = `w-12 h-12 ${colorMap[type]}`;
modalIcon.appendChild(iconEl);
modal.classList.remove('hidden');
createIcons({ icons });
}
@@ -133,20 +139,25 @@ async function withButtonLoading(
const button = document.getElementById(buttonId) as HTMLButtonElement;
if (!button) return;
const originalContent = button.innerHTML;
const originalChildren = Array.from(button.childNodes).map((n) =>
n.cloneNode(true)
);
const originalPointerEvents = button.style.pointerEvents;
try {
button.disabled = true;
button.style.pointerEvents = 'none';
button.innerHTML =
'<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
const spinner = document.createElement('i');
spinner.dataset.lucide = 'loader';
spinner.className = 'w-5 h-5 animate-spin text-white';
button.replaceChildren(spinner);
createIcons({ icons });
await action();
} finally {
button.disabled = false;
button.style.pointerEvents = originalPointerEvents;
button.innerHTML = originalContent;
button.replaceChildren(...originalChildren);
}
}
@@ -330,33 +341,122 @@ function initializeTool() {
}
});
const uploadArea = document.getElementById('upload-area');
if (uploadArea) {
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('border-indigo-500');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('border-indigo-500');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('border-indigo-500');
const files = Array.from(e.dataTransfer?.files || []).filter(
(f) => f.type === 'application/pdf'
);
if (files.length > 0) {
loadPdfs(files);
}
});
}
setupGlobalDragAndDrop();
document.getElementById('upload-area')?.classList.remove('hidden');
}
let dragOverlay: HTMLDivElement | null = null;
let isProcessingDrop = false;
function showDragOverlay() {
if (dragOverlay) return;
dragOverlay = document.createElement('div');
dragOverlay.className =
'fixed inset-0 bg-indigo-900/40 border-4 border-dashed border-indigo-400 z-40 pointer-events-none flex items-center justify-center';
const inner = document.createElement('div');
inner.className =
'text-white text-lg sm:text-xl font-semibold bg-gray-900/80 px-6 py-4 rounded-lg text-center';
inner.textContent = t('multiTool.dropToAdd');
dragOverlay.appendChild(inner);
document.body.appendChild(dragOverlay);
}
function hideDragOverlay() {
if (dragOverlay) {
dragOverlay.remove();
dragOverlay = null;
}
}
function setupGlobalDragAndDrop() {
const target = document.getElementById('main-scroll-container');
if (!target) return;
target.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
showDragOverlay();
});
document.addEventListener('dragleave', (e) => {
if (
e.clientX <= 0 ||
e.clientY <= 0 ||
e.clientX >= window.innerWidth ||
e.clientY >= window.innerHeight
) {
hideDragOverlay();
}
});
document.addEventListener('drop', () => {
hideDragOverlay();
});
target.addEventListener('drop', (e) => {
e.preventDefault();
hideDragOverlay();
const files = Array.from(e.dataTransfer?.files || []);
if (files.length === 0) return;
void handleIncomingFiles(files);
});
}
async function processIncomingFiles(rawFiles: File[]): Promise<File[]> {
const { pdfFiles, imageFiles, skipped } = partitionIncomingFiles(rawFiles);
const result: File[] = [...pdfFiles];
if (imageFiles.length > 0) {
try {
showLoading(0, 100);
const loadingText = document.getElementById('loading-text');
if (loadingText)
loadingText.textContent = t('multiTool.convertingImages');
const imagesPdfFile = await convertImagesToPdfFile(imageFiles);
result.push(imagesPdfFile);
} catch (e) {
console.error('Failed to convert images to PDF:', e);
hideLoading();
showModal(
t('multiTool.error'),
t('multiTool.failedToConvertImages'),
'error'
);
}
}
if (skipped.length > 0) {
console.warn('Skipped unsupported files:', skipped);
}
return result;
}
async function handleIncomingFiles(rawFiles: File[]) {
if (isRendering || isProcessingDrop) {
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info');
return;
}
isProcessingDrop = true;
try {
const processed = await processIncomingFiles(rawFiles);
if (processed.length > 0) {
await loadPdfs(processed);
} else {
hideLoading();
}
} finally {
isProcessingDrop = false;
}
}
function resetAll() {
renderCancelled = true;
isRendering = false;
isProcessingDrop = false;
hideDragOverlay();
snapshot();
allPages = [];
selectedPages.clear();
@@ -382,7 +482,7 @@ async function handlePdfUpload(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length > 0) {
await loadPdfs(files);
await handleIncomingFiles(files);
}
input.value = '';
}
@@ -524,20 +624,6 @@ async function loadPdfs(files: File[]) {
}
}
function getCacheKey(pdfIndex: number, pageIndex: number): string {
return `${pdfIndex}-${pageIndex}`;
}
// Wrapper for compatibility with updatePageDisplay
function createPageCard(pageData: PageData, index: number) {
const pagesContainer = document.getElementById('pages-container');
if (!pagesContainer) return;
const card = createPageElement(pageData.canvas, index);
pagesContainer.appendChild(card);
createIcons({ icons });
}
// Modified to return the element instead of appending it
function createPageElement(
canvas: HTMLCanvasElement | null,
@@ -584,10 +670,13 @@ function createPageElement(
const loading = document.createElement('div');
loading.className =
'flex flex-col items-center justify-center text-gray-400';
loading.innerHTML = `
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
<span class="text-xs">${t('common.loading')}</span>
`;
const loadingIcon = document.createElement('i');
loadingIcon.dataset.lucide = 'loader';
loadingIcon.className = 'w-8 h-8 animate-spin mb-2';
const loadingLabel = document.createElement('span');
loadingLabel.className = 'text-xs';
loadingLabel.textContent = t('common.loading');
loading.append(loadingIcon, loadingLabel);
preview.appendChild(loading);
preview.classList.add('bg-gray-700'); // Darker background for loading
}
@@ -611,9 +700,15 @@ function createPageElement(
const selectBtn = document.createElement('button');
selectBtn.className =
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
selectBtn.innerHTML = selectedPages.has(index)
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
const selectIcon = document.createElement('i');
if (selectedPages.has(index)) {
selectIcon.dataset.lucide = 'check-square';
selectIcon.className = 'w-4 h-4 text-indigo-400';
} else {
selectIcon.dataset.lucide = 'square';
selectIcon.className = 'w-4 h-4 text-gray-200';
}
selectBtn.appendChild(selectIcon);
selectBtn.onclick = (e) => {
e.stopPropagation();
toggleSelectOptimized(index);
@@ -1094,7 +1189,6 @@ async function downloadAll() {
async function downloadSplitPdfs() {
try {
const zip = new JSZip();
const sortedMarkers = Array.from(splitMarkers).sort((a, b) => a - b);
// Create segments based on split markers
const segments: number[][] = [];
+115 -142
View File
@@ -2,7 +2,12 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { downloadFile, getPDFDocument, formatBytes } from '../utils/helpers.js';
import {
downloadFile,
getPDFDocument,
formatBytes,
initializeQpdf,
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { state } from '../state.js';
import {
@@ -13,8 +18,18 @@ import { initPagePreview } from '../utils/page-preview.js';
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import JSZip from 'jszip';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import type { QpdfInstanceExtended } from '@/types';
import {
parseRangeGroups,
evenOddIndices,
allPagesIndices,
nTimesGroups,
bookmarkSplitGroups,
groupFilename,
uniqueZipName,
extractPagesWithQpdf,
} from '../utils/split-pdf-helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -23,6 +38,7 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
document.addEventListener('DOMContentLoaded', () => {
let visualSelectorRendered = false;
let isSplitting = false;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
@@ -38,7 +54,8 @@ document.addEventListener('DOMContentLoaded', () => {
const rangePanel = document.getElementById('range-panel');
const visualPanel = document.getElementById('visual-select-panel');
const evenOddPanel = document.getElementById('even-odd-panel');
const zipOptionWrapper = document.getElementById('zip-option-wrapper');
const outputModeWrapper = document.getElementById('output-mode-wrapper');
const outputSeparateLabel = document.getElementById('output-separate-label');
const allPagesPanel = document.getElementById('all-pages-panel');
const bookmarksPanel = document.getElementById('bookmarks-panel');
const nTimesPanel = document.getElementById('n-times-panel');
@@ -252,6 +269,11 @@ document.addEventListener('DOMContentLoaded', () => {
) as HTMLInputElement;
if (nValueInput) nValueInput.value = '5';
const combineRadio = document.getElementById(
'output-combine'
) as HTMLInputElement;
if (combineRadio) combineRadio.checked = true;
// Reset radio buttons to default (range)
const rangeRadio = document.querySelector(
'input[name="split-mode"][value="range"]'
@@ -271,18 +293,50 @@ document.addEventListener('DOMContentLoaded', () => {
};
const split = async () => {
if (isSplitting) return;
isSplitting = true;
if (processBtn) (processBtn as HTMLButtonElement).disabled = true;
const splitMode = splitModeSelect.value;
const downloadAsZip =
(document.getElementById('download-as-zip') as HTMLInputElement)
?.checked || false;
const oneFilePerUnit =
(
document.querySelector(
'input[name="split-output-mode"]:checked'
) as HTMLInputElement | null
)?.value === 'separate';
showLoader('Splitting PDF...');
let qpdf: QpdfInstanceExtended | null = null;
const inputPath = '/split-input.pdf';
try {
if (!state.pdfDoc) throw new Error('No PDF document loaded.');
const srcDoc = state.pdfDoc;
const totalPages = state.pdfDoc.getPageCount();
const totalPages = srcDoc.getPageCount();
let indicesToExtract: number[] = [];
let outputGroups: number[][] | null = null;
let sourceBytes: Uint8Array | null = null;
const getSourceBytes = async (): Promise<Uint8Array> => {
if (sourceBytes) return sourceBytes;
sourceBytes = new Uint8Array(await srcDoc.save());
return sourceBytes;
};
const ensureQpdf = async (): Promise<QpdfInstanceExtended> => {
if (qpdf) return qpdf;
const instance = await initializeQpdf();
instance.FS.writeFile(inputPath, await getSourceBytes());
qpdf = instance;
return instance;
};
const extractPages = async (indices: number[]): Promise<Uint8Array> => {
const instance = await ensureQpdf();
return extractPagesWithQpdf(instance, inputPath, indices);
};
switch (splitMode) {
case 'range': {
@@ -290,71 +344,12 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('page-range') as HTMLInputElement
).value;
if (!pageRangeInput) throw new Error('Choose a valid page range.');
const ranges = pageRangeInput.split(',');
const rangeGroups: number[][] = [];
for (const range of ranges) {
const trimmedRange = range.trim();
if (!trimmedRange) continue;
const { groups: rangeGroups, indices: rangeIndices } =
parseRangeGroups(pageRangeInput, totalPages);
indicesToExtract.push(...rangeIndices);
const groupIndices: number[] = [];
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (
isNaN(start) ||
isNaN(end) ||
start < 1 ||
end > totalPages ||
start > end
)
continue;
for (let i = start; i <= end; i++) groupIndices.push(i - 1);
} else {
const pageNum = Number(trimmedRange);
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages)
continue;
groupIndices.push(pageNum - 1);
}
if (groupIndices.length > 0) {
rangeGroups.push(groupIndices);
indicesToExtract.push(...groupIndices);
}
}
if (rangeGroups.length > 1) {
showLoader('Creating separate PDFs for each range...');
const zip = new JSZip();
for (let i = 0; i < rangeGroups.length; i++) {
const group = rangeGroups[i];
const newPdf = await PDFLibDocument.create();
const copiedPages = await newPdf.copyPages(state.pdfDoc, group);
copiedPages.forEach((page) => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
const minPage = Math.min(...group) + 1;
const maxPage = Math.max(...group) + 1;
const filename =
minPage === maxPage
? `page-${minPage}.pdf`
: `pages-${minPage}-${maxPage}.pdf`;
zip.file(filename, pdfBytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-pages.zip');
hideLoader();
showAlert(
'Success',
`PDF split into ${rangeGroups.length} files successfully!`,
'success',
() => {
resetState();
}
);
return;
}
if (oneFilePerUnit) outputGroups = rangeGroups;
break;
}
@@ -364,21 +359,20 @@ document.addEventListener('DOMContentLoaded', () => {
) as HTMLInputElement;
if (!choiceElement)
throw new Error('Please select even or odd pages.');
const choice = choiceElement.value;
for (let i = 0; i < totalPages; i++) {
if (choice === 'even' && (i + 1) % 2 === 0)
indicesToExtract.push(i);
if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
}
const choice = choiceElement.value === 'even' ? 'even' : 'odd';
indicesToExtract = evenOddIndices(choice, totalPages);
break;
}
case 'all':
indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
indicesToExtract = allPagesIndices(totalPages);
outputGroups = indicesToExtract.map((i) => [i]);
break;
case 'visual':
indicesToExtract = Array.from(
document.querySelectorAll('.page-thumbnail-wrapper.selected')
).map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0'));
if (oneFilePerUnit)
outputGroups = [...new Set(indicesToExtract)].map((i) => [i]);
break;
case 'bookmarks': {
if (!isCpdfAvailable()) {
@@ -388,8 +382,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
const { getCpdf } = await import('../utils/cpdf-helper.js');
const cpdf = await getCpdf();
const pdfBytes = await state.pdfDoc.save();
const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
const pdf = cpdf.fromMemory(
new Uint8Array(await getSourceBytes()),
''
);
cpdf.startGetBookmarkInfo(pdf);
const bookmarkCount = cpdf.numberBookmarks();
@@ -415,27 +411,11 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('No bookmarks found at the selected level.');
}
splitPages.sort((a, b) => a - b);
const zip = new JSZip();
const bookmarkGroups = bookmarkSplitGroups(splitPages, totalPages);
for (let i = 0; i < splitPages.length; i++) {
const startPage = i === 0 ? 0 : splitPages[i];
const endPage =
i < splitPages.length - 1
? splitPages[i + 1] - 1
: totalPages - 1;
const newPdf = await PDFLibDocument.create();
const pageIndices = Array.from(
{ length: endPage - startPage + 1 },
(_, idx) => startPage + idx
);
const copiedPages = await newPdf.copyPages(
state.pdfDoc,
pageIndices
);
copiedPages.forEach((page) => newPdf.addPage(page));
const pdfBytes2 = await newPdf.save();
for (let i = 0; i < bookmarkGroups.length; i++) {
const pdfBytes2 = await extractPages(bookmarkGroups[i]);
zip.file(`split-${i + 1}.pdf`, pdfBytes2);
}
@@ -456,23 +436,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (nValue < 1) throw new Error('N must be at least 1.');
const zip2 = new JSZip();
const numSplits = Math.ceil(totalPages / nValue);
const chunks = nTimesGroups(nValue, totalPages);
for (let i = 0; i < numSplits; i++) {
const startPage = i * nValue;
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
const pageIndices = Array.from(
{ length: endPage - startPage + 1 },
(_, idx) => startPage + idx
);
const newPdf = await PDFLibDocument.create();
const copiedPages = await newPdf.copyPages(
state.pdfDoc,
pageIndices
);
copiedPages.forEach((page) => newPdf.addPage(page));
const pdfBytes3 = await newPdf.save();
for (let i = 0; i < chunks.length; i++) {
const pdfBytes3 = await extractPages(chunks[i]);
zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
}
@@ -495,31 +462,26 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('No pages were selected for splitting.');
}
if (
splitMode === 'all' ||
(['range', 'visual'].includes(splitMode) && downloadAsZip)
) {
showLoader('Creating ZIP file...');
const zip = new JSZip();
for (const index of uniqueIndices) {
const newPdf = await PDFLibDocument.create();
const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
index as number,
]);
newPdf.addPage(copiedPage);
const pdfBytes = await newPdf.save();
zip.file(`page-${index + 1}.pdf`, new Uint8Array(pdfBytes));
if (outputGroups && outputGroups.length > 0) {
if (outputGroups.length === 1) {
const pdfBytes = await extractPages(outputGroups[0]);
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
groupFilename(outputGroups[0])
);
} else {
showLoader('Creating ZIP file...');
const zip = new JSZip();
const usedNames = new Map<string, number>();
for (const group of outputGroups) {
const pdfBytes = await extractPages(group);
zip.file(uniqueZipName(groupFilename(group), usedNames), pdfBytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-pages.zip');
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-pages.zip');
} else {
const newPdf = await PDFLibDocument.create();
const copiedPages = await newPdf.copyPages(
state.pdfDoc,
uniqueIndices as number[]
);
copiedPages.forEach((page) => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
const pdfBytes = await extractPages(uniqueIndices);
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
'split-document.pdf'
@@ -542,6 +504,15 @@ document.addEventListener('DOMContentLoaded', () => {
: 'Failed to split PDF. Please check your selection.'
);
} finally {
if (qpdf) {
try {
qpdf.FS.unlink(inputPath);
} catch (cleanupError) {
console.warn('Failed to clean up qpdf input file:', cleanupError);
}
}
isSplitting = false;
if (processBtn) (processBtn as HTMLButtonElement).disabled = false;
hideLoader();
}
};
@@ -610,15 +581,19 @@ document.addEventListener('DOMContentLoaded', () => {
allPagesPanel?.classList.add('hidden');
bookmarksPanel?.classList.add('hidden');
nTimesPanel?.classList.add('hidden');
zipOptionWrapper?.classList.add('hidden');
outputModeWrapper?.classList.add('hidden');
if (nTimesWarning) nTimesWarning.classList.add('hidden');
if (mode === 'range') {
rangePanel?.classList.remove('hidden');
zipOptionWrapper?.classList.remove('hidden');
outputModeWrapper?.classList.remove('hidden');
if (outputSeparateLabel)
outputSeparateLabel.textContent = 'One PDF per range';
} else if (mode === 'visual') {
visualPanel?.classList.remove('hidden');
zipOptionWrapper?.classList.remove('hidden');
outputModeWrapper?.classList.remove('hidden');
if (outputSeparateLabel)
outputSeparateLabel.textContent = 'One PDF per page';
renderVisualSelector();
} else if (mode === 'even-odd') {
evenOddPanel?.classList.remove('hidden');
@@ -626,10 +601,8 @@ document.addEventListener('DOMContentLoaded', () => {
allPagesPanel?.classList.remove('hidden');
} else if (mode === 'bookmarks') {
bookmarksPanel?.classList.remove('hidden');
zipOptionWrapper?.classList.remove('hidden');
} else if (mode === 'n-times') {
nTimesPanel?.classList.remove('hidden');
zipOptionWrapper?.classList.remove('hidden');
const updateWarning = () => {
if (!state.pdfDoc) return;
+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;
}
+84
View File
@@ -0,0 +1,84 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { preprocessImageFile } from './image-input-utils.js';
export interface EmbeddableImage {
bytes: Uint8Array;
format: 'jpg' | 'png';
}
export async function normalizeImageToEmbeddable(
file: File
): Promise<EmbeddableImage> {
const processed = await preprocessImageFile(file);
const name = processed.name.toLowerCase();
const isJpg =
processed.type === 'image/jpeg' ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg');
const isPng = processed.type === 'image/png' || name.endsWith('.png');
if (isJpg) {
return {
bytes: new Uint8Array(await processed.arrayBuffer()),
format: 'jpg',
};
}
if (isPng) {
return {
bytes: new Uint8Array(await processed.arrayBuffer()),
format: 'png',
};
}
const url = URL.createObjectURL(processed);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () =>
reject(new Error(`Failed to decode image: ${processed.name}`));
i.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas context unavailable');
ctx.drawImage(img, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
'image/png'
);
});
return { bytes: new Uint8Array(await blob.arrayBuffer()), format: 'png' };
} finally {
URL.revokeObjectURL(url);
}
}
export async function convertImagesToPdfFile(images: File[]): Promise<File> {
const pdfDoc = await PDFLibDocument.create();
for (const img of images) {
const { bytes, format } = await normalizeImageToEmbeddable(img);
const embedded =
format === 'jpg'
? await pdfDoc.embedJpg(bytes)
: await pdfDoc.embedPng(bytes);
const page = pdfDoc.addPage([embedded.width, embedded.height]);
page.drawImage(embedded, {
x: 0,
y: 0,
width: embedded.width,
height: embedded.height,
});
}
const pdfBytes = await pdfDoc.save();
const filename =
images.length === 1
? images[0].name.replace(/\.[^.]+$/, '.pdf')
: `images-${Date.now()}.pdf`;
return new File([new Uint8Array(pdfBytes)], filename, {
type: 'application/pdf',
});
}
+5 -3
View File
@@ -299,6 +299,7 @@ export class MarkdownEditor {
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
htmlLabels: false,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
@@ -717,9 +718,10 @@ export class MarkdownEditor {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
// Mermaid already sanitizes SVG at securityLevel: 'strict' (including
// foreignObject HTML labels). A second DOMPurify pass with the SVG-only
// profile strips those labels and leaves empty flowchart nodes.
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
} catch (error) {
+25
View File
@@ -0,0 +1,25 @@
import { isValidImageFile } from './image-input-utils.js';
export interface PartitionedFiles {
pdfFiles: File[];
imageFiles: File[];
skipped: string[];
}
export function partitionIncomingFiles(rawFiles: File[]): PartitionedFiles {
const pdfFiles: File[] = [];
const imageFiles: File[] = [];
const skipped: string[] = [];
for (const f of rawFiles) {
if (f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')) {
pdfFiles.push(f);
} else if (isValidImageFile(f)) {
imageFiles.push(f);
} else {
skipped.push(f.name);
}
}
return { pdfFiles, imageFiles, skipped };
}
+4 -4
View File
@@ -101,8 +101,8 @@ function drawOcrTextLayer(
y: transform.y,
font,
size: transform.fontSize,
color: rgb(1, 0, 0),
opacity: 1,
color: rgb(0, 0, 0),
opacity: 0,
rotate: lineRotation,
});
} catch {
@@ -132,8 +132,8 @@ function drawOcrTextLayer(
y: spaceTransform.y,
font,
size: spaceTransform.fontSize,
color: rgb(1, 0, 0),
opacity: 1,
color: rgb(0, 0, 0),
opacity: 0,
rotate: lineRotation,
});
} catch {
+148
View File
@@ -0,0 +1,148 @@
import type { QpdfInstanceExtended } from '@/types';
export function parseRangeGroups(
input: string,
totalPages: number
): { groups: number[][]; indices: number[] } {
const groups: number[][] = [];
const indices: number[] = [];
for (const range of input.split(',')) {
const trimmedRange = range.trim();
if (!trimmedRange) continue;
const groupIndices: number[] = [];
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (
isNaN(start) ||
isNaN(end) ||
start < 1 ||
end > totalPages ||
start > end
)
continue;
for (let i = start; i <= end; i++) groupIndices.push(i - 1);
} else {
const pageNum = Number(trimmedRange);
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
groupIndices.push(pageNum - 1);
}
if (groupIndices.length > 0) {
groups.push(groupIndices);
indices.push(...groupIndices);
}
}
return { groups, indices };
}
export function evenOddIndices(
choice: 'even' | 'odd',
totalPages: number
): number[] {
const indices: number[] = [];
for (let i = 0; i < totalPages; i++) {
if (choice === 'even' && (i + 1) % 2 === 0) indices.push(i);
if (choice === 'odd' && (i + 1) % 2 !== 0) indices.push(i);
}
return indices;
}
export function allPagesIndices(totalPages: number): number[] {
return Array.from({ length: totalPages }, (_, i) => i);
}
export function nTimesGroups(nValue: number, totalPages: number): number[][] {
const groups: number[][] = [];
const numSplits = Math.ceil(totalPages / nValue);
for (let i = 0; i < numSplits; i++) {
const startPage = i * nValue;
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
groups.push(
Array.from(
{ length: endPage - startPage + 1 },
(_, idx) => startPage + idx
)
);
}
return groups;
}
export function bookmarkSplitGroups(
splitPages: number[],
totalPages: number
): number[][] {
const sorted = [...splitPages].sort((a, b) => a - b);
const groups: number[][] = [];
for (let i = 0; i < sorted.length; i++) {
const startPage = i === 0 ? 0 : sorted[i];
const endPage = i < sorted.length - 1 ? sorted[i + 1] - 1 : totalPages - 1;
groups.push(
Array.from(
{ length: endPage - startPage + 1 },
(_, idx) => startPage + idx
)
);
}
return groups;
}
export function groupFilename(group: number[]): string {
const minPage = Math.min(...group) + 1;
const maxPage = Math.max(...group) + 1;
return minPage === maxPage
? `page-${minPage}.pdf`
: `pages-${minPage}-${maxPage}.pdf`;
}
export function uniqueZipName(
name: string,
usedNames: Map<string, number>
): string {
const seen = usedNames.get(name);
if (seen !== undefined) {
usedNames.set(name, seen + 1);
return name.replace(/\.pdf$/, `-${seen + 1}.pdf`);
}
usedNames.set(name, 0);
return name;
}
export function pagesToSpec(indices: number[]): string {
return indices.map((index) => index + 1).join(',');
}
export function extractPagesWithQpdf(
qpdf: QpdfInstanceExtended,
inputPath: string,
indices: number[],
outputPath = '/split-output.pdf'
): Uint8Array {
const exitCode = qpdf.callMain([
inputPath,
'--remove-unreferenced-resources=yes',
'--pages',
'.',
pagesToSpec(indices),
'--',
outputPath,
]);
try {
if (exitCode !== 0 && exitCode !== 3) {
throw new Error(`Failed to extract pages (qpdf exit code ${exitCode}).`);
}
const bytes = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
if (!bytes || bytes.length === 0) {
throw new Error('Page extraction produced an empty PDF.');
}
return bytes;
} finally {
try {
qpdf.FS.unlink(outputPath);
} catch (cleanupError) {
console.warn('Failed to clean up qpdf output file:', cleanupError);
}
}
}
+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
);
}
}
+3 -3
View File
@@ -383,18 +383,18 @@
class="text-gray-300 text-base sm:text-lg mb-1 sm:mb-2"
data-i18n="multiTool.uploadPdfFiles"
>
Select PDF Files
Select PDF or image files
</p>
<p
class="text-gray-400 text-xs sm:text-sm mb-3 sm:mb-4"
data-i18n="multiTool.dragAndDrop"
>
Drag and drop PDF files here, or click to select
Drag and drop PDF or image files here, or click to select
</p>
<input
type="file"
id="pdf-file-input"
accept="application/pdf"
accept="application/pdf,image/*,.heic,.heif,.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.webp,.svg,.psd,.jp2,.jpx,.jxr,.pnm,.pgm,.pbm,.ppm,.pam"
multiple
class="hidden"
/>
+35 -11
View File
@@ -359,17 +359,41 @@
</p>
</div>
<div id="zip-option-wrapper" class="mt-4 flex items-center">
<input
id="download-as-zip"
type="checkbox"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
/>
<label
for="download-as-zip"
class="ml-2 text-sm font-medium text-gray-300"
>Download as ZIP (for multiple files)</label
>
<div id="output-mode-wrapper" class="mt-4">
<p class="block mb-2 text-sm font-medium text-gray-300">Output</p>
<div class="flex items-center mb-2">
<input
id="output-combine"
type="radio"
value="combine"
name="split-output-mode"
checked
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
/>
<label
for="output-combine"
class="ml-2 text-sm font-medium text-gray-300"
>Single combined PDF</label
>
</div>
<div class="flex items-center">
<input
id="output-separate"
type="radio"
value="separate"
name="split-output-mode"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
/>
<label
for="output-separate"
id="output-separate-label"
class="ml-2 text-sm font-medium text-gray-300"
>One PDF per range</label
>
</div>
<p class="mt-2 text-xs text-gray-400">
Multiple files are downloaded together as a ZIP.
</p>
</div>
<button id="process-btn" class="btn-gradient w-full mt-6">
+1 -1
View File
@@ -6,7 +6,7 @@ import { resolveFontUrl } from '../js/utils/font-loader';
describe('font-loader', () => {
it('uses the default public font URL when no offline font base URL is configured', () => {
expect(resolveFontUrl('Noto Sans', {})).toBe(
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
);
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('heic2any', () => ({
default: vi.fn(async ({ blob }: { blob: Blob }) => {
const bytes = new Uint8Array(await blob.arrayBuffer());
return new Blob([bytes], { type: 'image/png' });
}),
}));
const mockEmbedJpg = vi.fn();
const mockEmbedPng = vi.fn();
const mockDrawImage = vi.fn();
const mockAddPage = vi.fn();
const mockSave = vi.fn();
vi.mock('pdf-lib', () => {
return {
PDFDocument: {
create: vi.fn(async () => ({
embedJpg: mockEmbedJpg,
embedPng: mockEmbedPng,
addPage: mockAddPage,
save: mockSave,
})),
},
};
});
import {
normalizeImageToEmbeddable,
convertImagesToPdfFile,
} from '../js/utils/images-to-pdf-lib';
function makeFile(name: string, type: string, bytes = [1, 2, 3]): File {
return new File([new Uint8Array(bytes)], name, { type });
}
describe('normalizeImageToEmbeddable', () => {
beforeEach(() => {
mockEmbedJpg.mockReset();
mockEmbedPng.mockReset();
mockDrawImage.mockReset();
mockAddPage.mockReset();
mockSave.mockReset();
});
it('passes through JPG bytes unchanged when MIME is image/jpeg', async () => {
const file = makeFile('photo.jpg', 'image/jpeg', [0xff, 0xd8, 0xff, 0xe0]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('jpg');
expect(Array.from(result.bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
});
it('passes through JPG by extension when MIME is missing', async () => {
const file = makeFile('photo.jpeg', '', [0xff, 0xd8]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('jpg');
});
it('passes through PNG bytes unchanged when MIME is image/png', async () => {
const file = makeFile('scan.png', 'image/png', [0x89, 0x50, 0x4e, 0x47]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
expect(Array.from(result.bytes)).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
it('passes through PNG by uppercase extension when MIME is missing', async () => {
const file = makeFile('SCAN.PNG', '', [0x89, 0x50]);
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
});
describe('canvas-decode path (non-JPG/PNG formats)', () => {
const originalCreateElement = document.createElement.bind(document);
const originalImage = globalThis.Image;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const revokeSpy = vi.fn();
beforeEach(() => {
revokeSpy.mockReset();
URL.createObjectURL = vi.fn(() => 'blob:test');
URL.revokeObjectURL = revokeSpy;
globalThis.Image = class {
naturalWidth = 100;
naturalHeight = 50;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
setTimeout(() => this.onload?.(), 0);
}
} as unknown as typeof Image;
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
toBlob: vi.fn((cb: (b: Blob) => void) => {
cb(new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' }));
}),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
});
afterEach(() => {
document.createElement = originalCreateElement;
globalThis.Image = originalImage;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('decodes a BMP through canvas and returns PNG bytes', async () => {
const file = makeFile('scan.bmp', 'image/bmp');
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
expect(Array.from(result.bytes)).toEqual([0x89, 0x50]);
});
it('decodes a TIFF through canvas', async () => {
const file = makeFile('page.tiff', 'image/tiff');
const result = await normalizeImageToEmbeddable(file);
expect(result.format).toBe('png');
});
it('revokes the object URL after success', async () => {
const file = makeFile('scan.bmp', 'image/bmp');
await normalizeImageToEmbeddable(file);
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
it('revokes the object URL even on Image decode error', async () => {
globalThis.Image = class {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
setTimeout(() => this.onerror?.(), 0);
}
} as unknown as typeof Image;
const file = makeFile('broken.gif', 'image/gif');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Failed to decode image/
);
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
});
it('rejects when canvas getContext returns null', async () => {
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => null),
toBlob: vi.fn(),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
const file = makeFile('scan.bmp', 'image/bmp');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Canvas context unavailable/
);
expect(revokeSpy).toHaveBeenCalled();
});
it('rejects when canvas.toBlob returns null', async () => {
document.createElement = ((tagName: string) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
toBlob: vi.fn((cb: (b: Blob | null) => void) => cb(null)),
} as unknown as HTMLCanvasElement;
}) as typeof document.createElement;
const file = makeFile('scan.bmp', 'image/bmp');
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
/Canvas toBlob failed/
);
expect(revokeSpy).toHaveBeenCalled();
});
});
});
describe('convertImagesToPdfFile', () => {
beforeEach(() => {
mockEmbedJpg.mockReset();
mockEmbedPng.mockReset();
mockDrawImage.mockReset();
mockAddPage.mockReset();
mockSave.mockReset();
mockEmbedJpg.mockResolvedValue({ width: 300, height: 200 });
mockEmbedPng.mockResolvedValue({ width: 600, height: 400 });
mockAddPage.mockReturnValue({ drawImage: mockDrawImage });
mockSave.mockResolvedValue(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
});
it('embeds a single JPG as one page and names file after image', async () => {
const file = makeFile('vacation.jpg', 'image/jpeg', [0xff, 0xd8]);
const result = await convertImagesToPdfFile([file]);
expect(mockEmbedJpg).toHaveBeenCalledTimes(1);
expect(mockEmbedPng).not.toHaveBeenCalled();
expect(mockAddPage).toHaveBeenCalledWith([300, 200]);
expect(mockDrawImage).toHaveBeenCalledWith(
expect.objectContaining({ width: 300, height: 200 }),
{ x: 0, y: 0, width: 300, height: 200 }
);
expect(result.name).toBe('vacation.pdf');
expect(result.type).toBe('application/pdf');
});
it('embeds a single PNG and names file after image', async () => {
const file = makeFile('scan.png', 'image/png', [0x89, 0x50]);
const result = await convertImagesToPdfFile([file]);
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
expect(mockEmbedJpg).not.toHaveBeenCalled();
expect(mockAddPage).toHaveBeenCalledWith([600, 400]);
expect(result.name).toBe('scan.pdf');
});
it('embeds multiple images and uses generic filename', async () => {
const files = [
makeFile('a.jpg', 'image/jpeg'),
makeFile('b.png', 'image/png'),
makeFile('c.jpg', 'image/jpeg'),
];
const result = await convertImagesToPdfFile(files);
expect(mockEmbedJpg).toHaveBeenCalledTimes(2);
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
expect(mockAddPage).toHaveBeenCalledTimes(3);
expect(result.name).toMatch(/^images-\d+\.pdf$/);
});
it('produces a PDF File with correct MIME type', async () => {
const file = makeFile('a.jpg', 'image/jpeg');
const result = await convertImagesToPdfFile([file]);
expect(result.type).toBe('application/pdf');
expect(result).toBeInstanceOf(File);
});
it('strips the original extension when generating single-file name', async () => {
const file = makeFile('photo.JPEG', 'image/jpeg');
const result = await convertImagesToPdfFile([file]);
expect(result.name).toBe('photo.pdf');
});
it('propagates errors from pdf-lib embed step', async () => {
mockEmbedJpg.mockRejectedValueOnce(new Error('embed failed'));
const file = makeFile('bad.jpg', 'image/jpeg');
await expect(convertImagesToPdfFile([file])).rejects.toThrow(
/embed failed/
);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import DOMPurify, { type Config } from 'dompurify';
// Broken when applied after mermaid.render() — strips foreignObject labels.
const BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS: Config = {
USE_PROFILES: { svg: true, svgFilters: true },
};
/** Matches Mermaid 11 strict-mode output sanitization in mermaid.render(). */
const MERMAID_STRICT_SVG_SANITIZE_OPTIONS: Config = {
ADD_TAGS: ['foreignobject'],
ADD_ATTR: ['dominant-baseline'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
};
describe('Markdown to PDF — Mermaid SVG sanitization', () => {
it('documents that SVG-only DOMPurify strips foreignObject labels (regression guard)', () => {
const htmlLabelsSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<g class="node">
<rect width="100" height="50"/>
<foreignObject width="100" height="50">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex">
<span>Start</span>
</div>
</foreignObject>
</g>
</svg>`;
const clean = DOMPurify.sanitize(
htmlLabelsSvg,
BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS
);
expect(clean).not.toContain('Start');
expect(clean).not.toContain('foreignObject');
});
it('preserves foreignObject labels with Mermaid strict sanitizer settings', () => {
const htmlLabelsSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="40">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex">
<span class="nodeLabel">Start</span>
</div>
</foreignObject>
</svg>`;
const clean = DOMPurify.sanitize(
htmlLabelsSvg,
MERMAID_STRICT_SVG_SANITIZE_OPTIONS
);
expect(clean).toContain('Start');
expect(clean).toContain('foreignObject');
expect(clean).toContain('nodeLabel');
});
it('preserves native SVG text labels under SVG-only profile', () => {
const svgTextLabels = `<svg xmlns="http://www.w3.org/2000/svg">
<g class="node">
<rect width="100" height="50"/>
<text x="10" y="20" fill="#333">Start</text>
</g>
</svg>`;
const clean = DOMPurify.sanitize(
svgTextLabels,
BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS
);
expect(clean).toContain('Start');
expect(clean).toContain('<text');
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('heic2any', () => ({
default: vi.fn(),
}));
import { partitionIncomingFiles } from '../js/utils/multi-tool-file-input';
function makeFile(name: string, type: string): File {
return new File([new Uint8Array([1, 2, 3])], name, { type });
}
describe('partitionIncomingFiles', () => {
it('routes PDFs by MIME type', () => {
const result = partitionIncomingFiles([
makeFile('a.pdf', 'application/pdf'),
makeFile('b.pdf', 'application/pdf'),
]);
expect(result.pdfFiles).toHaveLength(2);
expect(result.imageFiles).toHaveLength(0);
expect(result.skipped).toHaveLength(0);
});
it('routes PDFs by extension when MIME is missing', () => {
const result = partitionIncomingFiles([makeFile('doc.pdf', '')]);
expect(result.pdfFiles).toHaveLength(1);
expect(result.imageFiles).toHaveLength(0);
});
it('routes PDFs with uppercase extension', () => {
const result = partitionIncomingFiles([makeFile('SCAN.PDF', '')]);
expect(result.pdfFiles).toHaveLength(1);
});
it('routes images by MIME type', () => {
const result = partitionIncomingFiles([
makeFile('a.png', 'image/png'),
makeFile('b.jpg', 'image/jpeg'),
makeFile('c.webp', 'image/webp'),
]);
expect(result.imageFiles).toHaveLength(3);
expect(result.pdfFiles).toHaveLength(0);
});
it('routes images by extension when MIME is missing (HEIC, BMP, TIFF)', () => {
const result = partitionIncomingFiles([
makeFile('photo.heic', ''),
makeFile('scan.bmp', ''),
makeFile('page.tiff', ''),
]);
expect(result.imageFiles).toHaveLength(3);
expect(result.skipped).toHaveLength(0);
});
it('splits a mixed batch into PDFs, images, and skipped', () => {
const result = partitionIncomingFiles([
makeFile('doc.pdf', 'application/pdf'),
makeFile('photo.png', 'image/png'),
makeFile('readme.txt', 'text/plain'),
makeFile('archive.zip', 'application/zip'),
]);
expect(result.pdfFiles).toHaveLength(1);
expect(result.imageFiles).toHaveLength(1);
expect(result.skipped).toEqual(['readme.txt', 'archive.zip']);
});
it('preserves original File order within each bucket', () => {
const a = makeFile('a.pdf', 'application/pdf');
const b = makeFile('b.png', 'image/png');
const c = makeFile('c.pdf', 'application/pdf');
const d = makeFile('d.jpg', 'image/jpeg');
const result = partitionIncomingFiles([a, b, c, d]);
expect(result.pdfFiles).toEqual([a, c]);
expect(result.imageFiles).toEqual([b, d]);
});
it('returns empty buckets for empty input', () => {
const result = partitionIncomingFiles([]);
expect(result.pdfFiles).toHaveLength(0);
expect(result.imageFiles).toHaveLength(0);
expect(result.skipped).toHaveLength(0);
});
it('skips files with neither matching MIME nor extension', () => {
const result = partitionIncomingFiles([
makeFile('script.js', 'application/javascript'),
makeFile('data.csv', 'text/csv'),
]);
expect(result.skipped).toHaveLength(2);
expect(result.pdfFiles).toHaveLength(0);
expect(result.imageFiles).toHaveLength(0);
});
});
+364
View File
@@ -0,0 +1,364 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import createModule from '@neslinesli93/qpdf-wasm';
import { PDFDocument } from 'pdf-lib';
import type { QpdfInstanceExtended } from '@/types';
import {
parseRangeGroups,
evenOddIndices,
allPagesIndices,
nTimesGroups,
bookmarkSplitGroups,
groupFilename,
uniqueZipName,
pagesToSpec,
extractPagesWithQpdf,
} from '@/js/utils/split-pdf-helpers';
const FIXTURES = path.resolve(__dirname, './fixtures');
describe('split-pdf-helpers (pure planning)', () => {
describe('parseRangeGroups', () => {
it('parses a single page into one group', () => {
expect(parseRangeGroups('3', 10)).toEqual({
groups: [[2]],
indices: [2],
});
});
it('parses a hyphen range into one consecutive group', () => {
expect(parseRangeGroups('2-5', 10)).toEqual({
groups: [[1, 2, 3, 4]],
indices: [1, 2, 3, 4],
});
});
it('keeps disjoint ranges as separate groups, indices flattened in order', () => {
expect(parseRangeGroups('1-5, 8, 11-13', 20)).toEqual({
groups: [[0, 1, 2, 3, 4], [7], [10, 11, 12]],
indices: [0, 1, 2, 3, 4, 7, 10, 11, 12],
});
});
it('preserves the user-entered order (does not sort)', () => {
expect(parseRangeGroups('8, 1-2', 10)).toEqual({
groups: [[7], [0, 1]],
indices: [7, 0, 1],
});
});
it('skips whitespace and empty parts', () => {
expect(parseRangeGroups(' 1 , , 3-4 ', 10)).toEqual({
groups: [[0], [2, 3]],
indices: [0, 2, 3],
});
});
it('skips out-of-bounds, reversed, and non-numeric ranges', () => {
expect(parseRangeGroups('0-2, 9-5, abc, 3', 4)).toEqual({
groups: [[2]],
indices: [2],
});
});
it('skips a range whose end exceeds the page count', () => {
expect(parseRangeGroups('1-100', 5)).toEqual({ groups: [], indices: [] });
});
it('returns empty for an all-invalid input', () => {
expect(parseRangeGroups('99, abc', 5)).toEqual({
groups: [],
indices: [],
});
});
});
describe('evenOddIndices', () => {
it('selects even pages (1-based) as 0-based indices', () => {
expect(evenOddIndices('even', 6)).toEqual([1, 3, 5]);
});
it('selects odd pages', () => {
expect(evenOddIndices('odd', 6)).toEqual([0, 2, 4]);
});
it('handles odd total page counts', () => {
expect(evenOddIndices('even', 5)).toEqual([1, 3]);
expect(evenOddIndices('odd', 5)).toEqual([0, 2, 4]);
});
it('handles a single-page document', () => {
expect(evenOddIndices('odd', 1)).toEqual([0]);
expect(evenOddIndices('even', 1)).toEqual([]);
});
});
describe('allPagesIndices', () => {
it('returns every page index', () => {
expect(allPagesIndices(4)).toEqual([0, 1, 2, 3]);
});
it('returns empty for zero pages', () => {
expect(allPagesIndices(0)).toEqual([]);
});
});
describe('nTimesGroups', () => {
it('splits evenly when divisible', () => {
expect(nTimesGroups(2, 6)).toEqual([
[0, 1],
[2, 3],
[4, 5],
]);
});
it('puts the remainder in the final group', () => {
expect(nTimesGroups(2, 5)).toEqual([[0, 1], [2, 3], [4]]);
});
it('returns a single group when N >= total', () => {
expect(nTimesGroups(10, 3)).toEqual([[0, 1, 2]]);
});
it('produces one group per page when N is 1', () => {
expect(nTimesGroups(1, 3)).toEqual([[0], [1], [2]]);
});
});
describe('bookmarkSplitGroups', () => {
it('builds consecutive ranges from boundary pages, first section starts at 0', () => {
expect(bookmarkSplitGroups([2, 4], 8)).toEqual([
[0, 1, 2, 3],
[4, 5, 6, 7],
]);
});
it('sorts unsorted boundaries before grouping', () => {
expect(bookmarkSplitGroups([4, 2], 8)).toEqual([
[0, 1, 2, 3],
[4, 5, 6, 7],
]);
});
it('treats a lone boundary as one whole-document group (preserves existing behavior)', () => {
expect(bookmarkSplitGroups([3], 5)).toEqual([[0, 1, 2, 3, 4]]);
});
it('splits at every boundary after the first', () => {
expect(bookmarkSplitGroups([2, 4, 6], 8)).toEqual([
[0, 1, 2, 3],
[4, 5],
[6, 7],
]);
});
});
describe('groupFilename', () => {
it('names a single-page group page-N.pdf', () => {
expect(groupFilename([4])).toBe('page-5.pdf');
});
it('names a multi-page group pages-min-max.pdf', () => {
expect(groupFilename([0, 1, 2, 3, 4])).toBe('pages-1-5.pdf');
});
});
describe('uniqueZipName', () => {
it('returns the name unchanged on first use', () => {
const used = new Map<string, number>();
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1.pdf');
});
it('suffixes collisions instead of overwriting', () => {
const used = new Map<string, number>();
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1.pdf');
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1-1.pdf');
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1-2.pdf');
});
});
describe('pagesToSpec', () => {
it('converts 0-based indices to a 1-based comma-separated qpdf spec', () => {
expect(pagesToSpec([0, 1, 7])).toBe('1,2,8');
});
it('preserves order for non-ascending selections', () => {
expect(pagesToSpec([7, 0, 1])).toBe('8,1,2');
});
});
});
describe('extractPagesWithQpdf (error handling, stubbed)', () => {
function stubQpdf(
exitCode: number,
output: Uint8Array
): QpdfInstanceExtended {
return {
callMain: () => exitCode,
FS: {
mkdir: () => {},
mount: () => {},
unmount: () => {},
writeFile: () => {},
readFile: () => output,
unlink: () => {},
analyzePath: () => ({ exists: true }),
},
};
}
it('throws on a hard qpdf error (exit code 2)', () => {
const qpdf = stubQpdf(2, new Uint8Array([1, 2, 3]));
expect(() => extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toThrow(
/exit code 2/
);
});
it('throws when qpdf produces an empty file', () => {
const qpdf = stubQpdf(0, new Uint8Array());
expect(() => extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toThrow(
/empty PDF/
);
});
it('accepts exit code 3 (warnings) with valid output', () => {
const out = new Uint8Array([37, 80, 68, 70]);
const qpdf = stubQpdf(3, out);
expect(extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toBe(out);
});
});
describe('split modes end-to-end with real qpdf', () => {
let qpdf: QpdfInstanceExtended;
async function makePdf(pageCount: number): Promise<Uint8Array> {
const doc = await PDFDocument.create();
for (let i = 0; i < pageCount; i++) doc.addPage([100 + i, 200]);
return new Uint8Array(await doc.save());
}
async function originalIndices(bytes: Uint8Array): Promise<number[]> {
const doc = await PDFDocument.load(bytes);
return doc.getPages().map((p) => Math.round(p.getWidth()) - 100);
}
function extract(source: Uint8Array, indices: number[]): Uint8Array {
qpdf.FS.writeFile('/in.pdf', source);
try {
return extractPagesWithQpdf(qpdf, '/in.pdf', indices);
} finally {
qpdf.FS.unlink('/in.pdf');
}
}
beforeAll(async () => {
const wasmBinary = fs.readFileSync(
path.resolve(
process.cwd(),
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
)
);
qpdf = await (
createModule as unknown as (o: object) => Promise<QpdfInstanceExtended>
)({ wasmBinary, noInitialRun: true });
});
it('range (combine): disjoint ranges -> one PDF with the right pages in order', async () => {
const src = await makePdf(20);
const { indices } = parseRangeGroups('1-5, 8, 11-13', 20);
const out = extract(src, indices);
expect(await originalIndices(out)).toEqual([0, 1, 2, 3, 4, 7, 10, 11, 12]);
});
it('range (separate): each range group -> its own PDF', async () => {
const src = await makePdf(20);
const { groups } = parseRangeGroups('1-5, 8, 11-13', 20);
const outputs = groups.map((g) => extract(src, g));
const counts = await Promise.all(
outputs.map(async (o) => (await PDFDocument.load(o)).getPageCount())
);
expect(counts).toEqual([5, 1, 3]);
expect(groups.map(groupFilename)).toEqual([
'pages-1-5.pdf',
'page-8.pdf',
'pages-11-13.pdf',
]);
});
it('even-odd: extracts the correct pages', async () => {
const src = await makePdf(6);
expect(
await originalIndices(extract(src, evenOddIndices('even', 6)))
).toEqual([1, 3, 5]);
expect(
await originalIndices(extract(src, evenOddIndices('odd', 6)))
).toEqual([0, 2, 4]);
});
it('all: one single-page PDF per page', async () => {
const src = await makePdf(4);
const groups = allPagesIndices(4).map((i) => [i]);
const outputs = groups.map((g) => extract(src, g));
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
expect(ids).toEqual([[0], [1], [2], [3]]);
});
it('n-times: chunks of N pages with remainder in the last file', async () => {
const src = await makePdf(5);
const groups = nTimesGroups(2, 5);
const outputs = groups.map((g) => extract(src, g));
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
expect(ids).toEqual([[0, 1], [2, 3], [4]]);
});
it('bookmarks: boundary pages -> consecutive section PDFs', async () => {
const src = await makePdf(8);
const groups = bookmarkSplitGroups([2, 4], 8);
const outputs = groups.map((g) => extract(src, g));
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
expect(ids).toEqual([
[0, 1, 2, 3],
[4, 5, 6, 7],
]);
});
it('produces a single valid one-page PDF for a single index', async () => {
const src = await makePdf(3);
const out = extract(src, [1]);
const doc = await PDFDocument.load(out);
expect(doc.getPageCount()).toBe(1);
expect(Math.round(doc.getPage(0).getWidth())).toBe(101);
});
it('fixes resource bloat: a single-page extract is far smaller than pdf-lib copyPages', async () => {
const src = new Uint8Array(
fs.readFileSync(path.join(FIXTURES, 'shared-resources.pdf'))
);
const srcDoc = await PDFDocument.load(src);
const plDoc = await PDFDocument.create();
(await plDoc.copyPages(srcDoc, [0])).forEach((p) => plDoc.addPage(p));
const pdfLibSize = (await plDoc.save()).length;
const qpdfSize = extract(src, [0]).length;
expect(qpdfSize).toBeLessThan(pdfLibSize * 0.5);
});
it('preserves the document outline that pdf-lib copyPages drops', async () => {
const src = new Uint8Array(
fs.readFileSync(path.join(FIXTURES, 'bookmarked.pdf'))
);
const srcDoc = await PDFDocument.load(src);
const plDoc = await PDFDocument.create();
(await plDoc.copyPages(srcDoc, [0, 1, 2])).forEach((p) => plDoc.addPage(p));
const pdfLibOut = new TextDecoder('latin1').decode(await plDoc.save());
const qpdfOut = new TextDecoder('latin1').decode(extract(src, [0, 1, 2]));
expect(pdfLibOut).not.toContain('/Outlines');
expect(qpdfOut).toContain('/Outlines');
});
});
+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', () => {
+1
View File
@@ -34,6 +34,7 @@ const SUPPORTED_LANGUAGES = [
'ko',
'ja',
'uk',
'sk',
] as const;
const LANG_REGEX = new RegExp(
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`