mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-06 08:08:38 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98c65d2b45 | ||
|
|
1e7ef30fac | ||
|
|
0eb7dc7114 | ||
|
|
9e96abfbb1 | ||
|
|
9220507f63 | ||
|
|
0882bc9bc0 | ||
|
|
85d90c3382 | ||
|
|
88260c26ab | ||
|
|
582e5bf9cb | ||
|
|
6e47777c15 | ||
|
|
2e64e8e8d4 | ||
|
|
93d012c92a | ||
|
|
f5953d8c87 | ||
|
|
64153e698b | ||
|
|
518c18664d | ||
|
|
dbbad3f5d9 | ||
|
|
3e38e5b175 | ||
|
|
3fb779b231 | ||
|
|
988949dbf2 | ||
|
|
4f7f477898 | ||
|
|
c94d15f305 | ||
|
|
9283e89f19 | ||
|
|
a386fd26fc | ||
|
|
32ba24782f | ||
|
|
a119837c8a | ||
|
|
b9499b228a | ||
|
|
3cae20a10c | ||
|
|
f7a1eef5ec | ||
|
|
7351cb2e32 | ||
|
|
7313fa08a8 | ||
|
|
1174a6dbaa | ||
|
|
c37218b86d | ||
|
|
efa91e7a72 | ||
|
|
c8391d5886 | ||
|
|
43d747b3c5 | ||
|
|
e6c8eaf23c | ||
|
|
832e28aaca | ||
|
|
404dd544ab | ||
|
|
87d37a8be4 | ||
|
|
561ac12304 | ||
|
|
450815db96 | ||
|
|
eea81e7fd6 | ||
|
|
dffcfb77f3 | ||
|
|
b4ff2f68f8 | ||
|
|
1c7cc36472 | ||
|
|
b42d991157 | ||
|
|
f129b732ae |
+1
-1
@@ -8,7 +8,7 @@ VITE_CORS_PROXY_SECRET=
|
|||||||
# WASM Module URLs
|
# WASM Module URLs
|
||||||
# Pre-configured defaults enable advanced PDF features out of the box.
|
# Pre-configured defaults enable advanced PDF features out of the box.
|
||||||
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs.
|
|||||||
| **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. |
|
| **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. |
|
||||||
| **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. |
|
| **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. |
|
||||||
| **Add Page Numbers** | Easily add page numbers with customizable formatting. |
|
| **Add Page Numbers** | Easily add page numbers with customizable formatting. |
|
||||||
|
| **Bates Numbering** | Add sequential Bates numbers across one or more PDF files. |
|
||||||
| **Add Watermark** | Add text or image watermarks to protect your documents. |
|
| **Add Watermark** | Add text or image watermarks to protect your documents. |
|
||||||
| **Header & Footer** | Add customizable headers and footers. |
|
| **Header & Footer** | Add customizable headers and footers. |
|
||||||
| **Crop PDF** | Crop specific pages or the entire document. |
|
| **Crop PDF** | Crop specific pages or the entire document. |
|
||||||
@@ -457,7 +458,7 @@ Advanced PDF features (PyMuPDF, Ghostscript, CoherentPDF) are pre-configured to
|
|||||||
The default URLs are set in `.env.production`:
|
The default URLs are set in `.env.production`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
```
|
```
|
||||||
@@ -824,7 +825,7 @@ For detailed security configuration, see [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
### Digital Signature CORS Proxy (Required)
|
### Digital Signature CORS Proxy (Required)
|
||||||
|
|
||||||
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser.
|
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority providers. Since many certificate servers don't include CORS headers (and often serve over HTTP, which is blocked by browsers on HTTPS sites), a proxy is required for this feature to work.
|
||||||
|
|
||||||
**When is the proxy needed?**
|
**When is the proxy needed?**
|
||||||
|
|
||||||
@@ -846,30 +847,48 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
|
|||||||
npx wrangler login
|
npx wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the worker:**
|
3. **Update allowed origins** — open `cors-proxy-worker.js` and change `ALLOWED_ORIGINS` to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Without this step, the proxy will reject all requests from your site with a 403 error. The default only allows `bentopdf.com`.
|
||||||
|
|
||||||
|
4. **Deploy the worker:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
5. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
|
||||||
|
|
||||||
5. **Set the environment variable when building:**
|
6. **Set the environment variable when building:**
|
||||||
```bash
|
```bash
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
Or with Docker:
|
||||||
|
```bash
|
||||||
|
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
|
||||||
|
```
|
||||||
|
|
||||||
#### Production Security Features
|
#### Production Security Features
|
||||||
|
|
||||||
The CORS proxy includes several security measures:
|
The CORS proxy includes several security measures:
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
| ----------------------- | ------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) |
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x |
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
| **File Size Limit** | Rejects files larger than 10MB |
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
| **HMAC Signatures** | Optional client-side signing (limited protection) |
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
#### Enabling Rate Limiting (Recommended)
|
#### Enabling Rate Limiting (Recommended)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -7,10 +7,10 @@ type: application
|
|||||||
# This is the chart version. This version number should be incremented each time you make changes
|
# 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.
|
# to the chart and its templates, including the app version.
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
version: 1.0.0
|
version: 1.0.2
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
# 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
|
# 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.
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
# It is recommended to use it with quotes.
|
# It is recommended to use it with quotes.
|
||||||
appVersion: "2.1.0"
|
appVersion: '2.2.1'
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ ingress:
|
|||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
enabled: true
|
enabled: true
|
||||||
gatewayClassName: "cloudflare" # or your gateway class
|
gatewayClassName: 'cloudflare' # or your gateway class
|
||||||
|
|
||||||
httpRoute:
|
httpRoute:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -74,7 +74,7 @@ httpRoute:
|
|||||||
```yaml
|
```yaml
|
||||||
env:
|
env:
|
||||||
- name: DISABLE_IPV6
|
- name: DISABLE_IPV6
|
||||||
value: "true"
|
value: 'true'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Publish this chart to GHCR (OCI) for testing/deploying
|
## Publish this chart to GHCR (OCI) for testing/deploying
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{{- if .Values.httpRoute.enabled }}
|
{{- if .Values.httpRoute.enabled }}
|
||||||
{{- $defaultGatewayName := .Values.gateway.name -}}
|
{{- $defaultGatewayName := .Values.gateway.name -}}
|
||||||
{{- if not $defaultGatewayName -}}
|
{{- if not $defaultGatewayName -}}
|
||||||
{{- $defaultGatewayName = printf "%s-gateway" (include "chart.fullname" .) -}}
|
{{- $defaultGatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $defaultGatewayNs := .Release.Namespace -}}
|
{{- $defaultGatewayNs := .Release.Namespace -}}
|
||||||
{{- if .Values.gateway.enabled -}}
|
{{- if .Values.gateway.enabled -}}
|
||||||
@@ -31,16 +31,16 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- else if contains "NodePort" .Values.service.type }}
|
{{- else if contains "NodePort" .Values.service.type }}
|
||||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "chart.fullname" . }})
|
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "bentopdf.name" . }})
|
||||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
echo http://$NODE_IP:$NODE_PORT
|
echo http://$NODE_IP:$NODE_PORT
|
||||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||||
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "chart.fullname" . }}'
|
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "bentopdf.name" . }}'
|
||||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "chart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "bentopdf.name" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "chart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "bentopdf.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
|||||||
@@ -1,41 +1,23 @@
|
|||||||
{{/*
|
{{/*
|
||||||
Expand the name of the chart.
|
Expand the name of the bentopdf
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.name" -}}
|
{{- define "bentopdf.name" -}}
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create a default fully qualified app name.
|
|
||||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
|
||||||
If release name contains chart name it will be used as a full name.
|
|
||||||
*/}}
|
|
||||||
{{- define "chart.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Create chart name and version as used by the chart label.
|
Create chart name and version as used by the chart label.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.chart" -}}
|
{{- define "bentopdf.chart" -}}
|
||||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Common labels
|
Common labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.labels" -}}
|
{{- define "bentopdf.labels" -}}
|
||||||
helm.sh/chart: {{ include "chart.chart" . }}
|
helm.sh/chart: {{ include "bentopdf.chart" . }}
|
||||||
{{ include "chart.selectorLabels" . }}
|
{{ include "bentopdf.selectorLabels" . }}
|
||||||
{{- if .Chart.AppVersion }}
|
{{- if .Chart.AppVersion }}
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -45,8 +27,7 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|||||||
{{/*
|
{{/*
|
||||||
Selector labels
|
Selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "chart.selectorLabels" -}}
|
{{- define "bentopdf.selectorLabels" -}}
|
||||||
app.kubernetes.io/name: {{ include "chart.name" . }}
|
app.kubernetes.io/name: {{ include "bentopdf.name" . }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "chart.selectorLabels" . | nindent 6 }}
|
{{- include "bentopdf.selectorLabels" . | nindent 6 }}
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
{{- with .Values.podAnnotations }}
|
{{- with .Values.podAnnotations }}
|
||||||
@@ -16,7 +17,7 @@ spec:
|
|||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 8 }}
|
{{- include "bentopdf.labels" . | nindent 8 }}
|
||||||
{{- with .Values.podLabels }}
|
{{- with .Values.podLabels }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{- if .Values.gateway.enabled -}}
|
{{- if .Values.gateway.enabled -}}
|
||||||
{{- $gatewayName := .Values.gateway.name -}}
|
{{- $gatewayName := .Values.gateway.name -}}
|
||||||
{{- if not $gatewayName -}}
|
{{- if not $gatewayName -}}
|
||||||
{{- $gatewayName = printf "%s-gateway" (include "chart.fullname" .) -}}
|
{{- $gatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}}
|
{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}}
|
||||||
apiVersion: gateway.networking.k8s.io/v1
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
@@ -10,7 +10,7 @@ metadata:
|
|||||||
name: {{ $gatewayName }}
|
name: {{ $gatewayName }}
|
||||||
namespace: {{ $gatewayNs }}
|
namespace: {{ $gatewayNs }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.gateway.annotations }}
|
{{- with .Values.gateway.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
{{- if .Values.httpRoute.enabled -}}
|
{{- if .Values.httpRoute.enabled -}}
|
||||||
{{- $fullName := include "chart.fullname" . -}}
|
|
||||||
{{- $svcPort := .Values.service.port -}}
|
|
||||||
{{- $defaultGatewayName := .Values.gateway.name -}}
|
|
||||||
{{- if not $defaultGatewayName -}}
|
|
||||||
{{- $defaultGatewayName = printf "%s-gateway" $fullName -}}
|
|
||||||
{{- end -}}
|
|
||||||
{{- $defaultGatewayNs := .Release.Namespace -}}
|
|
||||||
{{- if .Values.gateway.enabled -}}
|
|
||||||
{{- $defaultGatewayNs = default .Release.Namespace .Values.gateway.namespace -}}
|
|
||||||
{{- end -}}
|
|
||||||
apiVersion: gateway.networking.k8s.io/v1
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
kind: HTTPRoute
|
kind: HTTPRoute
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ $fullName }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.httpRoute.annotations }}
|
{{- with .Values.httpRoute.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
@@ -23,14 +14,9 @@ spec:
|
|||||||
parentRefs:
|
parentRefs:
|
||||||
{{- if .Values.httpRoute.parentRefs }}
|
{{- if .Values.httpRoute.parentRefs }}
|
||||||
{{- range $ref := .Values.httpRoute.parentRefs }}
|
{{- range $ref := .Values.httpRoute.parentRefs }}
|
||||||
- name: {{ default $defaultGatewayName $ref.name | quote }}
|
- name: {{ $.Values.gateway.name | default $ref.name | quote }}
|
||||||
{{- $refNs := $ref.namespace | default $defaultGatewayNs -}}
|
namespace: {{ $ref.namespace | default (default $.Release.Namespace $.Values.gateway.namespace) | quote }}
|
||||||
namespace: {{ $refNs | quote }}
|
sectionName: {{ $ref.sectionName | default "http" | quote }}
|
||||||
{{- with $ref.sectionName }}
|
|
||||||
sectionName: {{ . | quote }}
|
|
||||||
{{- else }}
|
|
||||||
sectionName: "http"
|
|
||||||
{{- end }}
|
|
||||||
{{- with $ref.kind }}
|
{{- with $ref.kind }}
|
||||||
kind: {{ . | quote }}
|
kind: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -40,8 +26,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
# Default parentRef when parentRefs is empty or not set
|
# Default parentRef when parentRefs is empty or not set
|
||||||
- name: {{ $defaultGatewayName | quote }}
|
- name: {{ .Values.gateway.name | default (printf "%s-gateway" (include "bentopdf.name" $)) | quote }}
|
||||||
namespace: {{ $defaultGatewayNs | quote }}
|
namespace: {{ .Values.gateway.namespace | default .Release.Namespace | quote }}
|
||||||
sectionName: "http"
|
sectionName: "http"
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .Values.httpRoute.hostnames }}
|
{{- with .Values.httpRoute.hostnames }}
|
||||||
@@ -52,15 +38,15 @@ spec:
|
|||||||
{{- range .Values.httpRoute.rules }}
|
{{- range .Values.httpRoute.rules }}
|
||||||
- {{- with .matches }}
|
- {{- with .matches }}
|
||||||
matches:
|
matches:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .filters }}
|
{{- with .filters }}
|
||||||
filters:
|
filters:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
backendRefs:
|
backendRefs:
|
||||||
- name: {{ $fullName }}
|
- name: {{ include "bentopdf.name" $ }}
|
||||||
port: {{ $svcPort }}
|
port: {{ $.Values.service.port }}
|
||||||
weight: 1
|
weight: 1
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
{{- with .Values.ingress.annotations }}
|
{{- with .Values.ingress.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
@@ -35,7 +36,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: {{ include "chart.fullname" $ }}
|
name: {{ include "bentopdf.name" $ }}
|
||||||
port:
|
port:
|
||||||
number: {{ $.Values.service.port }}
|
number: {{ $.Values.service.port }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "chart.fullname" . }}
|
name: {{ include "bentopdf.name" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
type: {{ .Values.service.type }}
|
type: {{ .Values.service.type }}
|
||||||
ports:
|
ports:
|
||||||
@@ -12,4 +13,4 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: http
|
name: http
|
||||||
selector:
|
selector:
|
||||||
{{- include "chart.selectorLabels" . | nindent 4 }}
|
{{- include "bentopdf.selectorLabels" . | nindent 4 }}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# The service account was removed, as it's not necessary for a chart like this.
|
|
||||||
#
|
|
||||||
# There are no calls to the Kubernetes API - routing between services.
|
|
||||||
# It's a simple nginx static website deployment only.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Pod
|
kind: Pod
|
||||||
metadata:
|
metadata:
|
||||||
name: "{{ include "chart.fullname" . }}-test-connection"
|
name: "{{ include "bentopdf.name" . }}-test-connection"
|
||||||
labels:
|
labels:
|
||||||
{{- include "chart.labels" . | nindent 4 }}
|
{{- include "bentopdf.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
"helm.sh/hook": test
|
"helm.sh/hook": test
|
||||||
spec:
|
spec:
|
||||||
@@ -11,5 +11,5 @@ spec:
|
|||||||
- name: wget
|
- name: wget
|
||||||
image: busybox
|
image: busybox
|
||||||
command: ['wget']
|
command: ['wget']
|
||||||
args: ['-qO-', 'http://{{ include "chart.fullname" . }}:{{ .Values.service.port }}/']
|
args: ['-qO-', 'http://{{ include "bentopdf.name" . }}:{{ .Values.service.port }}/']
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|||||||
+6
-7
@@ -5,11 +5,10 @@ image:
|
|||||||
# Image built from this repo's `Dockerfile` (nginx serving static frontend).
|
# Image built from this repo's `Dockerfile` (nginx serving static frontend).
|
||||||
repository: ghcr.io/alam00000/bentopdf-simple
|
repository: ghcr.io/alam00000/bentopdf-simple
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
tag: ""
|
tag: ''
|
||||||
|
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
nameOverride: ""
|
nameOverride: ''
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
podAnnotations: {}
|
podAnnotations: {}
|
||||||
podLabels: {}
|
podLabels: {}
|
||||||
@@ -25,7 +24,7 @@ env: []
|
|||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
className: ""
|
className: ''
|
||||||
annotations: {}
|
annotations: {}
|
||||||
hosts:
|
hosts:
|
||||||
- host: bentopdf.local
|
- host: bentopdf.local
|
||||||
@@ -37,10 +36,10 @@ ingress:
|
|||||||
# Gateway API (optional)
|
# Gateway API (optional)
|
||||||
gateway:
|
gateway:
|
||||||
enabled: false
|
enabled: false
|
||||||
name: "" # default: release fullname + "-gateway" suffix
|
name: '' # default: release name + "-gateway" suffix
|
||||||
namespace: "" # to override the namespace
|
namespace: '' # to override the namespace
|
||||||
annotations: {}
|
annotations: {}
|
||||||
gatewayClassName: "" # required when enabled=true
|
gatewayClassName: '' # required when enabled=true
|
||||||
listeners:
|
listeners:
|
||||||
- name: http
|
- name: http
|
||||||
protocol: HTTP
|
protocol: HTTP
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml
|
|||||||
|
|
||||||
**Recommended Source URLs:**
|
**Recommended Source URLs:**
|
||||||
|
|
||||||
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/`
|
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/`
|
||||||
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
|
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
|
||||||
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
|
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
|
||||||
|
|
||||||
|
|||||||
+392
-288
@@ -12,28 +12,27 @@
|
|||||||
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
* - PROXY_SECRET: Shared secret for HMAC signature verification
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALLOWED_PATTERNS = [
|
const ALLOWED_PATH_PATTERNS = [
|
||||||
/\.crt$/i,
|
/\.crt$/i,
|
||||||
/\.cer$/i,
|
/\.cer$/i,
|
||||||
/\.pem$/i,
|
/\.pem$/i,
|
||||||
/\/certs\//i,
|
/\/certs\//i,
|
||||||
/\/ocsp/i,
|
/\/ocsp/i,
|
||||||
/\/crl/i,
|
/\/crl/i,
|
||||||
/caIssuers/i,
|
/caIssuers/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com'];
|
||||||
'https://www.bentopdf.com',
|
|
||||||
'https://bentopdf.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
const BLOCKED_DOMAINS = [
|
const SAFE_CONTENT_TYPES = [
|
||||||
'localhost',
|
'application/x-x509-ca-cert',
|
||||||
'127.0.0.1',
|
'application/pkix-cert',
|
||||||
'0.0.0.0',
|
'application/x-pem-file',
|
||||||
|
'application/pkcs7-mime',
|
||||||
|
'application/octet-stream',
|
||||||
|
'text/plain',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const RATE_LIMIT_MAX_REQUESTS = 60;
|
const RATE_LIMIT_MAX_REQUESTS = 60;
|
||||||
@@ -42,310 +41,415 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|||||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
async function verifySignature(message, signature, secret) {
|
async function verifySignature(message, signature, secret) {
|
||||||
try {
|
try {
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signatureBytes = new Uint8Array(
|
|
||||||
signature.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
|
|
||||||
);
|
|
||||||
|
|
||||||
return await crypto.subtle.verify(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
signatureBytes,
|
|
||||||
encoder.encode(message)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Signature verification error:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateSignature(message, secret) {
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
encoder.encode(secret),
|
encoder.encode(secret),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign']
|
['verify']
|
||||||
);
|
);
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign(
|
const signatureBytes = new Uint8Array(
|
||||||
'HMAC',
|
signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
|
||||||
key,
|
|
||||||
encoder.encode(message)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return await crypto.subtle.verify(
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
'HMAC',
|
||||||
.join('');
|
key,
|
||||||
|
signatureBytes,
|
||||||
|
encoder.encode(message)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Signature verification error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedOrigin(origin) {
|
function isAllowedOrigin(origin) {
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, '')));
|
return ALLOWED_ORIGINS.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateOrReservedHost(hostname) {
|
||||||
|
if (
|
||||||
|
/^10\./.test(hostname) ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||||
|
/^192\.168\./.test(hostname) ||
|
||||||
|
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
|
||||||
|
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
|
||||||
|
/^127\./.test(hostname) ||
|
||||||
|
/^0\./.test(hostname)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (
|
||||||
|
lower === '::1' ||
|
||||||
|
lower.startsWith('::ffff:') ||
|
||||||
|
lower.startsWith('fe80') ||
|
||||||
|
lower.startsWith('fc') ||
|
||||||
|
lower.startsWith('fd') ||
|
||||||
|
lower.startsWith('ff')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedNames = [
|
||||||
|
'localhost',
|
||||||
|
'localhost.localdomain',
|
||||||
|
'0.0.0.0',
|
||||||
|
'[::1]',
|
||||||
|
];
|
||||||
|
if (blockedNames.includes(hostname.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidCertificateUrl(urlString) {
|
function isValidCertificateUrl(urlString) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
if (BLOCKED_DOMAINS.some(domain => url.hostname.includes(domain))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname;
|
|
||||||
if (/^10\./.test(hostname) ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
|
||||||
/^192\.168\./.test(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ALLOWED_PATTERNS.some(pattern => pattern.test(urlString));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrivateOrReservedHost(url.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(url.pathname));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeContentType(upstreamContentType) {
|
||||||
|
if (!upstreamContentType) return 'application/octet-stream';
|
||||||
|
const match = SAFE_CONTENT_TYPES.find((ct) =>
|
||||||
|
upstreamContentType.startsWith(ct)
|
||||||
|
);
|
||||||
|
return match || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
function corsHeaders(origin) {
|
function corsHeaders(origin) {
|
||||||
return {
|
return {
|
||||||
'Access-Control-Allow-Origin': origin || '*',
|
'Access-Control-Allow-Origin': origin,
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOptions(request) {
|
function handleOptions(request) {
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
return new Response(null, {
|
if (!isAllowedOrigin(origin)) {
|
||||||
status: 204,
|
return new Response(null, { status: 403 });
|
||||||
headers: corsHeaders(origin),
|
}
|
||||||
});
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders(origin),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request, env, ctx) {
|
async fetch(request, env, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
|
|
||||||
if (request.method === 'OPTIONS') {
|
if (request.method === 'OPTIONS') {
|
||||||
return handleOptions(request);
|
return handleOptions(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
|
||||||
|
if (!isAllowedOrigin(origin)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'This proxy only accepts requests from allowed origins',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
|
if (request.method !== 'GET') {
|
||||||
if (!isAllowedOrigin(origin)) {
|
return new Response('Method not allowed', {
|
||||||
return new Response(JSON.stringify({
|
status: 405,
|
||||||
error: 'Forbidden',
|
headers: corsHeaders(origin),
|
||||||
message: 'This proxy only accepts requests from bentopdf.com',
|
});
|
||||||
}), {
|
}
|
||||||
status: 403,
|
|
||||||
headers: {
|
const targetUrl = url.searchParams.get('url');
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
if (!targetUrl) {
|
||||||
});
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Missing url parameter',
|
||||||
|
usage: 'GET /?url=<certificate_url>',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method !== 'GET') {
|
if (!isValidCertificateUrl(targetUrl)) {
|
||||||
return new Response('Method not allowed', {
|
return new Response(
|
||||||
status: 405,
|
JSON.stringify({
|
||||||
headers: corsHeaders(origin),
|
error: 'Invalid or disallowed URL',
|
||||||
});
|
message:
|
||||||
|
'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const targetUrl = url.searchParams.get('url');
|
if (env.PROXY_SECRET) {
|
||||||
const timestamp = url.searchParams.get('t');
|
const timestamp = url.searchParams.get('t');
|
||||||
const signature = url.searchParams.get('sig');
|
const signature = url.searchParams.get('sig');
|
||||||
|
|
||||||
if (env.PROXY_SECRET) {
|
if (!timestamp || !signature) {
|
||||||
if (!timestamp || !signature) {
|
return new Response(
|
||||||
return new Response(JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Missing authentication parameters',
|
error: 'Missing authentication parameters',
|
||||||
message: 'Request must include timestamp (t) and signature (sig) parameters',
|
message:
|
||||||
}), {
|
'Request must include timestamp (t) and signature (sig) parameters',
|
||||||
status: 401,
|
}),
|
||||||
headers: {
|
{
|
||||||
...corsHeaders(origin),
|
status: 401,
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
...corsHeaders(origin),
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
}
|
},
|
||||||
|
}
|
||||||
const requestTime = parseInt(timestamp, 10);
|
);
|
||||||
const now = Date.now();
|
}
|
||||||
if (isNaN(requestTime) || Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS) {
|
|
||||||
return new Response(JSON.stringify({
|
const requestTime = parseInt(timestamp, 10);
|
||||||
error: 'Request expired or invalid timestamp',
|
const now = Date.now();
|
||||||
message: 'Timestamp must be within 5 minutes of current time',
|
if (
|
||||||
}), {
|
isNaN(requestTime) ||
|
||||||
status: 401,
|
Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS
|
||||||
headers: {
|
) {
|
||||||
...corsHeaders(origin),
|
return new Response(
|
||||||
'Content-Type': 'application/json',
|
JSON.stringify({
|
||||||
},
|
error: 'Request expired or invalid timestamp',
|
||||||
});
|
message: 'Timestamp must be within 5 minutes of current time',
|
||||||
}
|
}),
|
||||||
|
{
|
||||||
const message = `${targetUrl}${timestamp}`;
|
status: 401,
|
||||||
const isValid = await verifySignature(message, signature, env.PROXY_SECRET);
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
if (!isValid) {
|
'Content-Type': 'application/json',
|
||||||
return new Response(JSON.stringify({
|
},
|
||||||
error: 'Invalid signature',
|
}
|
||||||
message: 'Request signature verification failed',
|
);
|
||||||
}), {
|
}
|
||||||
status: 401,
|
|
||||||
headers: {
|
const message = `${targetUrl}${timestamp}`;
|
||||||
...corsHeaders(origin),
|
const isValid = await verifySignature(
|
||||||
'Content-Type': 'application/json',
|
message,
|
||||||
},
|
signature,
|
||||||
});
|
env.PROXY_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Invalid signature',
|
||||||
|
message: 'Request signature verification failed',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||||
|
const rateLimitKey = `ratelimit:${clientIP}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (env.RATE_LIMIT_KV) {
|
||||||
|
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
|
||||||
|
type: 'json',
|
||||||
|
});
|
||||||
|
const requests = rateLimitData?.requests || [];
|
||||||
|
|
||||||
|
const recentRequests = requests.filter(
|
||||||
|
(t) => now - t < RATE_LIMIT_WINDOW_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
|
||||||
|
retryAfter: Math.ceil(
|
||||||
|
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': Math.ceil(
|
||||||
|
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
|
||||||
|
).toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentRequests.push(now);
|
||||||
|
await env.RATE_LIMIT_KV.put(
|
||||||
|
rateLimitKey,
|
||||||
|
JSON.stringify({ requests: recentRequests }),
|
||||||
|
{
|
||||||
|
expirationTtl: 120,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'BentoPDF-CertProxy/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Failed to fetch certificate',
|
||||||
|
status: response.status,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Length header first (fast reject for known-large responses)
|
||||||
|
const contentLength = parseInt(
|
||||||
|
response.headers.get('Content-Length') || '0',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
if (contentLength > MAX_FILE_SIZE_BYTES) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'File too large',
|
||||||
|
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 413,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
totalSize += value.byteLength;
|
||||||
|
if (totalSize > MAX_FILE_SIZE_BYTES) {
|
||||||
|
reader.cancel();
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'File too large',
|
||||||
|
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 413,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetUrl) {
|
chunks.push(value);
|
||||||
return new Response(JSON.stringify({
|
}
|
||||||
error: 'Missing url parameter',
|
|
||||||
usage: 'GET /?url=<certificate_url>',
|
const certData = new Uint8Array(totalSize);
|
||||||
}), {
|
let offset = 0;
|
||||||
status: 400,
|
for (const chunk of chunks) {
|
||||||
headers: {
|
certData.set(chunk, offset);
|
||||||
...corsHeaders(origin),
|
offset += chunk.byteLength;
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
|
||||||
});
|
return new Response(certData, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': getSafeContentType(
|
||||||
|
response.headers.get('Content-Type')
|
||||||
|
),
|
||||||
|
'Content-Length': totalSize.toString(),
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Proxy fetch error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Proxy error',
|
||||||
|
message: 'Failed to fetch the requested certificate',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders(origin),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
if (!isValidCertificateUrl(targetUrl)) {
|
}
|
||||||
return new Response(JSON.stringify({
|
},
|
||||||
error: 'Invalid or disallowed URL',
|
|
||||||
message: 'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
|
|
||||||
}), {
|
|
||||||
status: 403,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
|
||||||
const rateLimitKey = `ratelimit:${clientIP}`;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (env.RATE_LIMIT_KV) {
|
|
||||||
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { type: 'json' });
|
|
||||||
const requests = rateLimitData?.requests || [];
|
|
||||||
|
|
||||||
const recentRequests = requests.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
||||||
|
|
||||||
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Rate limit exceeded',
|
|
||||||
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
|
|
||||||
retryAfter: Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000),
|
|
||||||
}), {
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Retry-After': Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000).toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
recentRequests.push(now);
|
|
||||||
await env.RATE_LIMIT_KV.put(rateLimitKey, JSON.stringify({ requests: recentRequests }), {
|
|
||||||
expirationTtl: 120,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(targetUrl, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'BentoPDF-CertProxy/1.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to fetch certificate',
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
}), {
|
|
||||||
status: response.status,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
|
|
||||||
if (contentLength > MAX_FILE_SIZE_BYTES) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large',
|
|
||||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
|
||||||
size: contentLength,
|
|
||||||
maxSize: MAX_FILE_SIZE_BYTES,
|
|
||||||
}), {
|
|
||||||
status: 413,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const certData = await response.arrayBuffer();
|
|
||||||
|
|
||||||
if (certData.byteLength > MAX_FILE_SIZE_BYTES) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large',
|
|
||||||
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
|
|
||||||
size: certData.byteLength,
|
|
||||||
maxSize: MAX_FILE_SIZE_BYTES,
|
|
||||||
}), {
|
|
||||||
status: 413,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(certData, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': response.headers.get('Content-Type') || 'application/x-x509-ca-cert',
|
|
||||||
'Content-Length': certData.byteLength.toString(),
|
|
||||||
'Cache-Control': 'public, max-age=86400',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Proxy error',
|
|
||||||
message: error.message,
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders(origin),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
|
||||||
|
|
||||||
|
Additionally, many CA servers serve certificates over plain HTTP. When your BentoPDF instance is hosted over HTTPS, browsers block these HTTP requests (mixed content policy). The CORS proxy resolves both issues by routing requests through an HTTPS endpoint with proper headers.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
When signing a PDF with a certificate:
|
When signing a PDF with a certificate:
|
||||||
@@ -13,35 +15,64 @@ When signing a PDF with a certificate:
|
|||||||
|
|
||||||
## Self-Hosting the CORS Proxy
|
## Self-Hosting the CORS Proxy
|
||||||
|
|
||||||
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy.
|
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for digital signatures to work with certificates that require chain fetching.
|
||||||
|
|
||||||
### Option 1: Cloudflare Workers (Recommended)
|
### Option 1: Cloudflare Workers (Recommended)
|
||||||
|
|
||||||
1. **Install Wrangler CLI**:
|
1. **Install Wrangler CLI**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g wrangler
|
npm install -g wrangler
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Login to Cloudflare**:
|
2. **Login to Cloudflare**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrangler login
|
wrangler login
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy the proxy**:
|
3. **Clone BentoPDF and update allowed origins**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/alam00000/bentopdf.git
|
||||||
|
cd bentopdf/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `cors-proxy-worker.js` and change the `ALLOWED_ORIGINS` array to your domain:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://your-domain.com',
|
||||||
|
'https://www.your-domain.com',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning Important
|
||||||
|
Without this change, the proxy will reject all requests from your site with a **403 Forbidden** error. The default only allows requests from `bentopdf.com`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
4. **Deploy the proxy**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloudflare
|
|
||||||
wrangler deploy
|
wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Update your environment**:
|
Note your worker URL (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`).
|
||||||
Create a `.env` or set in your hosting platform:
|
|
||||||
```
|
5. **Rebuild BentoPDF with the proxy URL**:
|
||||||
VITE_CORS_PROXY_URL=https://your-worker-name.your-subdomain.workers.dev
|
|
||||||
|
If using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
|
||||||
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Rebuild BentoPDF**:
|
If building from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Custom Backend Proxy
|
### Option 2: Custom Backend Proxy
|
||||||
@@ -51,9 +82,9 @@ You can also create your own proxy endpoint. The requirements are:
|
|||||||
1. Accept GET requests with a `url` query parameter
|
1. Accept GET requests with a `url` query parameter
|
||||||
2. Fetch the URL from your server (no CORS restrictions server-side)
|
2. Fetch the URL from your server (no CORS restrictions server-side)
|
||||||
3. Return the response with these headers:
|
3. Return the response with these headers:
|
||||||
- `Access-Control-Allow-Origin: *` (or your specific origin)
|
- `Access-Control-Allow-Origin: https://your-domain.com`
|
||||||
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
- `Access-Control-Allow-Methods: GET, OPTIONS`
|
||||||
- `Content-Type: application/x-x509-ca-cert`
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
|
||||||
Example Express.js implementation:
|
Example Express.js implementation:
|
||||||
|
|
||||||
@@ -70,8 +101,9 @@ app.get('/api/cert-proxy', async (req, res) => {
|
|||||||
const response = await fetch(targetUrl);
|
const response = await fetch(targetUrl);
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
|
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', 'https://your-domain.com');
|
||||||
res.set('Content-Type', 'application/x-x509-ca-cert');
|
res.set('Content-Type', 'application/octet-stream');
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
res.send(Buffer.from(data));
|
res.send(Buffer.from(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Proxy error' });
|
res.status(500).json({ error: 'Proxy error' });
|
||||||
@@ -83,9 +115,15 @@ app.get('/api/cert-proxy', async (req, res) => {
|
|||||||
|
|
||||||
The included Cloudflare Worker has several security measures:
|
The included Cloudflare Worker has several security measures:
|
||||||
|
|
||||||
- **URL Validation**: Only allows certificate-related URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`)
|
| Feature | Description |
|
||||||
- **Blocked Domains**: Prevents access to localhost and private IP ranges
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
- **HTTP Methods**: Only allows GET requests
|
| **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
|
||||||
|
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
|
||||||
|
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
|
||||||
|
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
|
||||||
|
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
|
||||||
|
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
|
||||||
|
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
|
||||||
|
|
||||||
## Disabling the Proxy
|
## Disabling the Proxy
|
||||||
|
|
||||||
@@ -95,22 +133,39 @@ If you don't want to use a CORS proxy, set the environment variable to an empty
|
|||||||
VITE_CORS_PROXY_URL=
|
VITE_CORS_PROXY_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail.
|
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail with a "Failed to fetch" error.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Failed to fetch certificate chain" Error
|
### "Signing error: TypeError: Failed to fetch"
|
||||||
|
|
||||||
1. Check that your CORS proxy is deployed and accessible
|
This usually means either:
|
||||||
2. Verify the `VITE_CORS_PROXY_URL` is correctly set
|
|
||||||
3. Test the proxy directly:
|
1. **No CORS proxy configured** — Set `VITE_CORS_PROXY_URL` and rebuild
|
||||||
```bash
|
2. **Mixed content blocked** — Your site is HTTPS but the certificate's issuer URL is HTTP. The CORS proxy resolves this.
|
||||||
curl "https://your-proxy.workers.dev?url=https://www.cert.fnmt.es/certs/ACUSU.crt"
|
3. **CORS proxy rejecting your origin** — Check that your domain is in the `ALLOWED_ORIGINS` array in `cors-proxy-worker.js`
|
||||||
```
|
|
||||||
|
### "403 Forbidden" from the proxy
|
||||||
|
|
||||||
|
Your domain is not in the `ALLOWED_ORIGINS` list. Edit `cors-proxy-worker.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ALLOWED_ORIGINS = ['https://your-domain.com'];
|
||||||
|
```
|
||||||
|
|
||||||
|
Then redeploy: `npx wrangler deploy`
|
||||||
|
|
||||||
|
### Testing the proxy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://your-domain.com" \
|
||||||
|
"https://your-proxy.workers.dev?url=http://www.cert.fnmt.es/certs/ACUSU.crt"
|
||||||
|
```
|
||||||
|
|
||||||
### Certificates That Work Without Proxy
|
### Certificates That Work Without Proxy
|
||||||
|
|
||||||
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
Some certificates include the full chain in the P12/PFX file and don't require external fetching:
|
||||||
|
|
||||||
- Self-signed certificates
|
- Self-signed certificates
|
||||||
- Some commercial CAs that bundle intermediate certificates
|
- Some commercial CAs that bundle intermediate certificates
|
||||||
- Certificates you've manually assembled with the full chain
|
- Certificates you've manually assembled with the full chain
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ docker run -d -p 3000:8080 bentopdf:custom
|
|||||||
| ----------------------- | ------------------------------- | -------------------------------------------------------------- |
|
| ----------------------- | ------------------------------- | -------------------------------------------------------------- |
|
||||||
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
||||||
| `BASE_URL` | Deploy to subdirectory | `/` |
|
| `BASE_URL` | Deploy to subdirectory | `/` |
|
||||||
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/` |
|
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/` |
|
||||||
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
||||||
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
||||||
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
|
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ As of v2.0.0, WASM modules are pre-configured to load from jsDelivr CDN via envi
|
|||||||
These are set in `.env.production` and baked into the build:
|
These are set in `.env.production` and baked into the build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
```
|
```
|
||||||
|
|||||||
+32
@@ -717,6 +717,38 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="compact-mode-toggle"
|
||||||
|
class="text-sm font-medium text-gray-200 cursor-pointer"
|
||||||
|
data-i18n="settings.compactMode"
|
||||||
|
>
|
||||||
|
Compact Mode
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
data-i18n="settings.compactModeDescription"
|
||||||
|
>
|
||||||
|
Display tools as a compact list instead of cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="relative inline-flex items-center cursor-pointer ml-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="compact-mode-toggle"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Settings Link -->
|
<!-- Advanced Settings Link -->
|
||||||
<a
|
<a
|
||||||
href="wasm-settings.html"
|
href="wasm-settings.html"
|
||||||
|
|||||||
+15
-16
@@ -27,6 +27,21 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
|
location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
|
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
||||||
|
expires 5m;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
|
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
||||||
|
expires 5m;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
}
|
||||||
|
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
@@ -84,22 +99,6 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
|
|
||||||
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
|
||||||
expires 5m;
|
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(.+?)/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
|
|
||||||
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
|
||||||
expires 5m;
|
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ $uri.html /index.html;
|
try_files $uri $uri/ $uri.html /index.html;
|
||||||
expires 5m;
|
expires 5m;
|
||||||
|
|||||||
Generated
+28
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"version": "2.1.0",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"version": "2.1.0",
|
"version": "2.2.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/cedarville-cursive": "^5.2.7",
|
"@fontsource/cedarville-cursive": "^5.2.7",
|
||||||
@@ -36,12 +36,12 @@
|
|||||||
"i18next": "^25.7.2",
|
"i18next": "^25.7.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.2.0",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.546.0",
|
"lucide": "^0.546.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.1",
|
||||||
"markdown-it-abbr": "^2.0.0",
|
"markdown-it-abbr": "^2.0.0",
|
||||||
"markdown-it-anchor": "^9.2.0",
|
"markdown-it-anchor": "^9.2.0",
|
||||||
"markdown-it-deflist": "^3.0.0",
|
"markdown-it-deflist": "^3.0.0",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"tiff": "^7.1.2",
|
"tiff": "^7.1.2",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zgapdfsigner": "^2.7.5"
|
"zgapdfsigner": "^2.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -480,9 +480,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.28.4",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -6432,9 +6432,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
||||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -8228,12 +8228,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jspdf": {
|
"node_modules/jspdf": {
|
||||||
"version": "4.1.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
|
||||||
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
|
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.6",
|
||||||
"fast-png": "^6.2.0",
|
"fast-png": "^6.2.0",
|
||||||
"fflate": "^0.8.1"
|
"fflate": "^0.8.1"
|
||||||
},
|
},
|
||||||
@@ -9050,9 +9050,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
@@ -10309,9 +10309,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11442,9 +11442,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.48.2",
|
"version": "5.53.1",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.1.tgz",
|
||||||
"integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==",
|
"integrity": "sha512-WzxFHZhhD23Qzu7JCYdvm1rxvRSzdt9HtHO8TScMBX51bLRFTcJmATVqjqXG+6Ln6hrViGCo9DzwOhAasxwC/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11452,13 +11452,14 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
"@types/estree": "^1.0.5",
|
"@types/estree": "^1.0.5",
|
||||||
|
"@types/trusted-types": "^2.0.7",
|
||||||
"acorn": "^8.12.1",
|
"acorn": "^8.12.1",
|
||||||
"aria-query": "^5.3.1",
|
"aria-query": "^5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.6.2",
|
"devalue": "^5.6.3",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.1",
|
"esrap": "^2.2.2",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
"locate-character": "^3.0.0",
|
"locate-character": "^3.0.0",
|
||||||
"magic-string": "^0.30.11",
|
"magic-string": "^0.30.11",
|
||||||
@@ -13852,7 +13853,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/xlsx": {
|
"node_modules/xlsx": {
|
||||||
"version": "0.20.3",
|
"version": "0.20.3",
|
||||||
"resolved": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.1",
|
"version": "2.3.3",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -92,12 +92,12 @@
|
|||||||
"i18next": "^25.7.2",
|
"i18next": "^25.7.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.2.0",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.546.0",
|
"lucide": "^0.546.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.1",
|
||||||
"markdown-it-abbr": "^2.0.0",
|
"markdown-it-abbr": "^2.0.0",
|
||||||
"markdown-it-anchor": "^9.2.0",
|
"markdown-it-anchor": "^9.2.0",
|
||||||
"markdown-it-deflist": "^3.0.0",
|
"markdown-it-deflist": "^3.0.0",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"tiff": "^7.1.2",
|
"tiff": "^7.1.2",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.1.4",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zgapdfsigner": "^2.7.5"
|
"zgapdfsigner": "^2.7.5"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "أرقام الصفحات",
|
"name": "أرقام الصفحات",
|
||||||
"subtitle": "إدراج أرقام الصفحات في مستندك."
|
"subtitle": "إدراج أرقام الصفحات في مستندك."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "ترقيم بيتس",
|
||||||
|
"subtitle": "إضافة أرقام بيتس التسلسلية عبر ملف PDF واحد أو أكثر."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "إضافة علامة مائية",
|
"name": "إضافة علامة مائية",
|
||||||
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك."
|
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.",
|
||||||
|
"applyToAllPages": "تطبيق على جميع الصفحات"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "رأس وتذييل",
|
"name": "رأس وتذييل",
|
||||||
|
|||||||
@@ -358,6 +358,6 @@
|
|||||||
},
|
},
|
||||||
"simpleMode": {
|
"simpleMode": {
|
||||||
"title": "Інструменты PDF",
|
"title": "Інструменты PDF",
|
||||||
"subtitle": "Абярыце інструмент, каб пачаць"
|
"subtitle": "Выберыце інструмент, каб пачаць"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Нумары старонак",
|
"name": "Нумары старонак",
|
||||||
"subtitle": "Уставіць у дакумент нумары старонак."
|
"subtitle": "Уставіць у дакумент нумары старонак."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Нумарацыя Бейтса",
|
||||||
|
"subtitle": "Дадаць паслядоўныя нумары Бейтса да аднаго або некалькіх файлаў PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Дадаць вадзяны знак",
|
"name": "Дадаць вадзяны знак",
|
||||||
"subtitle": "Накласці на старонкі PDF тэкст або відарыс."
|
"subtitle": "Накласці на старонкі PDF тэкст або відарыс.",
|
||||||
|
"applyToAllPages": "Прымяніць да ўсіх старонак"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Верхні і ніжні калантытул",
|
"name": "Верхні і ніжні калантытул",
|
||||||
@@ -590,10 +595,10 @@
|
|||||||
"subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць."
|
"subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць."
|
||||||
},
|
},
|
||||||
"pdfWorkflow": {
|
"pdfWorkflow": {
|
||||||
"name": "Канструктар працоўнага працэсу PDF",
|
"name": "Канструктар рабочага працэсу PDF",
|
||||||
"subtitle": "Стварайце ўласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара вузлоў.",
|
"subtitle": "Стварыць уласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара.",
|
||||||
"nodes": "Вузлы",
|
"nodes": "Вузлы",
|
||||||
"searchNodes": "Шукаць вузлы...",
|
"searchNodes": "Пошук вузлоў...",
|
||||||
"run": "Запусціць",
|
"run": "Запусціць",
|
||||||
"clear": "Ачысціць",
|
"clear": "Ачысціць",
|
||||||
"save": "Захаваць",
|
"save": "Захаваць",
|
||||||
@@ -605,21 +610,21 @@
|
|||||||
"processing": "Апрацоўка...",
|
"processing": "Апрацоўка...",
|
||||||
"saveTemplate": "Захаваць шаблон",
|
"saveTemplate": "Захаваць шаблон",
|
||||||
"templateName": "Назва шаблону",
|
"templateName": "Назва шаблону",
|
||||||
"templatePlaceholder": "напр. Працоўны працэс рахункаў-фактур",
|
"templatePlaceholder": "напрыклад, Праца з накладнымі",
|
||||||
"cancel": "Адмяніць",
|
"cancel": "Скасаваць",
|
||||||
"loadTemplate": "Загрузіць шаблон",
|
"loadTemplate": "Загрузіць шаблон",
|
||||||
"noTemplates": "Пакуль няма захаваных шаблонаў.",
|
"noTemplates": "Пакуль няма захаваных шаблонаў.",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"workflowCompleted": "Працоўны працэс завершаны",
|
"workflowCompleted": "Рабочы працэс завершаны",
|
||||||
"errorDuringExecution": "Памылка падчас выканання",
|
"errorDuringExecution": "Памылка падчас выканання",
|
||||||
"addNodeError": "Дадайце хаця б адзін вузел для запуску працоўнага працэсу.",
|
"addNodeError": "Дадайце хаця б адзін вузел для запуску рабочага працэсу.",
|
||||||
"needInputOutput": "Для працы працоўнага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.",
|
"needInputOutput": "Для працы рабочага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.",
|
||||||
"enterName": "Калі ласка, увядзіце назву.",
|
"enterName": "Увядзіце назву.",
|
||||||
"templateExists": "Шаблон з такой назвай ужо існуе.",
|
"templateExists": "Шаблон з такой назвай ужо існуе.",
|
||||||
"templateSaved": "Шаблон \"{{name}}\" захаваны.",
|
"templateSaved": "Шаблон \"{{name}}\" захаваны.",
|
||||||
"templateLoaded": "Шаблон \"{{name}}\" загружаны.",
|
"templateLoaded": "Шаблон \"{{name}}\" загружаны.",
|
||||||
"failedLoadTemplate": "Не ўдалося загрузіць шаблон.",
|
"failedLoadTemplate": "Не ўдалося загрузіць шаблон.",
|
||||||
"noSettings": "Няма наладжвальных параметраў для гэтага вузла.",
|
"noSettings": "Няма налад для гэтага вузла.",
|
||||||
"advancedSettings": "Пашыраныя налады"
|
"advancedSettings": "Пашыраныя налады"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Sidetal",
|
"name": "Sidetal",
|
||||||
"subtitle": "Indsæt sidetal i dokumentet."
|
"subtitle": "Indsæt sidetal i dokumentet."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-nummerering",
|
||||||
|
"subtitle": "Tilføj sekventielle Bates-numre på tværs af en eller flere PDF-filer."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tilføj vandmærke",
|
"name": "Tilføj vandmærke",
|
||||||
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider."
|
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider.",
|
||||||
|
"applyToAllPages": "Anvend på alle sider"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Sidehoved og sidefod",
|
"name": "Sidehoved og sidefod",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Seitenzahlen",
|
"name": "Seitenzahlen",
|
||||||
"subtitle": "Seitenzahlen in Ihr Dokument einfügen."
|
"subtitle": "Seitenzahlen in Ihr Dokument einfügen."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-Nummerierung",
|
||||||
|
"subtitle": "Fortlaufende Bates-Nummern über eine oder mehrere PDF-Dateien hinzufügen."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Wasserzeichen hinzufügen",
|
"name": "Wasserzeichen hinzufügen",
|
||||||
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln."
|
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln.",
|
||||||
|
"applyToAllPages": "Auf alle Seiten anwenden"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Kopf- & Fußzeile",
|
"name": "Kopf- & Fußzeile",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Page Numbers",
|
"name": "Page Numbers",
|
||||||
"subtitle": "Insert page numbers into your document."
|
"subtitle": "Insert page numbers into your document."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates Numbering",
|
||||||
|
"subtitle": "Add sequential Bates numbers across one or more PDF files."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Add Watermark",
|
"name": "Add Watermark",
|
||||||
"subtitle": "Stamp text or an image over your PDF pages."
|
"subtitle": "Stamp text or an image over your PDF pages.",
|
||||||
|
"applyToAllPages": "Apply to all pages"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Header & Footer",
|
"name": "Header & Footer",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Números de Página",
|
"name": "Números de Página",
|
||||||
"subtitle": "Inserta números de página en tu documento."
|
"subtitle": "Inserta números de página en tu documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numeración Bates",
|
||||||
|
"subtitle": "Añadir números Bates secuenciales en uno o más archivos PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Agregar Marca de Agua",
|
"name": "Agregar Marca de Agua",
|
||||||
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF."
|
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF.",
|
||||||
|
"applyToAllPages": "Aplicar a todas las páginas"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Encabezado y Pie de Página",
|
"name": "Encabezado y Pie de Página",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Numéros de page",
|
"name": "Numéros de page",
|
||||||
"subtitle": "Insérer une numérotation dans le document."
|
"subtitle": "Insérer une numérotation dans le document."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numérotation Bates",
|
||||||
|
"subtitle": "Ajouter des numéros Bates séquentiels sur un ou plusieurs fichiers PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Ajouter un filigrane",
|
"name": "Ajouter un filigrane",
|
||||||
"subtitle": "Apposer un texte ou une image sur les pages du PDF."
|
"subtitle": "Apposer un texte ou une image sur les pages du PDF.",
|
||||||
|
"applyToAllPages": "Appliquer à toutes les pages"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "En-tête et pied de page",
|
"name": "En-tête et pied de page",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Nomor Halaman",
|
"name": "Nomor Halaman",
|
||||||
"subtitle": "Sisipkan nomor halaman ke dokumen Anda."
|
"subtitle": "Sisipkan nomor halaman ke dokumen Anda."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Penomoran Bates",
|
||||||
|
"subtitle": "Tambahkan nomor Bates berurutan pada satu atau lebih file PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tambah Watermark",
|
"name": "Tambah Watermark",
|
||||||
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda."
|
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda.",
|
||||||
|
"applyToAllPages": "Terapkan ke semua halaman"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Header & Footer",
|
"name": "Header & Footer",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Numeri di Pagina",
|
"name": "Numeri di Pagina",
|
||||||
"subtitle": "Inserisci i numeri di pagina nel tuo documento."
|
"subtitle": "Inserisci i numeri di pagina nel tuo documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numerazione Bates",
|
||||||
|
"subtitle": "Aggiungi numeri Bates sequenziali su uno o più file PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Aggiungi Filigrana",
|
"name": "Aggiungi Filigrana",
|
||||||
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF."
|
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF.",
|
||||||
|
"applyToAllPages": "Applica a tutte le pagine"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Intestazione e Piè di Pagina",
|
"name": "Intestazione e Piè di Pagina",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
"title": "Aan de slag met",
|
"title": "Aan de slag met",
|
||||||
"toolsLabel": "Tools",
|
"toolsLabel": "Tools",
|
||||||
"subtitle": "Klik een tool om de bestandslader te openen",
|
"subtitle": "Klik een tool om de bestandslader te openen",
|
||||||
"searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)",
|
"searchPlaceholder": "Zoek een tool (bijv. 'splitsen', 'organiseren', ...)",
|
||||||
"backToTools": "Terug naar Tools",
|
"backToTools": "Terug naar Tools",
|
||||||
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
|
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
|
||||||
},
|
},
|
||||||
@@ -95,6 +95,15 @@
|
|||||||
"webpImages": "WebP-afbeeldingen"
|
"webpImages": "WebP-afbeeldingen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Hoe het werkt",
|
||||||
|
"step1": "Klik of sleep uw bestand hierheen",
|
||||||
|
"step2": "Klik op de verwerkingsknop",
|
||||||
|
"step3": "Sla uw verwerkte bestand direct op"
|
||||||
|
},
|
||||||
|
"relatedTools": {
|
||||||
|
"title": "Gerelateerde PDF-tools"
|
||||||
|
},
|
||||||
"loader": {
|
"loader": {
|
||||||
"processing": "Verwerken..."
|
"processing": "Verwerken..."
|
||||||
},
|
},
|
||||||
@@ -166,6 +175,7 @@
|
|||||||
"faq": {
|
"faq": {
|
||||||
"title": "Veelgestelde",
|
"title": "Veelgestelde",
|
||||||
"questions": "Vragen",
|
"questions": "Vragen",
|
||||||
|
"sectionTitle": "Veelgestelde vragen",
|
||||||
"isFree": {
|
"isFree": {
|
||||||
"question": "Is BentoPDF echt gratis?",
|
"question": "Is BentoPDF echt gratis?",
|
||||||
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
|
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
|
||||||
@@ -197,8 +207,7 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
|
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
|
||||||
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
|
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
|
||||||
},
|
}
|
||||||
"sectionTitle": "Veelgestelde vragen"
|
|
||||||
},
|
},
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"title": "Wat onze",
|
"title": "Wat onze",
|
||||||
@@ -349,17 +358,9 @@
|
|||||||
"error": "Fout",
|
"error": "Fout",
|
||||||
"failedToLoad": "Laden is mislukt"
|
"failedToLoad": "Laden is mislukt"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
|
||||||
"title": "Hoe het werkt",
|
|
||||||
"step1": "Klik of sleep uw bestand hierheen",
|
|
||||||
"step2": "Klik op de verwerkingsknop",
|
|
||||||
"step3": "Sla uw verwerkte bestand direct op"
|
|
||||||
},
|
|
||||||
"relatedTools": {
|
|
||||||
"title": "Gerelateerde PDF-tools"
|
|
||||||
},
|
|
||||||
"simpleMode": {
|
"simpleMode": {
|
||||||
"title": "PDF-tools",
|
"title": "PDF-tools",
|
||||||
"subtitle": "Selecteer een tool om te beginnen"
|
"subtitle": "Selecteer een tool om te beginnen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Paginanummers",
|
"name": "Paginanummers",
|
||||||
"subtitle": "Paginanummers aan je document toevoegen."
|
"subtitle": "Paginanummers aan je document toevoegen."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-nummering",
|
||||||
|
"subtitle": "Voeg opeenvolgende Bates-nummers toe aan een of meer PDF-bestanden."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Watermerk toevoegen",
|
"name": "Watermerk toevoegen",
|
||||||
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen."
|
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.",
|
||||||
|
"applyToAllPages": "Toepassen op alle pagina's"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Koptekst & Voettekst",
|
"name": "Koptekst & Voettekst",
|
||||||
@@ -613,7 +618,7 @@
|
|||||||
"workflowCompleted": "Werkproces voltooid",
|
"workflowCompleted": "Werkproces voltooid",
|
||||||
"errorDuringExecution": "Fout tijdens uitvoering",
|
"errorDuringExecution": "Fout tijdens uitvoering",
|
||||||
"addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
|
"addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
|
||||||
"needInputOutput": "Om te kunnen functioneren heeft een werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.",
|
"needInputOutput": "Het werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.",
|
||||||
"enterName": "Voer een naam in.",
|
"enterName": "Voer een naam in.",
|
||||||
"templateExists": "Er bestaat al een sjabloon met deze naam.",
|
"templateExists": "Er bestaat al een sjabloon met deze naam.",
|
||||||
"templateSaved": "Sjabloon \"{{name}}\" opgeslagen.",
|
"templateSaved": "Sjabloon \"{{name}}\" opgeslagen.",
|
||||||
|
|||||||
+134
-129
@@ -64,9 +64,14 @@
|
|||||||
"name": "Números de Página",
|
"name": "Números de Página",
|
||||||
"subtitle": "Insira números de página no seu documento."
|
"subtitle": "Insira números de página no seu documento."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Numeração Bates",
|
||||||
|
"subtitle": "Adicionar números Bates sequenciais em um ou mais arquivos PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Adicionar Marca d'Água",
|
"name": "Adicionar Marca d'Água",
|
||||||
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF."
|
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF.",
|
||||||
|
"applyToAllPages": "Aplicar a todas as páginas"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Cabeçalho e Rodapé",
|
"name": "Cabeçalho e Rodapé",
|
||||||
@@ -203,7 +208,7 @@
|
|||||||
"subtitle": "Converta arquivos PDF para o formato JSON."
|
"subtitle": "Converta arquivos PDF para o formato JSON."
|
||||||
},
|
},
|
||||||
"ocrPdf": {
|
"ocrPdf": {
|
||||||
"name": "OCR PDF",
|
"name": "OCR para PDF",
|
||||||
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
|
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
|
||||||
},
|
},
|
||||||
"alternateMix": {
|
"alternateMix": {
|
||||||
@@ -325,192 +330,192 @@
|
|||||||
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
|
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
|
||||||
},
|
},
|
||||||
"rotateCustom": {
|
"rotateCustom": {
|
||||||
"name": "Rotate by Custom Degrees",
|
"name": "Rotacionar em Graus Customizáveis",
|
||||||
"subtitle": "Rotate pages by any custom angle."
|
"subtitle": "Rotaciona páginas em qualquer ângulo customizado."
|
||||||
},
|
},
|
||||||
"odtToPdf": {
|
"odtToPdf": {
|
||||||
"name": "ODT to PDF",
|
"name": "ODT para PDF",
|
||||||
"subtitle": "Convert OpenDocument Text files to PDF format. Supports multiple files.",
|
"subtitle": "Converte arquivos em formato OpenDocument Text para PDF. Suporta multiplos arquivos.",
|
||||||
"acceptedFormats": "ODT files",
|
"acceptedFormats": "Arquivos ODT",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"csvToPdf": {
|
"csvToPdf": {
|
||||||
"name": "CSV to PDF",
|
"name": "CSV para PDF",
|
||||||
"subtitle": "Convert CSV spreadsheet files to PDF format. Supports multiple files.",
|
"subtitle": "Converte arquivos de planilhas CSV para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "CSV files",
|
"acceptedFormats": "Arquivos CSV",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"rtfToPdf": {
|
"rtfToPdf": {
|
||||||
"name": "RTF to PDF",
|
"name": "RTF para PDF",
|
||||||
"subtitle": "Convert Rich Text Format documents to PDF. Supports multiple files.",
|
"subtitle": "Converte documentos Rich Text Format para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "RTF files",
|
"acceptedFormats": "Arquivos RTF",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wordToPdf": {
|
"wordToPdf": {
|
||||||
"name": "Word to PDF",
|
"name": "Word para PDF",
|
||||||
"subtitle": "Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports multiple files.",
|
"subtitle": "Converte documentos Word documents (DOCX, DOC, ODT, RTF) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "DOCX, DOC, ODT, RTF files",
|
"acceptedFormats": "Arquivos DOCX, DOC, ODT, RTF",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"excelToPdf": {
|
"excelToPdf": {
|
||||||
"name": "Excel to PDF",
|
"name": "Excel para PDF",
|
||||||
"subtitle": "Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. Supports multiple files.",
|
"subtitle": "Converte planilhas Excel (XLSX, XLS, ODS, CSV) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XLSX, XLS, ODS, CSV files",
|
"acceptedFormats": "Arquivos XLSX, XLS, ODS, CSV",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"powerpointToPdf": {
|
"powerpointToPdf": {
|
||||||
"name": "PowerPoint to PDF",
|
"name": "PowerPoint para PDF",
|
||||||
"subtitle": "Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF format. Supports multiple files.",
|
"subtitle": "Converte apresentações PowerPoint (PPTX, PPT, ODP) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PPTX, PPT, ODP files",
|
"acceptedFormats": "Arquivos PPTX, PPT, ODP",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"markdownToPdf": {
|
"markdownToPdf": {
|
||||||
"name": "Markdown to PDF",
|
"name": "Markdown para PDF",
|
||||||
"subtitle": "Write or paste Markdown and export it as a beautifully formatted PDF.",
|
"subtitle": "Escreva ou cole Markdown e exporte como um belo formato PDF.",
|
||||||
"paneMarkdown": "Markdown",
|
"paneMarkdown": "Markdown",
|
||||||
"panePreview": "Preview",
|
"panePreview": "Pré-visualizar",
|
||||||
"btnUpload": "Upload",
|
"btnUpload": "Upload",
|
||||||
"btnSyncScroll": "Sync Scroll",
|
"btnSyncScroll": "Sincronizar Scroll",
|
||||||
"btnSettings": "Settings",
|
"btnSettings": "Configurações",
|
||||||
"btnExportPdf": "Export PDF",
|
"btnExportPdf": "Exportar PDF",
|
||||||
"settingsTitle": "Markdown Settings",
|
"settingsTitle": "Configurações Markdown",
|
||||||
"settingsPreset": "Preset",
|
"settingsPreset": "Predefinição",
|
||||||
"presetDefault": "Default (GFM-like)",
|
"presetDefault": "Padrão (tipo GFM)",
|
||||||
"presetCommonmark": "CommonMark (strict)",
|
"presetCommonmark": "CommonMark (extrito)",
|
||||||
"presetZero": "Minimal (no features)",
|
"presetZero": "Mínimo (sem features)",
|
||||||
"settingsOptions": "Markdown Options",
|
"settingsOptions": "Opções de Markdown",
|
||||||
"optAllowHtml": "Allow HTML tags",
|
"optAllowHtml": "Permitir tags HTML",
|
||||||
"optBreaks": "Convert newlines to <br>",
|
"optBreaks": "Converter novas linhas em <br>",
|
||||||
"optLinkify": "Auto-convert URLs to links",
|
"optLinkify": "Auto-converter URLs para links",
|
||||||
"optTypographer": "Typographer (smart quotes, etc.)"
|
"optTypographer": "Tipógrafo (aspas inteligentes, etc.)"
|
||||||
},
|
},
|
||||||
"pdfBooklet": {
|
"pdfBooklet": {
|
||||||
"name": "PDF Booklet",
|
"name": "PDF Booklet",
|
||||||
"subtitle": "Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet.",
|
"subtitle": "Reordena páginas para impressão de livreto frente e verso. Dobre e grampeie para criar um livreto.",
|
||||||
"howItWorks": "How it works:",
|
"howItWorks": "Como funciona:",
|
||||||
"step1": "Upload a PDF file.",
|
"step1": "Faça o upload de um arquivo PDF.",
|
||||||
"step2": "Pages will be rearranged in booklet order.",
|
"step2": "As páginas serão reordenadas na ordem de livretos.",
|
||||||
"step3": "Print double-sided, flip on short edge, fold and staple.",
|
"step3": "Imprima frente e verso, vire pelo topo, dobre e grampeie.",
|
||||||
"paperSize": "Paper Size",
|
"paperSize": "Tamanho do Papel",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientação",
|
||||||
"portrait": "Portrait",
|
"portrait": "Retrato",
|
||||||
"landscape": "Landscape",
|
"landscape": "Paisagem",
|
||||||
"pagesPerSheet": "Pages per Sheet",
|
"pagesPerSheet": "Páginas por Folha",
|
||||||
"createBooklet": "Create Booklet",
|
"createBooklet": "Criar Livreto",
|
||||||
"processing": "Processing...",
|
"processing": "Processando...",
|
||||||
"pageCount": "Page count will be padded to multiple of 4 if needed."
|
"pageCount": "A contagem de páginas será ajustada para um múltiplo de 4, caso necessário."
|
||||||
},
|
},
|
||||||
"xpsToPdf": {
|
"xpsToPdf": {
|
||||||
"name": "XPS to PDF",
|
"name": "XPS para PDF",
|
||||||
"subtitle": "Convert XPS/OXPS documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte XPS/OXPS para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XPS, OXPS files",
|
"acceptedFormats": "Arquivos XPS, OXPS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"mobiToPdf": {
|
"mobiToPdf": {
|
||||||
"name": "MOBI to PDF",
|
"name": "MOBI para PDF",
|
||||||
"subtitle": "Convert MOBI e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books MOBI para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "MOBI files",
|
"acceptedFormats": "Arquivos MOBI",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"epubToPdf": {
|
"epubToPdf": {
|
||||||
"name": "EPUB to PDF",
|
"name": "EPUB para PDF",
|
||||||
"subtitle": "Convert EPUB e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books EPUB para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "EPUB files",
|
"acceptedFormats": "Arquivos EPUB",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"fb2ToPdf": {
|
"fb2ToPdf": {
|
||||||
"name": "FB2 to PDF",
|
"name": "FB2 para PDF",
|
||||||
"subtitle": "Convert FictionBook (FB2) e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books FictionBook (FB2) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "FB2 files",
|
"acceptedFormats": "Arquivos FB2",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"cbzToPdf": {
|
"cbzToPdf": {
|
||||||
"name": "CBZ to PDF",
|
"name": "CBZ para PDF",
|
||||||
"subtitle": "Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple files.",
|
"subtitle": "Converte comic book archives (CBZ/CBR) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "CBZ, CBR files",
|
"acceptedFormats": "Arquivos CBZ, CBR",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wpdToPdf": {
|
"wpdToPdf": {
|
||||||
"name": "WPD to PDF",
|
"name": "WPD para PDF",
|
||||||
"subtitle": "Convert WordPerfect documents (WPD) to PDF format. Supports multiple files.",
|
"subtitle": "Converte WordPerfect (WPD) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "WPD files",
|
"acceptedFormats": "Arquivos WPD",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wpsToPdf": {
|
"wpsToPdf": {
|
||||||
"name": "WPS to PDF",
|
"name": "WPS para PDF",
|
||||||
"subtitle": "Convert WPS Office documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte WPS Office para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "WPS files",
|
"acceptedFormats": "Arquivos WPS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"xmlToPdf": {
|
"xmlToPdf": {
|
||||||
"name": "XML to PDF",
|
"name": "XML para PDF",
|
||||||
"subtitle": "Convert XML documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte XML para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XML files",
|
"acceptedFormats": "Arquivos XML",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pagesToPdf": {
|
"pagesToPdf": {
|
||||||
"name": "Pages to PDF",
|
"name": "Pages para PDF",
|
||||||
"subtitle": "Convert Apple Pages documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte Apple Pages para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "Pages files",
|
"acceptedFormats": "Arquivos Pages",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odgToPdf": {
|
"odgToPdf": {
|
||||||
"name": "ODG to PDF",
|
"name": "ODG para PDF",
|
||||||
"subtitle": "Convert OpenDocument Graphics (ODG) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Graphics (ODG) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODG files",
|
"acceptedFormats": "Arquivos ODG",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odsToPdf": {
|
"odsToPdf": {
|
||||||
"name": "ODS to PDF",
|
"name": "ODS para PDF",
|
||||||
"subtitle": "Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Spreadsheet (ODS) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODS files",
|
"acceptedFormats": "Arquivos ODS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odpToPdf": {
|
"odpToPdf": {
|
||||||
"name": "ODP to PDF",
|
"name": "ODP para PDF",
|
||||||
"subtitle": "Convert OpenDocument Presentation (ODP) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Presentation (ODP) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODP files",
|
"acceptedFormats": "Arquivos ODP",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pubToPdf": {
|
"pubToPdf": {
|
||||||
"name": "PUB to PDF",
|
"name": "PUB para PDF",
|
||||||
"subtitle": "Convert Microsoft Publisher (PUB) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Microsoft Publisher (PUB) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PUB files",
|
"acceptedFormats": "Arquivos PUB",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"vsdToPdf": {
|
"vsdToPdf": {
|
||||||
"name": "VSD to PDF",
|
"name": "VSD para PDF",
|
||||||
"subtitle": "Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Microsoft Visio (VSD, VSDX) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "VSD, VSDX files",
|
"acceptedFormats": "Arquivos VSD, VSDX",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"psdToPdf": {
|
"psdToPdf": {
|
||||||
"name": "PSD to PDF",
|
"name": "PSD para PDF",
|
||||||
"subtitle": "Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Adobe Photoshop (PSD) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PSD files",
|
"acceptedFormats": "Arquivos PSD",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pdfToSvg": {
|
"pdfToSvg": {
|
||||||
"name": "PDF to SVG",
|
"name": "PDF para SVG",
|
||||||
"subtitle": "Convert each page of a PDF file into a scalable vector graphic (SVG) for perfect quality at any size."
|
"subtitle": "Converte cada página de um arquivo PDF em um scalable vector graphic (SVG) para qualidade perfeita em qualquer tamanho."
|
||||||
},
|
},
|
||||||
"extractTables": {
|
"extractTables": {
|
||||||
"name": "Extract PDF Tables",
|
"name": "Extrair Tabelas PDF",
|
||||||
"subtitle": "Extract tables from PDF files and export as CSV, JSON, or Markdown."
|
"subtitle": "Extrai tabelas de arquivos PDF e exporta como CSV, JSON, ou Markdown."
|
||||||
},
|
},
|
||||||
"pdfToCsv": {
|
"pdfToCsv": {
|
||||||
"name": "PDF to CSV",
|
"name": "PDF para CSV",
|
||||||
"subtitle": "Extract tables from PDF and convert to CSV format."
|
"subtitle": "Extrai tabelas do PDF e converte em CSV."
|
||||||
},
|
},
|
||||||
"pdfToExcel": {
|
"pdfToExcel": {
|
||||||
"name": "PDF to Excel",
|
"name": "PDF para Excel",
|
||||||
"subtitle": "Extract tables from PDF and convert to Excel (XLSX) format."
|
"subtitle": "Extrai tabelas do PDF e converte em Excel (XLSX)."
|
||||||
},
|
},
|
||||||
"pdfToText": {
|
"pdfToText": {
|
||||||
"name": "PDF to Text",
|
"name": "PDF para Texto",
|
||||||
"subtitle": "Extract text from PDF files and save as plain text (.txt). Supports multiple files.",
|
"subtitle": "Extrai texto de arquivos PDF e salva como texto simples (.txt). Suporta múltiplos arquivos.",
|
||||||
"note": "This tool works ONLY with digitally created PDFs. For scanned documents or image-based PDFs, use our OCR PDF tool instead.",
|
"note": "Esta ferrament funciona SOMENTE com PDFs criados digitalmente. Para documentos escaneados ou PDFs baseados em imagens, use nossa ferramenta OCR para PDF no lugar.",
|
||||||
"convertButton": "Extract Text"
|
"convertButton": "Extrair Texto"
|
||||||
},
|
},
|
||||||
"digitalSignPdf": {
|
"digitalSignPdf": {
|
||||||
"name": "Assinatura Digital PDF",
|
"name": "Assinatura Digital PDF",
|
||||||
|
|||||||
@@ -64,9 +64,14 @@
|
|||||||
"name": "Sayfa Numaraları",
|
"name": "Sayfa Numaraları",
|
||||||
"subtitle": "Belgenize sayfa numaraları ekleyin."
|
"subtitle": "Belgenize sayfa numaraları ekleyin."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates Numaralandırma",
|
||||||
|
"subtitle": "Bir veya daha fazla PDF dosyasına sıralı Bates numaraları ekleyin."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Filigran Ekle",
|
"name": "Filigran Ekle",
|
||||||
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin."
|
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin.",
|
||||||
|
"applyToAllPages": "Tüm sayfalara uygula"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Üst Bilgi & Alt Bilgi",
|
"name": "Üst Bilgi & Alt Bilgi",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "Số trang",
|
"name": "Số trang",
|
||||||
"subtitle": "Chèn số trang vào tài liệu của bạn."
|
"subtitle": "Chèn số trang vào tài liệu của bạn."
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Đánh số Bates",
|
||||||
|
"subtitle": "Thêm số Bates tuần tự trên một hoặc nhiều tệp PDF."
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Thêm Watermark",
|
"name": "Thêm Watermark",
|
||||||
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn."
|
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn.",
|
||||||
|
"applyToAllPages": "Áp dụng cho tất cả các trang"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Đầu trang & Chân trang",
|
"name": "Đầu trang & Chân trang",
|
||||||
|
|||||||
@@ -64,9 +64,14 @@
|
|||||||
"name": "頁碼",
|
"name": "頁碼",
|
||||||
"subtitle": "在你的文件中插入頁碼。"
|
"subtitle": "在你的文件中插入頁碼。"
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates編號",
|
||||||
|
"subtitle": "在一個或多個PDF檔案中新增連續的Bates編號。"
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加浮水印",
|
"name": "添加浮水印",
|
||||||
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。"
|
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。",
|
||||||
|
"applyToAllPages": "套用至所有頁面"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "頁首與頁尾",
|
"name": "頁首與頁尾",
|
||||||
|
|||||||
@@ -86,9 +86,14 @@
|
|||||||
"name": "页码",
|
"name": "页码",
|
||||||
"subtitle": "将页码插入到您的文档中。"
|
"subtitle": "将页码插入到您的文档中。"
|
||||||
},
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates编号",
|
||||||
|
"subtitle": "在一个或多个PDF文件中添加连续的Bates编号。"
|
||||||
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加水印",
|
"name": "添加水印",
|
||||||
"subtitle": "在您的 PDF 页面上添加文字或图片水印。"
|
"subtitle": "在您的 PDF 页面上添加文字或图片水印。",
|
||||||
|
"applyToAllPages": "应用到所有页面"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "页眉和页脚",
|
"name": "页眉和页脚",
|
||||||
|
|||||||
@@ -269,4 +269,7 @@ async function generateI18nPages() {
|
|||||||
console.log('✅ i18n pages generated successfully!');
|
console.log('✅ i18n pages generated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateI18nPages().catch(console.error);
|
generateI18nPages().catch((err) => {
|
||||||
|
console.error('❌ i18n page generation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -311,6 +311,30 @@
|
|||||||
"created_at": "2026-02-03T13:50:03Z",
|
"created_at": "2026-02-03T13:50:03Z",
|
||||||
"repoId": 1074785178,
|
"repoId": 1074785178,
|
||||||
"pullRequestNo": 461
|
"pullRequestNo": 461
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tzabbi",
|
||||||
|
"id": 40226087,
|
||||||
|
"comment_id": 3926638030,
|
||||||
|
"created_at": "2026-02-19T11:27:36Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 493
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "diogotcorreia",
|
||||||
|
"id": 7467891,
|
||||||
|
"comment_id": 3937759209,
|
||||||
|
"created_at": "2026-02-21T00:26:29Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 501
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hagibr",
|
||||||
|
"id": 23699887,
|
||||||
|
"comment_id": 3960964454,
|
||||||
|
"created_at": "2026-02-25T17:50:30Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 516
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -332,6 +332,38 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="compact-mode-toggle"
|
||||||
|
class="text-sm font-medium text-gray-200 cursor-pointer"
|
||||||
|
data-i18n="settings.compactMode"
|
||||||
|
>
|
||||||
|
Compact Mode
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
data-i18n="settings.compactModeDescription"
|
||||||
|
>
|
||||||
|
Display tools as a compact list instead of cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="relative inline-flex items-center cursor-pointer ml-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="compact-mode-toggle"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="wasm-settings.html"
|
href="wasm-settings.html"
|
||||||
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group"
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group"
|
||||||
|
|||||||
+147
-14
@@ -76,6 +76,106 @@ body {
|
|||||||
border-color: #4f46e5;
|
border-color: #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-group:first-child .category-header {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover .category-chevron {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tools {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-group.collapsed .category-chevron {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card {
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
background-color: transparent;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card i {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card i.ph {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode .tool-card:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.compact-mode .category-tools {
|
||||||
|
grid-template-columns: repeat(3, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
transition:
|
transition:
|
||||||
background-color 0.2s ease-in-out,
|
background-color 0.2s ease-in-out,
|
||||||
@@ -116,7 +216,7 @@ input[type='file']::file-selector-button {
|
|||||||
border: 2px dashed #4f46e5;
|
border: 2px dashed #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#embed-pdf-container>div {
|
#embed-pdf-container > div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -126,7 +226,7 @@ input[type='file']::file-selector-button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-thumbnail,
|
.page-thumbnail,
|
||||||
#file-list>li {
|
#file-list > li {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +364,6 @@ footer a {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(to right, transparent, #4d44f7, transparent);
|
background: linear-gradient(to right, transparent, #4d44f7, transparent);
|
||||||
@@ -501,19 +599,19 @@ footer a {
|
|||||||
color: rgb(165 180 252);
|
color: rgb(165 180 252);
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary {
|
details > summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary::-webkit-details-marker {
|
details > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary .icon {
|
details > summary .icon {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
details[open]>summary .icon {
|
details[open] > summary .icon {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +635,9 @@ button:disabled,
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
background-color: #374151;
|
background-color: #374151;
|
||||||
@@ -547,7 +647,9 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-input {
|
.shortcut-input {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
|
'Courier New', monospace;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,18 +693,49 @@ button:disabled,
|
|||||||
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
||||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
|
-webkit-mask-image: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
black 40%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker */
|
||||||
|
input[type='color'] {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #4b5563;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide spin buttons for number inputs */
|
/* Hide spin buttons for number inputs */
|
||||||
input[type="number"] {
|
input[type='number'] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-moz-appearance: textfield; /* Firefox */
|
-moz-appearance: textfield; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="number"]::-webkit-inner-spin-button,
|
input[type='number']::-webkit-inner-spin-button,
|
||||||
input[type="number"]::-webkit-outer-spin-button {
|
input[type='number']::-webkit-outer-spin-button {
|
||||||
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
-webkit-appearance: none; /* Chrome, Safari, Edge */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,12 @@ export const categories = [
|
|||||||
icon: 'ph-list-numbers',
|
icon: 'ph-list-numbers',
|
||||||
subtitle: 'Insert page numbers into your document.',
|
subtitle: 'Insert page numbers into your document.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: import.meta.env.BASE_URL + 'bates-numbering.html',
|
||||||
|
name: 'Bates Numbering',
|
||||||
|
icon: 'ph-hash',
|
||||||
|
subtitle: 'Add sequential Bates numbers across one or more PDF files.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: import.meta.env.BASE_URL + 'add-watermark.html',
|
href: import.meta.env.BASE_URL + 'add-watermark.html',
|
||||||
name: 'Add Watermark',
|
name: 'Add Watermark',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PACKAGE_VERSIONS = {
|
export const PACKAGE_VERSIONS = {
|
||||||
ghostscript: '0.1.1',
|
ghostscript: '0.1.1',
|
||||||
pymupdf: '0.11.14',
|
pymupdf: '0.11.16',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+887
-128
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
|||||||
|
import { createIcons, icons } from 'lucide';
|
||||||
|
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||||
|
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
import { FileEntry, Position, StylePreset } from '@/types';
|
||||||
|
|
||||||
|
const FONT_MAP: Record<string, keyof typeof StandardFonts> = {
|
||||||
|
Helvetica: 'Helvetica',
|
||||||
|
TimesRoman: 'TimesRoman',
|
||||||
|
Courier: 'Courier',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLE_PRESETS: Record<string, StylePreset> = {
|
||||||
|
'full-6': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
'full-5': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
'full-4': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
'full-3': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 3,
|
||||||
|
},
|
||||||
|
'full-0': {
|
||||||
|
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
'no-page-6': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 6 },
|
||||||
|
'no-page-5': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 5 },
|
||||||
|
'no-page-4': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 4 },
|
||||||
|
'no-page-3': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 3 },
|
||||||
|
'no-page-0': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 0 },
|
||||||
|
'case-6': { template: 'Case XYZ [BATES]', padding: 6 },
|
||||||
|
'case-5': { template: 'Case XYZ [BATES]', padding: 5 },
|
||||||
|
'case-4': { template: 'Case XYZ [BATES]', padding: 4 },
|
||||||
|
'case-3': { template: 'Case XYZ [BATES]', padding: 3 },
|
||||||
|
'case-0': { template: 'Case XYZ [BATES]', padding: 0 },
|
||||||
|
'bates-6': { template: '[BATES]', padding: 6 },
|
||||||
|
'bates-5': { template: '[BATES]', padding: 5 },
|
||||||
|
'bates-4': { template: '[BATES]', padding: 4 },
|
||||||
|
'bates-3': { template: '[BATES]', padding: 3 },
|
||||||
|
'bates-0': { template: '[BATES]', padding: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const files: FileEntry[] = [];
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
|
} else {
|
||||||
|
initializePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializePage() {
|
||||||
|
createIcons({ icons });
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const backBtn = document.getElementById('back-to-tools');
|
||||||
|
const processBtn = document.getElementById('process-btn');
|
||||||
|
const addMoreBtn = document.getElementById('add-more-btn');
|
||||||
|
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||||
|
const stylePreset = document.getElementById(
|
||||||
|
'style-preset'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const templateInput = document.getElementById(
|
||||||
|
'bates-template'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (fileInput.files?.length) {
|
||||||
|
handleFiles(fileInput.files);
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropZone) {
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('border-indigo-500');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('border-indigo-500');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('border-indigo-500');
|
||||||
|
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.addEventListener('click', () => {
|
||||||
|
window.location.href = import.meta.env.BASE_URL;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processBtn) {
|
||||||
|
processBtn.addEventListener('click', applyBatesNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addMoreBtn) {
|
||||||
|
addMoreBtn.addEventListener('click', () => fileInput?.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearFilesBtn) {
|
||||||
|
clearFilesBtn.addEventListener('click', resetState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stylePreset) {
|
||||||
|
stylePreset.addEventListener('change', () => {
|
||||||
|
const value = stylePreset.value;
|
||||||
|
const isCustom = value === 'custom';
|
||||||
|
const paddingGroup = document.getElementById('padding-group');
|
||||||
|
if (!isCustom && STYLE_PRESETS[value]) {
|
||||||
|
templateInput.value = STYLE_PRESETS[value].template;
|
||||||
|
if (paddingGroup) paddingGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (paddingGroup) paddingGroup.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
templateInput.readOnly = !isCustom;
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateInput) {
|
||||||
|
templateInput.addEventListener('input', () => {
|
||||||
|
const preset = stylePreset;
|
||||||
|
if (preset && preset.value !== 'custom') {
|
||||||
|
preset.value = 'custom';
|
||||||
|
templateInput.readOnly = false;
|
||||||
|
document.getElementById('padding-group')?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById('bates-padding')
|
||||||
|
?.addEventListener('change', updatePreview);
|
||||||
|
document
|
||||||
|
.getElementById('bates-start')
|
||||||
|
?.addEventListener('input', updatePreview);
|
||||||
|
document
|
||||||
|
.getElementById('file-start')
|
||||||
|
?.addEventListener('input', updatePreview);
|
||||||
|
|
||||||
|
initSortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortable() {
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
if (!fileList) return;
|
||||||
|
Sortable.create(fileList, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
onEnd: (evt) => {
|
||||||
|
if (evt.oldIndex !== undefined && evt.newIndex !== undefined) {
|
||||||
|
const [moved] = files.splice(evt.oldIndex, 1);
|
||||||
|
files.splice(evt.newIndex, 0, moved);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(fileList: FileList) {
|
||||||
|
showLoader('Loading PDFs...');
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
if (file.type !== 'application/pdf') continue;
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||||
|
files.push({ file, pageCount: pdfDoc.getPageCount() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
showAlert('Invalid File', 'Please upload valid PDF files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFileList();
|
||||||
|
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||||
|
document.getElementById('file-controls')?.classList.remove('hidden');
|
||||||
|
updatePreview();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showAlert('Error', 'Failed to load one or more PDF files.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileList() {
|
||||||
|
const fileListEl = document.getElementById('file-list');
|
||||||
|
if (!fileListEl) return;
|
||||||
|
|
||||||
|
fileListEl.innerHTML = '';
|
||||||
|
let totalPages = 0;
|
||||||
|
|
||||||
|
files.forEach((entry, index) => {
|
||||||
|
totalPages += entry.pageCount;
|
||||||
|
|
||||||
|
const fileDiv = document.createElement('div');
|
||||||
|
fileDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
|
|
||||||
|
const leftSection = document.createElement('div');
|
||||||
|
leftSection.className = 'flex items-center gap-3 flex-1 min-w-0';
|
||||||
|
|
||||||
|
const dragHandle = document.createElement('i');
|
||||||
|
dragHandle.setAttribute('data-lucide', 'grip-vertical');
|
||||||
|
dragHandle.className =
|
||||||
|
'drag-handle w-4 h-4 text-gray-400 cursor-grab flex-shrink-0';
|
||||||
|
|
||||||
|
const infoContainer = document.createElement('div');
|
||||||
|
infoContainer.className = 'flex flex-col min-w-0';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('div');
|
||||||
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm';
|
||||||
|
nameSpan.textContent = entry.file.name;
|
||||||
|
|
||||||
|
const metaSpan = document.createElement('div');
|
||||||
|
metaSpan.className = 'text-xs text-gray-400';
|
||||||
|
metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`;
|
||||||
|
|
||||||
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
leftSection.append(dragHandle, infoContainer);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||||
|
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
|
removeBtn.onclick = () => {
|
||||||
|
files.splice(index, 1);
|
||||||
|
renderFileList();
|
||||||
|
updatePreview();
|
||||||
|
if (files.length === 0) resetState();
|
||||||
|
};
|
||||||
|
|
||||||
|
fileDiv.append(leftSection, removeBtn);
|
||||||
|
fileListEl.appendChild(fileDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
createIcons({ icons });
|
||||||
|
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'text-xs text-gray-400 mt-1';
|
||||||
|
summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`;
|
||||||
|
fileListEl.appendChild(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBatesText(
|
||||||
|
template: string,
|
||||||
|
batesNum: number,
|
||||||
|
pageNum: number,
|
||||||
|
fileNum: number,
|
||||||
|
fileName: string,
|
||||||
|
padding: number
|
||||||
|
): string {
|
||||||
|
const batesStr =
|
||||||
|
padding > 0 ? String(batesNum).padStart(padding, '0') : String(batesNum);
|
||||||
|
return template
|
||||||
|
.replace(/\[BATES\]/g, batesStr)
|
||||||
|
.replace(/\[PAGE\]/g, String(pageNum))
|
||||||
|
.replace(/\[FILE\]/g, String(fileNum))
|
||||||
|
.replace(/\[FILENAME\]/g, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivePadding(): number {
|
||||||
|
const presetValue = (
|
||||||
|
document.getElementById('style-preset') as HTMLSelectElement
|
||||||
|
).value;
|
||||||
|
if (presetValue !== 'custom' && STYLE_PRESETS[presetValue]) {
|
||||||
|
return STYLE_PRESETS[presetValue].padding;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-padding') as HTMLSelectElement).value
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const previewEl = document.getElementById('preview-content');
|
||||||
|
if (!previewEl) return;
|
||||||
|
|
||||||
|
const template = (
|
||||||
|
document.getElementById('bates-template') as HTMLInputElement
|
||||||
|
).value;
|
||||||
|
const padding = getActivePadding();
|
||||||
|
const batesStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const fileStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('file-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
lines.push(
|
||||||
|
formatBatesText(template, batesStart, 1, fileStart, 'document', padding)
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
formatBatesText(
|
||||||
|
template,
|
||||||
|
batesStart + 1,
|
||||||
|
2,
|
||||||
|
fileStart,
|
||||||
|
'document',
|
||||||
|
padding
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let batesCounter = batesStart;
|
||||||
|
let fileCounter = fileStart;
|
||||||
|
for (const entry of files) {
|
||||||
|
const name = entry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
lines.push(
|
||||||
|
`File ${fileCounter}, Page 1: ${formatBatesText(template, batesCounter, 1, fileCounter, name, padding)}`
|
||||||
|
);
|
||||||
|
if (entry.pageCount > 1) {
|
||||||
|
lines.push(
|
||||||
|
`File ${fileCounter}, Page 2: ${formatBatesText(template, batesCounter + 1, 2, fileCounter, name, padding)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batesCounter += entry.pageCount;
|
||||||
|
fileCounter++;
|
||||||
|
}
|
||||||
|
const lastEntry = files[files.length - 1];
|
||||||
|
const lastName = lastEntry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
const lastBates = batesCounter - 1;
|
||||||
|
lines.push('...');
|
||||||
|
lines.push(
|
||||||
|
`File ${fileStart + files.length - 1}, Page ${lastEntry.pageCount}: ${formatBatesText(template, lastBates, lastEntry.pageCount, fileStart + files.length - 1, lastName, padding)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewEl.textContent = lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePosition(
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
xOffset: number,
|
||||||
|
yOffset: number,
|
||||||
|
textWidth: number,
|
||||||
|
fontSize: number,
|
||||||
|
position: Position
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const minMargin = 8;
|
||||||
|
const maxMargin = 40;
|
||||||
|
const marginPct = 0.04;
|
||||||
|
|
||||||
|
const hMargin = Math.max(
|
||||||
|
minMargin,
|
||||||
|
Math.min(maxMargin, pageWidth * marginPct)
|
||||||
|
);
|
||||||
|
const vMargin = Math.max(
|
||||||
|
minMargin,
|
||||||
|
Math.min(maxMargin, pageHeight * marginPct)
|
||||||
|
);
|
||||||
|
const safeH = Math.max(hMargin, textWidth / 2 + 3);
|
||||||
|
const safeV = Math.max(vMargin, fontSize + 3);
|
||||||
|
|
||||||
|
let x = 0,
|
||||||
|
y = 0;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom-center':
|
||||||
|
x =
|
||||||
|
Math.max(
|
||||||
|
safeH,
|
||||||
|
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
|
||||||
|
) + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'bottom-left':
|
||||||
|
x = safeH + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
|
||||||
|
y = safeV + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
x =
|
||||||
|
Math.max(
|
||||||
|
safeH,
|
||||||
|
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
|
||||||
|
) + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-left':
|
||||||
|
x = safeH + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
case 'top-right':
|
||||||
|
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
|
||||||
|
y = pageHeight - safeV - fontSize + yOffset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
x = Math.max(xOffset + 3, Math.min(xOffset + pageWidth - textWidth - 3, x));
|
||||||
|
y = Math.max(yOffset + 3, Math.min(yOffset + pageHeight - fontSize - 3, y));
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
files.length = 0;
|
||||||
|
const fileListEl = document.getElementById('file-list');
|
||||||
|
if (fileListEl) fileListEl.innerHTML = '';
|
||||||
|
document.getElementById('options-panel')?.classList.add('hidden');
|
||||||
|
document.getElementById('file-controls')?.classList.add('hidden');
|
||||||
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBatesNumbers() {
|
||||||
|
if (files.length === 0) {
|
||||||
|
showAlert('Error', 'Please upload at least one PDF file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoader('Applying Bates numbers...');
|
||||||
|
try {
|
||||||
|
const template = (
|
||||||
|
document.getElementById('bates-template') as HTMLInputElement
|
||||||
|
).value;
|
||||||
|
const padding = getActivePadding();
|
||||||
|
const batesStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('bates-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const fileStart =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('file-start') as HTMLInputElement).value
|
||||||
|
) || 1;
|
||||||
|
const position = (document.getElementById('position') as HTMLSelectElement)
|
||||||
|
.value as Position;
|
||||||
|
const fontKey = (
|
||||||
|
document.getElementById('font-family') as HTMLSelectElement
|
||||||
|
).value;
|
||||||
|
const fontSize =
|
||||||
|
parseInt(
|
||||||
|
(document.getElementById('font-size') as HTMLInputElement).value
|
||||||
|
) || 10;
|
||||||
|
const colorHex = (document.getElementById('text-color') as HTMLInputElement)
|
||||||
|
.value;
|
||||||
|
const textColor = hexToRgb(colorHex);
|
||||||
|
|
||||||
|
const fontName = FONT_MAP[fontKey] || 'Helvetica';
|
||||||
|
const results: { name: string; bytes: Uint8Array }[] = [];
|
||||||
|
let batesCounter = batesStart;
|
||||||
|
let fileCounter = fileStart;
|
||||||
|
|
||||||
|
for (const entry of files) {
|
||||||
|
const arrayBuffer = await entry.file.arrayBuffer();
|
||||||
|
const pdfDoc = await PDFDocument.load(arrayBuffer);
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts[fontName]);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const fileName = entry.file.name.replace(/\.pdf$/i, '');
|
||||||
|
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const page = pages[i];
|
||||||
|
const bounds = page.getCropBox() || page.getMediaBox();
|
||||||
|
const text = formatBatesText(
|
||||||
|
template,
|
||||||
|
batesCounter,
|
||||||
|
i + 1,
|
||||||
|
fileCounter,
|
||||||
|
fileName,
|
||||||
|
padding
|
||||||
|
);
|
||||||
|
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||||
|
|
||||||
|
const { x, y } = calculatePosition(
|
||||||
|
bounds.width,
|
||||||
|
bounds.height,
|
||||||
|
bounds.x || 0,
|
||||||
|
bounds.y || 0,
|
||||||
|
textWidth,
|
||||||
|
fontSize,
|
||||||
|
position
|
||||||
|
);
|
||||||
|
|
||||||
|
page.drawText(text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
font,
|
||||||
|
size: fontSize,
|
||||||
|
color: rgb(textColor.r, textColor.g, textColor.b),
|
||||||
|
});
|
||||||
|
|
||||||
|
batesCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCounter++;
|
||||||
|
const pdfBytes = await pdfDoc.save();
|
||||||
|
results.push({
|
||||||
|
name: `bates_${entry.file.name}`,
|
||||||
|
bytes: new Uint8Array(pdfBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 1) {
|
||||||
|
downloadFile(
|
||||||
|
new Blob([new Uint8Array(results[0].bytes)], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
}),
|
||||||
|
results[0].name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const result of results) {
|
||||||
|
zip.file(result.name, result.bytes);
|
||||||
|
}
|
||||||
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
downloadFile(zipBlob, 'bates_numbered.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(
|
||||||
|
'Success',
|
||||||
|
`Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`,
|
||||||
|
'success',
|
||||||
|
() => {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showAlert('Error', 'Failed to apply Bates numbers.');
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" />
|
|||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
</select>
|
</select>
|
||||||
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden w-full h-10 mt-2 rounded cursor-pointer border border-gray-300" value="#000000" />' : ''}
|
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden mt-2" value="#000000" />' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (field.type === 'destination') {
|
} else if (field.type === 'destination') {
|
||||||
|
|||||||
+217
-178
@@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner';
|
|||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
import { CertificateData, SignPdfOptions } from '@/types';
|
import { CertificateData, SignPdfOptions } from '@/types';
|
||||||
|
|
||||||
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData {
|
export function parsePfxFile(
|
||||||
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes)));
|
pfxBytes: ArrayBuffer,
|
||||||
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
password: string
|
||||||
|
): CertificateData {
|
||||||
|
const pfxAsn1 = forge.asn1.fromDer(
|
||||||
|
forge.util.createBuffer(new Uint8Array(pfxBytes))
|
||||||
|
);
|
||||||
|
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
|
||||||
|
|
||||||
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
|
||||||
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||||
|
|
||||||
const certBagArray = certBags[forge.pki.oids.certBag];
|
const certBagArray = certBags[forge.pki.oids.certBag];
|
||||||
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
|
||||||
|
|
||||||
if (!certBagArray || certBagArray.length === 0) {
|
if (!certBagArray || certBagArray.length === 0) {
|
||||||
throw new Error('No certificate found in PFX file');
|
throw new Error('No certificate found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyBagArray || keyBagArray.length === 0) {
|
if (!keyBagArray || keyBagArray.length === 0) {
|
||||||
throw new Error('No private key found in PFX file');
|
throw new Error('No private key found in PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
const certificate = certBagArray[0].cert;
|
const certificate = certBagArray[0].cert;
|
||||||
|
|
||||||
if (!certificate) {
|
if (!certificate) {
|
||||||
throw new Error('Failed to extract certificate from PFX file');
|
throw new Error('Failed to extract certificate from PFX file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { p12Buffer: pfxBytes, password, certificate };
|
return { p12Buffer: pfxBytes, password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePemFiles(
|
export function parsePemFiles(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
keyPem: string,
|
keyPem: string,
|
||||||
keyPassword?: string
|
keyPassword?: string
|
||||||
): CertificateData {
|
): CertificateData {
|
||||||
const certificate = forge.pki.certificateFromPem(certPem);
|
const certificate = forge.pki.certificateFromPem(certPem);
|
||||||
|
|
||||||
let privateKey: forge.pki.PrivateKey;
|
let privateKey: forge.pki.PrivateKey;
|
||||||
if (keyPem.includes('ENCRYPTED')) {
|
if (keyPem.includes('ENCRYPTED')) {
|
||||||
if (!keyPassword) {
|
if (!keyPassword) {
|
||||||
throw new Error('Password required for encrypted private key');
|
throw new Error('Password required for encrypted private key');
|
||||||
}
|
|
||||||
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Failed to decrypt private key');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
|
||||||
}
|
}
|
||||||
|
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
|
||||||
const p12Password = keyPassword || 'temp-password';
|
if (!privateKey) {
|
||||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
throw new Error('Failed to decrypt private key');
|
||||||
privateKey,
|
|
||||||
[certificate],
|
|
||||||
p12Password,
|
|
||||||
{ algorithm: '3des' }
|
|
||||||
);
|
|
||||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
|
||||||
const p12Buffer = new Uint8Array(p12Der.length);
|
|
||||||
for (let i = 0; i < p12Der.length; i++) {
|
|
||||||
p12Buffer[i] = p12Der.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
privateKey = forge.pki.privateKeyFromPem(keyPem);
|
||||||
|
}
|
||||||
|
|
||||||
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
const p12Password = keyPassword || crypto.randomUUID();
|
||||||
|
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
privateKey,
|
||||||
|
[certificate],
|
||||||
|
p12Password,
|
||||||
|
{ algorithm: '3des' }
|
||||||
|
);
|
||||||
|
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||||
|
const p12Buffer = new Uint8Array(p12Der.length);
|
||||||
|
for (let i = 0; i < p12Der.length; i++) {
|
||||||
|
p12Buffer[i] = p12Der.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCombinedPem(pemContent: string, password?: string): CertificateData {
|
export function parseCombinedPem(
|
||||||
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
|
pemContent: string,
|
||||||
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/);
|
password?: string
|
||||||
|
): CertificateData {
|
||||||
|
const certMatch = pemContent.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
const keyMatch = pemContent.match(
|
||||||
|
/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/
|
||||||
|
);
|
||||||
|
|
||||||
if (!certMatch) {
|
if (!certMatch) {
|
||||||
throw new Error('No certificate found in PEM file');
|
throw new Error('No certificate found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyMatch) {
|
if (!keyMatch) {
|
||||||
throw new Error('No private key found in PEM file');
|
throw new Error('No private key found in PEM file');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
return parsePemFiles(certMatch[0], keyMatch[0], password);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
|
|||||||
*/
|
*/
|
||||||
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
|
||||||
|
|
||||||
async function generateProxySignature(url: string, timestamp: number): Promise<string> {
|
async function generateProxySignature(
|
||||||
const encoder = new TextEncoder();
|
url: string,
|
||||||
const key = await crypto.subtle.importKey(
|
timestamp: number
|
||||||
'raw',
|
): Promise<string> {
|
||||||
encoder.encode(CORS_PROXY_SECRET),
|
const encoder = new TextEncoder();
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
const key = await crypto.subtle.importKey(
|
||||||
false,
|
'raw',
|
||||||
['sign']
|
encoder.encode(CORS_PROXY_SECRET),
|
||||||
);
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
const message = `${url}${timestamp}`;
|
const message = `${url}${timestamp}`;
|
||||||
const signature = await crypto.subtle.sign(
|
const signature = await crypto.subtle.sign(
|
||||||
'HMAC',
|
'HMAC',
|
||||||
key,
|
key,
|
||||||
encoder.encode(message)
|
encoder.encode(message)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(new Uint8Array(signature))
|
return Array.from(new Uint8Array(signature))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,139 +160,163 @@ async function generateProxySignature(url: string, timestamp: number): Promise<s
|
|||||||
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
* the fetch fails. This wrapper routes such requests through our CORS proxy.
|
||||||
*
|
*
|
||||||
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
|
let fetchWrapRefCount = 0;
|
||||||
|
let savedOriginalFetch: typeof fetch | null = null;
|
||||||
|
|
||||||
function createCorsAwareFetch(): {
|
function createCorsAwareFetch(): {
|
||||||
wrappedFetch: typeof fetch;
|
wrappedFetch: typeof fetch;
|
||||||
restore: () => void;
|
restore: () => void;
|
||||||
} {
|
} {
|
||||||
const originalFetch = window.fetch.bind(window);
|
if (fetchWrapRefCount === 0) {
|
||||||
|
savedOriginalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
const wrappedFetch: typeof fetch = async (input, init) => {
|
const originalFetch = savedOriginalFetch;
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
|
|
||||||
const isExternalCertificateUrl = (
|
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
url.includes('.crt') ||
|
const url =
|
||||||
url.includes('.cer') ||
|
typeof input === 'string'
|
||||||
url.includes('.pem') ||
|
? input
|
||||||
url.includes('/certs/') ||
|
: input instanceof URL
|
||||||
url.includes('/ocsp') ||
|
? input.toString()
|
||||||
url.includes('/crl') ||
|
: input.url;
|
||||||
url.includes('caIssuers')
|
|
||||||
) && !url.startsWith(window.location.origin);
|
|
||||||
|
|
||||||
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
const isExternalCertificateUrl =
|
||||||
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
(url.includes('.crt') ||
|
||||||
|
url.includes('.cer') ||
|
||||||
|
url.includes('.pem') ||
|
||||||
|
url.includes('/certs/') ||
|
||||||
|
url.includes('/ocsp') ||
|
||||||
|
url.includes('/crl') ||
|
||||||
|
url.includes('caIssuers')) &&
|
||||||
|
!url.startsWith(window.location.origin);
|
||||||
|
|
||||||
if (CORS_PROXY_SECRET) {
|
if (isExternalCertificateUrl && CORS_PROXY_URL) {
|
||||||
const timestamp = Date.now();
|
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
|
||||||
const signature = await generateProxySignature(url, timestamp);
|
|
||||||
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
|
||||||
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalFetch(proxyUrl, init);
|
if (CORS_PROXY_SECRET) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const signature = await generateProxySignature(url, timestamp);
|
||||||
|
proxyUrl += `&t=${timestamp}&sig=${signature}`;
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[CORS Proxy] Routing certificate request through proxy: ${url}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return originalFetch(input, init);
|
return originalFetch(proxyUrl, init);
|
||||||
};
|
}
|
||||||
|
|
||||||
window.fetch = wrappedFetch;
|
return originalFetch(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
fetchWrapRefCount++;
|
||||||
wrappedFetch,
|
|
||||||
restore: () => {
|
return {
|
||||||
window.fetch = originalFetch;
|
wrappedFetch: window.fetch,
|
||||||
}
|
restore: () => {
|
||||||
};
|
fetchWrapRefCount--;
|
||||||
|
if (fetchWrapRefCount === 0 && savedOriginalFetch) {
|
||||||
|
window.fetch = savedOriginalFetch;
|
||||||
|
savedOriginalFetch = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function signPdf(
|
export async function signPdf(
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
certificateData: CertificateData,
|
certificateData: CertificateData,
|
||||||
options: SignPdfOptions = {}
|
options: SignPdfOptions = {}
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const signatureInfo = options.signatureInfo ?? {};
|
const signatureInfo = options.signatureInfo ?? {};
|
||||||
|
|
||||||
const signOptions: SignOption = {
|
const signOptions: SignOption = {
|
||||||
p12cert: certificateData.p12Buffer,
|
p12cert: certificateData.p12Buffer,
|
||||||
pwd: certificateData.password,
|
pwd: certificateData.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signatureInfo.reason) {
|
||||||
|
signOptions.reason = signatureInfo.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.location) {
|
||||||
|
signOptions.location = signatureInfo.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureInfo.contactInfo) {
|
||||||
|
signOptions.contact = signatureInfo.contactInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.visibleSignature?.enabled) {
|
||||||
|
const vs = options.visibleSignature;
|
||||||
|
|
||||||
|
const drawinf = {
|
||||||
|
area: {
|
||||||
|
x: vs.x,
|
||||||
|
y: vs.y,
|
||||||
|
w: vs.width,
|
||||||
|
h: vs.height,
|
||||||
|
},
|
||||||
|
pageidx: vs.page,
|
||||||
|
imgInfo: undefined as
|
||||||
|
| { imgData: ArrayBuffer; imgType: string }
|
||||||
|
| undefined,
|
||||||
|
textInfo: undefined as
|
||||||
|
| { text: string; size: number; color: string }
|
||||||
|
| undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signatureInfo.reason) {
|
if (vs.imageData && vs.imageType) {
|
||||||
signOptions.reason = signatureInfo.reason;
|
drawinf.imgInfo = {
|
||||||
|
imgData: vs.imageData,
|
||||||
|
imgType: vs.imageType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.location) {
|
if (vs.text) {
|
||||||
signOptions.location = signatureInfo.location;
|
drawinf.textInfo = {
|
||||||
|
text: vs.text,
|
||||||
|
size: vs.textSize ?? 12,
|
||||||
|
color: vs.textColor ?? '#000000',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signatureInfo.contactInfo) {
|
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
||||||
signOptions.contact = signatureInfo.contactInfo;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (options.visibleSignature?.enabled) {
|
const signer = new PdfSigner(signOptions);
|
||||||
const vs = options.visibleSignature;
|
|
||||||
|
|
||||||
const drawinf = {
|
const { restore } = createCorsAwareFetch();
|
||||||
area: {
|
|
||||||
x: vs.x,
|
|
||||||
y: vs.y,
|
|
||||||
w: vs.width,
|
|
||||||
h: vs.height,
|
|
||||||
},
|
|
||||||
pageidx: vs.page,
|
|
||||||
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
|
|
||||||
textInfo: undefined as { text: string; size: number; color: string } | undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (vs.imageData && vs.imageType) {
|
try {
|
||||||
drawinf.imgInfo = {
|
const signedPdfBytes = await signer.sign(pdfBytes);
|
||||||
imgData: vs.imageData,
|
return new Uint8Array(signedPdfBytes);
|
||||||
imgType: vs.imageType,
|
} finally {
|
||||||
};
|
restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vs.text) {
|
|
||||||
drawinf.textInfo = {
|
|
||||||
text: vs.text,
|
|
||||||
size: vs.textSize ?? 12,
|
|
||||||
color: vs.textColor ?? '#000000',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
signOptions.drawinf = drawinf as SignOption['drawinf'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const signer = new PdfSigner(signOptions);
|
|
||||||
|
|
||||||
const { restore } = createCorsAwareFetch();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signedPdfBytes = await signer.sign(pdfBytes);
|
|
||||||
return new Uint8Array(signedPdfBytes);
|
|
||||||
} finally {
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
export function getCertificateInfo(certificate: forge.pki.Certificate): {
|
||||||
subject: string;
|
subject: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
validFrom: Date;
|
validFrom: Date;
|
||||||
validTo: Date;
|
validTo: Date;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
} {
|
} {
|
||||||
const subjectCN = certificate.subject.getField('CN');
|
const subjectCN = certificate.subject.getField('CN');
|
||||||
const issuerCN = certificate.issuer.getField('CN');
|
const issuerCN = certificate.issuer.getField('CN');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: subjectCN?.value as string ?? 'Unknown',
|
subject: (subjectCN?.value as string) ?? 'Unknown',
|
||||||
issuer: issuerCN?.value as string ?? 'Unknown',
|
issuer: (issuerCN?.value as string) ?? 'Unknown',
|
||||||
validFrom: certificate.validity.notBefore,
|
validFrom: certificate.validity.notBefore,
|
||||||
validTo: certificate.validity.notAfter,
|
validTo: certificate.validity.notAfter,
|
||||||
serialNumber: certificate.serialNumber,
|
serialNumber: certificate.serialNumber,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -931,7 +931,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
|
||||||
<input type="color" id="propTextColor" value="${field.textColor}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
<input type="color" id="propTextColor" value="${field.textColor}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
|
||||||
@@ -1169,7 +1169,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
||||||
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
||||||
|
|||||||
+396
-130
@@ -1,10 +1,15 @@
|
|||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib';
|
import { degrees, PDFDocument as PDFLibDocument, PDFPage } from 'pdf-lib';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { downloadFile, getPDFDocument } from '../utils/helpers';
|
import { downloadFile, getPDFDocument } from '../utils/helpers';
|
||||||
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils';
|
import {
|
||||||
|
renderPagesProgressively,
|
||||||
|
cleanupLazyRendering,
|
||||||
|
renderPageToCanvas,
|
||||||
|
createPlaceholder,
|
||||||
|
} from '../utils/render-utils';
|
||||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||||
import { repairPdfFile } from './repair-pdf.js';
|
import { repairPdfFile } from './repair-pdf.js';
|
||||||
|
|
||||||
@@ -41,13 +46,17 @@ let sortableInstance: Sortable | null = null;
|
|||||||
|
|
||||||
const pageCanvasCache = new Map<string, HTMLCanvasElement>();
|
const pageCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] };
|
type Snapshot = {
|
||||||
|
allPages: PageData[];
|
||||||
|
selectedPages: number[];
|
||||||
|
splitMarkers: number[];
|
||||||
|
};
|
||||||
const undoStack: Snapshot[] = [];
|
const undoStack: Snapshot[] = [];
|
||||||
const redoStack: Snapshot[] = [];
|
const redoStack: Snapshot[] = [];
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const snap: Snapshot = {
|
const snap: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p, canvas: p.canvas })),
|
allPages: allPages.map((p) => ({ ...p, canvas: p.canvas })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -56,16 +65,20 @@ function snapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restore(snap: Snapshot) {
|
function restore(snap: Snapshot) {
|
||||||
allPages = snap.allPages.map(p => ({
|
allPages = snap.allPages.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
canvas: p.canvas
|
canvas: p.canvas,
|
||||||
}));
|
}));
|
||||||
selectedPages = new Set(snap.selectedPages);
|
selectedPages = new Set(snap.selectedPages);
|
||||||
splitMarkers = new Set(snap.splitMarkers);
|
splitMarkers = new Set(snap.splitMarkers);
|
||||||
updatePageDisplay();
|
updatePageDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') {
|
function showModal(
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
type: 'info' | 'error' | 'success' = 'info'
|
||||||
|
) {
|
||||||
const modal = document.getElementById('modal');
|
const modal = document.getElementById('modal');
|
||||||
const modalTitle = document.getElementById('modal-title');
|
const modalTitle = document.getElementById('modal-title');
|
||||||
const modalMessage = document.getElementById('modal-message');
|
const modalMessage = document.getElementById('modal-message');
|
||||||
@@ -79,12 +92,12 @@ function showModal(title: string, message: string, type: 'info' | 'error' | 'suc
|
|||||||
const iconMap = {
|
const iconMap = {
|
||||||
info: 'info',
|
info: 'info',
|
||||||
error: 'alert-circle',
|
error: 'alert-circle',
|
||||||
success: 'check-circle'
|
success: 'check-circle',
|
||||||
};
|
};
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
info: 'text-blue-400',
|
info: 'text-blue-400',
|
||||||
error: 'text-red-400',
|
error: 'text-red-400',
|
||||||
success: 'text-green-400'
|
success: 'text-green-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
||||||
@@ -112,7 +125,10 @@ function showLoading(current: number, total: number) {
|
|||||||
text.textContent = t('multiTool.renderingPages');
|
text.textContent = t('multiTool.renderingPages');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withButtonLoading(buttonId: string, action: () => Promise<void>) {
|
async function withButtonLoading(
|
||||||
|
buttonId: string,
|
||||||
|
action: () => Promise<void>
|
||||||
|
) {
|
||||||
const button = document.getElementById(buttonId) as HTMLButtonElement;
|
const button = document.getElementById(buttonId) as HTMLButtonElement;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
@@ -122,7 +138,8 @@ async function withButtonLoading(buttonId: string, action: () => Promise<void>)
|
|||||||
try {
|
try {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.style.pointerEvents = 'none';
|
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>';
|
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>';
|
||||||
|
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,7 +160,9 @@ if (document.readyState === 'loading') {
|
|||||||
initializeTool();
|
initializeTool();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('PDF Multi Tool: DOMContentLoaded already fired, initializing immediately');
|
console.log(
|
||||||
|
'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'
|
||||||
|
);
|
||||||
initializeTool();
|
initializeTool();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +179,30 @@ function initializeTool() {
|
|||||||
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
||||||
console.log('Upload button clicked, isRendering:', isRendering);
|
console.log('Upload button clicked, isRendering:', isRendering);
|
||||||
if (isRendering) {
|
if (isRendering) {
|
||||||
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info');
|
showModal(
|
||||||
|
t('multiTool.pleaseWait'),
|
||||||
|
t('multiTool.pagesRendering'),
|
||||||
|
'info'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('pdf-file-input')?.click();
|
document.getElementById('pdf-file-input')?.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload);
|
document
|
||||||
document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf);
|
.getElementById('pdf-file-input')
|
||||||
|
?.addEventListener('change', handlePdfUpload);
|
||||||
|
document
|
||||||
|
.getElementById('insert-pdf-input')
|
||||||
|
?.addEventListener('change', handleInsertPdf);
|
||||||
|
|
||||||
document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-rotate-left-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
bulkRotate(-90);
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
bulkRotate(-90);
|
||||||
|
});
|
||||||
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
|
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
snapshot();
|
snapshot();
|
||||||
@@ -184,27 +213,38 @@ function initializeTool() {
|
|||||||
snapshot();
|
snapshot();
|
||||||
bulkDelete();
|
bulkDelete();
|
||||||
});
|
});
|
||||||
document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-duplicate-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
bulkDuplicate();
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
bulkDuplicate();
|
||||||
|
});
|
||||||
document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
|
document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
snapshot();
|
snapshot();
|
||||||
bulkSplit();
|
bulkSplit();
|
||||||
});
|
});
|
||||||
document.getElementById('bulk-download-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-download-btn')
|
||||||
if (isRendering) return;
|
?.addEventListener('click', () => {
|
||||||
if (selectedPages.size === 0) {
|
if (isRendering) return;
|
||||||
showModal(t('multiTool.noPagesSelected'), t('multiTool.selectOnePage'), 'info');
|
if (isRendering) return;
|
||||||
return;
|
if (selectedPages.size === 0) {
|
||||||
}
|
showModal(
|
||||||
withButtonLoading('bulk-download-btn', async () => {
|
t('multiTool.noPagesSelected'),
|
||||||
await downloadPagesAsPdf(Array.from(selectedPages).sort((a, b) => a - b), 'selected-pages.pdf');
|
t('multiTool.selectOnePage'),
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
withButtonLoading('bulk-download-btn', async () => {
|
||||||
|
await downloadPagesAsPdf(
|
||||||
|
Array.from(selectedPages).sort((a, b) => a - b),
|
||||||
|
'selected-pages.pdf'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('select-all-btn')?.addEventListener('click', () => {
|
document.getElementById('select-all-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
@@ -229,17 +269,19 @@ function initializeTool() {
|
|||||||
await downloadAll();
|
await downloadAll();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('add-blank-page-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('add-blank-page-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
addBlankPage();
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
addBlankPage();
|
||||||
|
});
|
||||||
document.getElementById('undo-btn')?.addEventListener('click', () => {
|
document.getElementById('undo-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
const last = undoStack.pop();
|
const last = undoStack.pop();
|
||||||
if (last) {
|
if (last) {
|
||||||
const current: Snapshot = {
|
const current: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p })),
|
allPages: allPages.map((p) => ({ ...p })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -252,7 +294,7 @@ function initializeTool() {
|
|||||||
const next = redoStack.pop();
|
const next = redoStack.pop();
|
||||||
if (next) {
|
if (next) {
|
||||||
const current: Snapshot = {
|
const current: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p })),
|
allPages: allPages.map((p) => ({ ...p })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -269,7 +311,9 @@ function initializeTool() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('modal-close-btn')?.addEventListener('click', hideModal);
|
document
|
||||||
|
.getElementById('modal-close-btn')
|
||||||
|
?.addEventListener('click', hideModal);
|
||||||
document.getElementById('modal')?.addEventListener('click', (e) => {
|
document.getElementById('modal')?.addEventListener('click', (e) => {
|
||||||
if (e.target === document.getElementById('modal')) {
|
if (e.target === document.getElementById('modal')) {
|
||||||
hideModal();
|
hideModal();
|
||||||
@@ -288,7 +332,9 @@ function initializeTool() {
|
|||||||
uploadArea.addEventListener('drop', (e) => {
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
uploadArea.classList.remove('border-indigo-500');
|
uploadArea.classList.remove('border-indigo-500');
|
||||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf');
|
const files = Array.from(e.dataTransfer?.files || []).filter(
|
||||||
|
(f) => f.type === 'application/pdf'
|
||||||
|
);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
loadPdfs(files);
|
loadPdfs(files);
|
||||||
}
|
}
|
||||||
@@ -361,27 +407,38 @@ async function loadPdfs(files: File[]) {
|
|||||||
try {
|
try {
|
||||||
console.log(`Repairing ${file.name}...`);
|
console.log(`Repairing ${file.name}...`);
|
||||||
const loadingText = document.getElementById('loading-text');
|
const loadingText = document.getElementById('loading-text');
|
||||||
if (loadingText) loadingText.textContent = `Repairing ${file.name}...`;
|
if (loadingText)
|
||||||
|
loadingText.textContent = `Repairing ${file.name}...`;
|
||||||
|
|
||||||
const repairedData = await repairPdfFile(file);
|
const repairedData = await repairPdfFile(file);
|
||||||
if (repairedData) {
|
if (repairedData) {
|
||||||
arrayBuffer = repairedData.buffer as ArrayBuffer;
|
arrayBuffer = repairedData.buffer as ArrayBuffer;
|
||||||
console.log(`Successfully repaired ${file.name} before loading.`);
|
console.log(`Successfully repaired ${file.name} before loading.`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Repair returned null for ${file.name}, using original file.`);
|
console.warn(
|
||||||
|
`Repair returned null for ${file.name}, using original file.`
|
||||||
|
);
|
||||||
arrayBuffer = await file.arrayBuffer();
|
arrayBuffer = await file.arrayBuffer();
|
||||||
}
|
}
|
||||||
} catch (repairError) {
|
} catch (repairError) {
|
||||||
console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError);
|
console.warn(
|
||||||
|
`Failed to repair ${file.name}, attempting to load original:`,
|
||||||
|
repairError
|
||||||
|
);
|
||||||
arrayBuffer = await file.arrayBuffer();
|
arrayBuffer = await file.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
currentPdfDocs.push(pdfDoc);
|
currentPdfDocs.push(pdfDoc);
|
||||||
const pdfIndex = currentPdfDocs.length - 1;
|
const pdfIndex = currentPdfDocs.length - 1;
|
||||||
|
|
||||||
const pdfBytes = await pdfDoc.save();
|
const pdfBytes = await pdfDoc.save();
|
||||||
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
|
const pdfjsDoc = await getPDFDocument({
|
||||||
|
data: new Uint8Array(pdfBytes),
|
||||||
|
}).promise;
|
||||||
const numPages = pdfjsDoc.numPages;
|
const numPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
// Pre-fill allPages with placeholders to maintain order/state
|
// Pre-fill allPages with placeholders to maintain order/state
|
||||||
@@ -425,10 +482,13 @@ async function loadPdfs(files: File[]) {
|
|||||||
shouldCancel: () => renderCancelled, // Pass cancellation check
|
shouldCancel: () => renderCancelled, // Pass cancellation check
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to load PDF ${file.name}:`, e);
|
console.error(`Failed to load PDF ${file.name}:`, e);
|
||||||
showModal(t('multiTool.error'), `${t('multiTool.failedToLoad')} ${file.name}.`, 'error');
|
showModal(
|
||||||
|
t('multiTool.error'),
|
||||||
|
`${t('multiTool.failedToLoad')} ${file.name}.`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +499,9 @@ async function loadPdfs(files: File[]) {
|
|||||||
} finally {
|
} finally {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
isRendering = false;
|
isRendering = false;
|
||||||
console.log('PDF Multi Tool: Render finished/cancelled, isRendering set to false');
|
console.log(
|
||||||
|
'PDF Multi Tool: Render finished/cancelled, isRendering set to false'
|
||||||
|
);
|
||||||
if (renderCancelled) {
|
if (renderCancelled) {
|
||||||
renderCancelled = false;
|
renderCancelled = false;
|
||||||
}
|
}
|
||||||
@@ -464,7 +526,10 @@ function createPageCard(pageData: PageData, index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modified to return the element instead of appending it
|
// Modified to return the element instead of appending it
|
||||||
function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTMLElement {
|
function createPageElement(
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
index: number
|
||||||
|
): HTMLElement {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
console.error(`Page data not found for index ${index}`);
|
console.error(`Page data not found for index ${index}`);
|
||||||
@@ -472,7 +537,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
|
card.className =
|
||||||
|
'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
|
||||||
card.dataset.pageIndex = index.toString();
|
card.dataset.pageIndex = index.toString();
|
||||||
card.dataset.pageId = pageData.id; // Set ID for reconciliation
|
card.dataset.pageId = pageData.id; // Set ID for reconciliation
|
||||||
|
|
||||||
@@ -490,7 +556,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preview = document.createElement('div');
|
const preview = document.createElement('div');
|
||||||
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
preview.className =
|
||||||
|
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
||||||
|
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const previewCanvas = canvas;
|
const previewCanvas = canvas;
|
||||||
@@ -502,7 +569,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
} else {
|
} else {
|
||||||
// Show loading placeholder if canvas is null
|
// Show loading placeholder if canvas is null
|
||||||
const loading = document.createElement('div');
|
const loading = document.createElement('div');
|
||||||
loading.className = 'flex flex-col items-center justify-center text-gray-400';
|
loading.className =
|
||||||
|
'flex flex-col items-center justify-center text-gray-400';
|
||||||
loading.innerHTML = `
|
loading.innerHTML = `
|
||||||
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
|
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
|
||||||
<span class="text-xs">${t('common.loading')}</span>
|
<span class="text-xs">${t('common.loading')}</span>
|
||||||
@@ -518,15 +586,18 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
|
|
||||||
// Actions toolbar
|
// Actions toolbar
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
|
actions.className =
|
||||||
|
'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
|
||||||
|
|
||||||
const actionsInner = document.createElement('div');
|
const actionsInner = document.createElement('div');
|
||||||
actionsInner.className = 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
|
actionsInner.className =
|
||||||
|
'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
|
||||||
actions.appendChild(actionsInner);
|
actions.appendChild(actionsInner);
|
||||||
|
|
||||||
// Select checkbox
|
// Select checkbox
|
||||||
const selectBtn = document.createElement('button');
|
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.className =
|
||||||
|
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
|
||||||
selectBtn.innerHTML = selectedPages.has(index)
|
selectBtn.innerHTML = selectedPages.has(index)
|
||||||
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
|
? '<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>';
|
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
@@ -538,14 +609,16 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Rotate button
|
// Rotate button
|
||||||
const rotateBtn = document.createElement('button');
|
const rotateBtn = document.createElement('button');
|
||||||
rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
|
rotateBtn.innerHTML =
|
||||||
|
'<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
|
||||||
rotateBtn.onclick = (e) => {
|
rotateBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
rotatePage(index, 90);
|
rotatePage(index, 90);
|
||||||
};
|
};
|
||||||
const rotateLeftBtn = document.createElement('button');
|
const rotateLeftBtn = document.createElement('button');
|
||||||
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
|
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
|
rotateLeftBtn.innerHTML =
|
||||||
|
'<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
|
||||||
rotateLeftBtn.onclick = (e) => {
|
rotateLeftBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
rotatePage(index, -90);
|
rotatePage(index, -90);
|
||||||
@@ -554,7 +627,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Duplicate button
|
// Duplicate button
|
||||||
const duplicateBtn = document.createElement('button');
|
const duplicateBtn = document.createElement('button');
|
||||||
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
duplicateBtn.innerHTML = '<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
|
duplicateBtn.innerHTML =
|
||||||
|
'<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
|
||||||
duplicateBtn.title = t('multiTool.actions.duplicatePage');
|
duplicateBtn.title = t('multiTool.actions.duplicatePage');
|
||||||
duplicateBtn.onclick = (e) => {
|
duplicateBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -565,7 +639,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Delete button
|
// Delete button
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
|
deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
|
deleteBtn.innerHTML =
|
||||||
|
'<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
|
||||||
deleteBtn.title = t('multiTool.actions.deletePage');
|
deleteBtn.title = t('multiTool.actions.deletePage');
|
||||||
deleteBtn.onclick = (e) => {
|
deleteBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -576,7 +651,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Insert PDF button
|
// Insert PDF button
|
||||||
const insertBtn = document.createElement('button');
|
const insertBtn = document.createElement('button');
|
||||||
insertBtn.className = 'p-1 rounded hover:bg-gray-700';
|
insertBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
insertBtn.innerHTML = '<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
|
insertBtn.innerHTML =
|
||||||
|
'<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
|
||||||
insertBtn.title = t('multiTool.actions.insertPdf');
|
insertBtn.title = t('multiTool.actions.insertPdf');
|
||||||
insertBtn.onclick = (e) => {
|
insertBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -587,7 +663,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Split button
|
// Split button
|
||||||
const splitBtn = document.createElement('button');
|
const splitBtn = document.createElement('button');
|
||||||
splitBtn.className = 'p-1 rounded hover:bg-gray-700';
|
splitBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
splitBtn.innerHTML = '<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
|
splitBtn.innerHTML =
|
||||||
|
'<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
|
||||||
splitBtn.title = t('multiTool.actions.toggleSplit');
|
splitBtn.title = t('multiTool.actions.toggleSplit');
|
||||||
splitBtn.onclick = (e) => {
|
splitBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -596,14 +673,23 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
renderSplitMarkers();
|
renderSplitMarkers();
|
||||||
};
|
};
|
||||||
|
|
||||||
actionsInner.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn);
|
actionsInner.append(
|
||||||
|
rotateLeftBtn,
|
||||||
|
rotateBtn,
|
||||||
|
duplicateBtn,
|
||||||
|
insertBtn,
|
||||||
|
splitBtn,
|
||||||
|
deleteBtn
|
||||||
|
);
|
||||||
card.append(preview, info, actions, selectBtn);
|
card.append(preview, info, actions, selectBtn);
|
||||||
|
|
||||||
// Check for split marker
|
// Check for split marker
|
||||||
if (splitMarkers.has(index)) {
|
if (splitMarkers.has(index)) {
|
||||||
const marker = document.createElement('div');
|
const marker = document.createElement('div');
|
||||||
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
marker.className =
|
||||||
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
||||||
|
marker.innerHTML =
|
||||||
|
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
||||||
card.appendChild(marker);
|
card.appendChild(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,15 +741,19 @@ function toggleSelectOptimized(index: number) {
|
|||||||
const card = pagesContainer.children[index] as HTMLElement;
|
const card = pagesContainer.children[index] as HTMLElement;
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]');
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
);
|
||||||
if (!selectBtn) return;
|
if (!selectBtn) return;
|
||||||
|
|
||||||
if (selectedPages.has(index)) {
|
if (selectedPages.has(index)) {
|
||||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
@@ -730,7 +820,7 @@ function deletePage(index: number) {
|
|||||||
allPages.splice(index, 1);
|
allPages.splice(index, 1);
|
||||||
selectedPages.delete(index);
|
selectedPages.delete(index);
|
||||||
const newSelected = new Set<number>();
|
const newSelected = new Set<number>();
|
||||||
selectedPages.forEach(i => {
|
selectedPages.forEach((i) => {
|
||||||
if (i > index) newSelected.add(i - 1);
|
if (i > index) newSelected.add(i - 1);
|
||||||
else if (i < index) newSelected.add(i);
|
else if (i < index) newSelected.add(i);
|
||||||
});
|
});
|
||||||
@@ -759,13 +849,17 @@ async function handleInsertPdf(e: Event) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
currentPdfDocs.push(pdfDoc);
|
currentPdfDocs.push(pdfDoc);
|
||||||
const pdfIndex = currentPdfDocs.length - 1;
|
const pdfIndex = currentPdfDocs.length - 1;
|
||||||
|
|
||||||
// Load PDF.js document for rendering
|
// Load PDF.js document for rendering
|
||||||
const pdfBytes = await pdfDoc.save();
|
const pdfBytes = await pdfDoc.save();
|
||||||
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
|
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) })
|
||||||
|
.promise;
|
||||||
const numPages = pdfjsDoc.numPages;
|
const numPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
const newPages: PageData[] = [];
|
const newPages: PageData[] = [];
|
||||||
@@ -802,13 +896,18 @@ async function handleInsertPdf(e: Event) {
|
|||||||
|
|
||||||
// Update UI if card exists
|
// Update UI if card exists
|
||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
const card = pagesContainer?.querySelector(`div[data-page-index="${globalIndex}"]`);
|
const card = pagesContainer?.querySelector(
|
||||||
|
`div[data-page-index="${globalIndex}"]`
|
||||||
|
);
|
||||||
if (card) {
|
if (card) {
|
||||||
const preview = card.querySelector('.bg-gray-700') || card.querySelector('.bg-white');
|
const preview =
|
||||||
|
card.querySelector('.bg-gray-700') ||
|
||||||
|
card.querySelector('.bg-white');
|
||||||
if (preview) {
|
if (preview) {
|
||||||
// Re-create the preview content
|
// Re-create the preview content
|
||||||
preview.innerHTML = '';
|
preview.innerHTML = '';
|
||||||
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
preview.className =
|
||||||
|
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
||||||
|
|
||||||
const previewCanvas = canvas;
|
const previewCanvas = canvas;
|
||||||
previewCanvas.className = 'max-w-full max-h-full object-contain';
|
previewCanvas.className = 'max-w-full max-h-full object-contain';
|
||||||
@@ -819,10 +918,13 @@ async function handleInsertPdf(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to insert PDF:', e);
|
console.error('Failed to insert PDF:', e);
|
||||||
showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error');
|
showModal(
|
||||||
|
'Error',
|
||||||
|
'Failed to insert PDF. The file may be corrupted.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -837,13 +939,15 @@ function renderSplitMarkers() {
|
|||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
if (!pagesContainer) return;
|
if (!pagesContainer) return;
|
||||||
|
|
||||||
pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove());
|
pagesContainer.querySelectorAll('.split-marker').forEach((m) => m.remove());
|
||||||
|
|
||||||
Array.from(pagesContainer.children).forEach((cardEl, i) => {
|
Array.from(pagesContainer.children).forEach((cardEl, i) => {
|
||||||
if (splitMarkers.has(i)) {
|
if (splitMarkers.has(i)) {
|
||||||
const marker = document.createElement('div');
|
const marker = document.createElement('div');
|
||||||
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
marker.className =
|
||||||
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
||||||
|
marker.innerHTML =
|
||||||
|
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
||||||
(cardEl as HTMLElement).appendChild(marker);
|
(cardEl as HTMLElement).appendChild(marker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -881,7 +985,7 @@ function bulkRotate(delta: number) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedPages.forEach(index => {
|
selectedPages.forEach((index) => {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
// Update state
|
// Update state
|
||||||
@@ -890,7 +994,9 @@ function bulkRotate(delta: number) {
|
|||||||
|
|
||||||
// Update DOM immediately if it exists
|
// Update DOM immediately if it exists
|
||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
const card = pagesContainer?.querySelector(`div[data-page-index="${index}"]`);
|
const card = pagesContainer?.querySelector(
|
||||||
|
`div[data-page-index="${index}"]`
|
||||||
|
);
|
||||||
if (card) {
|
if (card) {
|
||||||
const canvas = card.querySelector('canvas');
|
const canvas = card.querySelector('canvas');
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
@@ -911,7 +1017,7 @@ function bulkDelete() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||||
indices.forEach(index => allPages.splice(index, 1));
|
indices.forEach((index) => allPages.splice(index, 1));
|
||||||
selectedPages.clear();
|
selectedPages.clear();
|
||||||
|
|
||||||
if (allPages.length === 0) {
|
if (allPages.length === 0) {
|
||||||
@@ -928,7 +1034,7 @@ function bulkDuplicate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||||
indices.forEach(index => {
|
indices.forEach((index) => {
|
||||||
duplicatePage(index);
|
duplicatePage(index);
|
||||||
});
|
});
|
||||||
selectedPages.clear();
|
selectedPages.clear();
|
||||||
@@ -937,11 +1043,15 @@ function bulkDuplicate() {
|
|||||||
|
|
||||||
function bulkSplit() {
|
function bulkSplit() {
|
||||||
if (selectedPages.size === 0) {
|
if (selectedPages.size === 0) {
|
||||||
showModal('No Selection', 'Please select pages to mark for splitting.', 'info');
|
showModal(
|
||||||
|
'No Selection',
|
||||||
|
'Please select pages to mark for splitting.',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages);
|
const indices = Array.from(selectedPages);
|
||||||
indices.forEach(index => {
|
indices.forEach((index) => {
|
||||||
if (!splitMarkers.has(index)) {
|
if (!splitMarkers.has(index)) {
|
||||||
splitMarkers.add(index);
|
splitMarkers.add(index);
|
||||||
}
|
}
|
||||||
@@ -951,7 +1061,6 @@ function bulkSplit() {
|
|||||||
updatePageDisplay();
|
updatePageDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function downloadAll() {
|
async function downloadAll() {
|
||||||
if (allPages.length === 0) {
|
if (allPages.length === 0) {
|
||||||
showModal('No Pages', 'Please upload PDFs first.', 'info');
|
showModal('No Pages', 'Please upload PDFs first.', 'info');
|
||||||
@@ -993,24 +1102,63 @@ async function downloadSplitPdfs() {
|
|||||||
segments.push(currentSegment);
|
segments.push(currentSegment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create PDFs for each segment
|
|
||||||
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
||||||
const segment = segments[segIndex];
|
const segment = segments[segIndex];
|
||||||
const newPdf = await PDFLibDocument.create();
|
const newPdf = await PDFLibDocument.create();
|
||||||
|
|
||||||
|
const segSpecs: (
|
||||||
|
| {
|
||||||
|
type: 'pdf';
|
||||||
|
pdfDoc: PDFLibDocument;
|
||||||
|
originalPageIndex: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
| { type: 'blank' }
|
||||||
|
)[] = [];
|
||||||
for (const index of segment) {
|
for (const index of segment) {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) continue;
|
||||||
console.warn(`Page data missing for index ${index}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
||||||
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]);
|
segSpecs.push({
|
||||||
const page = newPdf.addPage(copiedPage);
|
type: 'pdf',
|
||||||
|
pdfDoc: pageData.pdfDoc,
|
||||||
|
originalPageIndex: pageData.originalPageIndex,
|
||||||
|
rotation: pageData.rotation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
segSpecs.push({ type: 'blank' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageData.rotation !== 0) {
|
const docPageIndices = new Map<PDFLibDocument, number[]>();
|
||||||
|
for (const spec of segSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
if (!docPageIndices.has(spec.pdfDoc)) {
|
||||||
|
docPageIndices.set(spec.pdfDoc, []);
|
||||||
|
}
|
||||||
|
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
|
||||||
|
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
|
||||||
|
const copied = await newPdf.copyPages(doc, pageIdxs);
|
||||||
|
copiedPagesMap.set(doc, copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docConsumeIndex = new Map<PDFLibDocument, number>();
|
||||||
|
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
|
||||||
|
|
||||||
|
for (const spec of segSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
const idx = docConsumeIndex.get(spec.pdfDoc)!;
|
||||||
|
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
|
||||||
|
docConsumeIndex.set(spec.pdfDoc, idx + 1);
|
||||||
|
|
||||||
|
const page = newPdf.addPage(copiedPage);
|
||||||
|
if (spec.rotation !== 0) {
|
||||||
const currentRotation = page.getRotation().angle;
|
const currentRotation = page.getRotation().angle;
|
||||||
page.setRotation(degrees(currentRotation + pageData.rotation));
|
page.setRotation(degrees(currentRotation + spec.rotation));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPdf.addPage([595, 842]);
|
newPdf.addPage([595, 842]);
|
||||||
@@ -1025,7 +1173,11 @@ async function downloadSplitPdfs() {
|
|||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
downloadFile(zipBlob, 'split-documents.zip');
|
downloadFile(zipBlob, 'split-documents.zip');
|
||||||
|
|
||||||
showModal('Success', `Downloaded ${segments.length} PDF files in a ZIP archive.`, 'success');
|
showModal(
|
||||||
|
'Success',
|
||||||
|
`Downloaded ${segments.length} PDF files in a ZIP archive.`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create split PDFs:', e);
|
console.error('Failed to create split PDFs:', e);
|
||||||
showModal('Error', 'Failed to create split PDFs.', 'error');
|
showModal('Error', 'Failed to create split PDFs.', 'error');
|
||||||
@@ -1038,20 +1190,59 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
|
|||||||
try {
|
try {
|
||||||
const newPdf = await PDFLibDocument.create();
|
const newPdf = await PDFLibDocument.create();
|
||||||
|
|
||||||
|
const pageSpecs: (
|
||||||
|
| {
|
||||||
|
type: 'pdf';
|
||||||
|
pdfDoc: PDFLibDocument;
|
||||||
|
originalPageIndex: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
| { type: 'blank' }
|
||||||
|
)[] = [];
|
||||||
for (const index of indices) {
|
for (const index of indices) {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) continue;
|
||||||
console.warn(`Page data missing for index ${index}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
||||||
// Copy page from original PDF
|
pageSpecs.push({
|
||||||
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]);
|
type: 'pdf',
|
||||||
const page = newPdf.addPage(copiedPage);
|
pdfDoc: pageData.pdfDoc,
|
||||||
|
originalPageIndex: pageData.originalPageIndex,
|
||||||
|
rotation: pageData.rotation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pageSpecs.push({ type: 'blank' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageData.rotation !== 0) {
|
const docPageIndices = new Map<PDFLibDocument, number[]>();
|
||||||
|
for (const spec of pageSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
if (!docPageIndices.has(spec.pdfDoc)) {
|
||||||
|
docPageIndices.set(spec.pdfDoc, []);
|
||||||
|
}
|
||||||
|
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
|
||||||
|
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
|
||||||
|
const copied = await newPdf.copyPages(doc, pageIdxs);
|
||||||
|
copiedPagesMap.set(doc, copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docConsumeIndex = new Map<PDFLibDocument, number>();
|
||||||
|
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
|
||||||
|
|
||||||
|
for (const spec of pageSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
const idx = docConsumeIndex.get(spec.pdfDoc)!;
|
||||||
|
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
|
||||||
|
docConsumeIndex.set(spec.pdfDoc, idx + 1);
|
||||||
|
|
||||||
|
const page = newPdf.addPage(copiedPage);
|
||||||
|
if (spec.rotation !== 0) {
|
||||||
const currentRotation = page.getRotation().angle;
|
const currentRotation = page.getRotation().angle;
|
||||||
page.setRotation(degrees(currentRotation + pageData.rotation));
|
page.setRotation(degrees(currentRotation + spec.rotation));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPdf.addPage([595, 842]);
|
newPdf.addPage([595, 842]);
|
||||||
@@ -1059,7 +1250,9 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
const pdfBytes = await newPdf.save();
|
||||||
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
|
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
downloadFile(blob, filename);
|
downloadFile(blob, filename);
|
||||||
showModal('Success', 'PDF downloaded successfully.', 'success');
|
showModal('Success', 'PDF downloaded successfully.', 'success');
|
||||||
@@ -1101,18 +1294,28 @@ function updatePageDisplay() {
|
|||||||
|
|
||||||
// Update index-dependent attributes
|
// Update index-dependent attributes
|
||||||
card.dataset.pageIndex = index.toString();
|
card.dataset.pageIndex = index.toString();
|
||||||
const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2');
|
const info = card.querySelector(
|
||||||
|
'.text-xs.text-gray-400.text-center.mb-2'
|
||||||
|
);
|
||||||
if (info) info.textContent = `Page ${index + 1} `;
|
if (info) info.textContent = `Page ${index + 1} `;
|
||||||
|
|
||||||
// Update selection state
|
// Update selection state
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]');
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
);
|
||||||
if (selectBtn) {
|
if (selectBtn) {
|
||||||
if (selectedPages.has(index)) {
|
if (selectedPages.has(index)) {
|
||||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.remove(
|
||||||
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
'border-indigo-500',
|
||||||
|
'ring-2',
|
||||||
|
'ring-indigo-500'
|
||||||
|
);
|
||||||
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
}
|
}
|
||||||
// Update click handler to use new index
|
// Update click handler to use new index
|
||||||
(selectBtn as HTMLElement).onclick = (e) => {
|
(selectBtn as HTMLElement).onclick = (e) => {
|
||||||
@@ -1128,17 +1331,47 @@ function updatePageDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update action buttons
|
// Update action buttons
|
||||||
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90');
|
const actionsInner = card.querySelector(
|
||||||
|
'.flex.items-center.gap-1.bg-gray-900\\/90'
|
||||||
|
);
|
||||||
if (actionsInner) {
|
if (actionsInner) {
|
||||||
const buttons = actionsInner.querySelectorAll('button');
|
const buttons = actionsInner.querySelectorAll('button');
|
||||||
if (buttons[0]) (buttons[0] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); };
|
if (buttons[0])
|
||||||
if (buttons[1]) (buttons[1] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); };
|
(buttons[0] as HTMLElement).onclick = (e) => {
|
||||||
if (buttons[2]) (buttons[2] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); };
|
e.stopPropagation();
|
||||||
if (buttons[3]) (buttons[3] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); };
|
rotatePage(index, -90);
|
||||||
if (buttons[4]) (buttons[4] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); };
|
};
|
||||||
if (buttons[5]) (buttons[5] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); };
|
if (buttons[1])
|
||||||
|
(buttons[1] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rotatePage(index, 90);
|
||||||
|
};
|
||||||
|
if (buttons[2])
|
||||||
|
(buttons[2] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
duplicatePage(index);
|
||||||
|
};
|
||||||
|
if (buttons[3])
|
||||||
|
(buttons[3] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
insertPdfAfter(index);
|
||||||
|
};
|
||||||
|
if (buttons[4])
|
||||||
|
(buttons[4] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
toggleSplitMarker(index);
|
||||||
|
renderSplitMarkers();
|
||||||
|
};
|
||||||
|
if (buttons[5])
|
||||||
|
(buttons[5] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
deletePage(index);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Element doesn't exist, create it
|
// Element doesn't exist, create it
|
||||||
card = createPageElement(pageData.canvas, index);
|
card = createPageElement(pageData.canvas, index);
|
||||||
@@ -1179,7 +1412,9 @@ function updatePageNumbers() {
|
|||||||
// We need to find the buttons and update their onclick handlers
|
// We need to find the buttons and update their onclick handlers
|
||||||
// This is necessary because the original handlers captured the old index
|
// This is necessary because the original handlers captured the old index
|
||||||
|
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]') as HTMLButtonElement;
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
if (selectBtn) {
|
if (selectBtn) {
|
||||||
selectBtn.onclick = (e) => {
|
selectBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1187,16 +1422,47 @@ function updatePageNumbers() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90');
|
const actionsInner = card.querySelector(
|
||||||
|
'.flex.items-center.gap-1.bg-gray-900\\/90'
|
||||||
|
);
|
||||||
if (actionsInner) {
|
if (actionsInner) {
|
||||||
const buttons = actionsInner.querySelectorAll('button');
|
const buttons = actionsInner.querySelectorAll('button');
|
||||||
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
|
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
|
||||||
if (buttons[0]) buttons[0].onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); };
|
if (buttons[0])
|
||||||
if (buttons[1]) buttons[1].onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); };
|
buttons[0].onclick = (e) => {
|
||||||
if (buttons[2]) buttons[2].onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); };
|
e.stopPropagation();
|
||||||
if (buttons[3]) buttons[3].onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); };
|
rotatePage(index, -90);
|
||||||
if (buttons[4]) buttons[4].onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); };
|
};
|
||||||
if (buttons[5]) buttons[5].onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); };
|
if (buttons[1])
|
||||||
|
buttons[1].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rotatePage(index, 90);
|
||||||
|
};
|
||||||
|
if (buttons[2])
|
||||||
|
buttons[2].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
duplicatePage(index);
|
||||||
|
};
|
||||||
|
if (buttons[3])
|
||||||
|
buttons[3].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
insertPdfAfter(index);
|
||||||
|
};
|
||||||
|
if (buttons[4])
|
||||||
|
buttons[4].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
toggleSplitMarker(index);
|
||||||
|
renderSplitMarkers();
|
||||||
|
};
|
||||||
|
if (buttons[5])
|
||||||
|
buttons[5].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
deletePage(index);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,11 @@ import { showAlert } from '../ui.js';
|
|||||||
import { tesseractLanguages } from '../config/tesseract-languages.js';
|
import { tesseractLanguages } from '../config/tesseract-languages.js';
|
||||||
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
|
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
|
||||||
import { executeWorkflow } from '../workflow/engine';
|
import { executeWorkflow } from '../workflow/engine';
|
||||||
import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry';
|
import {
|
||||||
|
nodeRegistry,
|
||||||
|
getNodesByCategory,
|
||||||
|
createNodeByType,
|
||||||
|
} from '../workflow/nodes/registry';
|
||||||
import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
|
import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
|
||||||
import type { WorkflowEditor } from '../workflow/editor';
|
import type { WorkflowEditor } from '../workflow/editor';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +47,7 @@ import {
|
|||||||
|
|
||||||
let workflowEditor: WorkflowEditor | null = null;
|
let workflowEditor: WorkflowEditor | null = null;
|
||||||
let selectedNodeId: string | null = null;
|
let selectedNodeId: string | null = null;
|
||||||
|
let deleteNodeHandler: EventListener | null = null;
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
@@ -245,10 +250,14 @@ async function initializePage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('wf-delete-node', ((e: CustomEvent) => {
|
if (deleteNodeHandler) {
|
||||||
|
document.removeEventListener('wf-delete-node', deleteNodeHandler);
|
||||||
|
}
|
||||||
|
deleteNodeHandler = ((e: CustomEvent) => {
|
||||||
const nodeId = e.detail?.nodeId;
|
const nodeId = e.detail?.nodeId;
|
||||||
if (nodeId) deleteNodeById(nodeId);
|
if (nodeId) deleteNodeById(nodeId);
|
||||||
}) as EventListener);
|
}) as EventListener;
|
||||||
|
document.addEventListener('wf-delete-node', deleteNodeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNodeById(nodeId: string) {
|
async function deleteNodeById(nodeId: string) {
|
||||||
@@ -539,14 +548,12 @@ async function addNodeToCanvas(
|
|||||||
if (!workflowEditor) return;
|
if (!workflowEditor) return;
|
||||||
const { editor, area } = workflowEditor;
|
const { editor, area } = workflowEditor;
|
||||||
|
|
||||||
const entry = nodeRegistry[type];
|
|
||||||
if (!entry) {
|
|
||||||
console.error('Node type not found in registry:', type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const node = entry.factory();
|
const node = createNodeByType(type);
|
||||||
|
if (!node) {
|
||||||
|
console.error('Node type not found in registry:', type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await editor.addNode(node);
|
await editor.addNode(node);
|
||||||
|
|
||||||
const pos = position || getCanvasCenter(area);
|
const pos = position || getCanvasCenter(area);
|
||||||
|
|||||||
+100
-5
@@ -138,6 +138,7 @@ const init = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolTranslationKeys: Record<string, string> = {
|
const toolTranslationKeys: Record<string, string> = {
|
||||||
|
'PDF Workflow Builder': 'tools:pdfWorkflow',
|
||||||
'PDF Multi Tool': 'tools:pdfMultiTool',
|
'PDF Multi Tool': 'tools:pdfMultiTool',
|
||||||
'Merge PDF': 'tools:mergePdf',
|
'Merge PDF': 'tools:mergePdf',
|
||||||
'Split PDF': 'tools:splitPdf',
|
'Split PDF': 'tools:splitPdf',
|
||||||
@@ -158,6 +159,7 @@ const init = async () => {
|
|||||||
'Background Color': 'tools:backgroundColor',
|
'Background Color': 'tools:backgroundColor',
|
||||||
'Change Text Color': 'tools:changeTextColor',
|
'Change Text Color': 'tools:changeTextColor',
|
||||||
'Add Stamps': 'tools:addStamps',
|
'Add Stamps': 'tools:addStamps',
|
||||||
|
'Bates Numbering': 'tools:batesNumbering',
|
||||||
'Remove Annotations': 'tools:removeAnnotations',
|
'Remove Annotations': 'tools:removeAnnotations',
|
||||||
'PDF Form Filler': 'tools:pdfFormFiller',
|
'PDF Form Filler': 'tools:pdfFormFiller',
|
||||||
'Create PDF Form': 'tools:createPdfForm',
|
'Create PDF Form': 'tools:createPdfForm',
|
||||||
@@ -255,19 +257,78 @@ const init = async () => {
|
|||||||
if (dom.toolGrid) {
|
if (dom.toolGrid) {
|
||||||
dom.toolGrid.textContent = '';
|
dom.toolGrid.textContent = '';
|
||||||
|
|
||||||
|
let collapsedCategories: string[] = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('collapsedCategories');
|
||||||
|
if (stored) collapsedCategories = JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('collapsedCategories');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsedCategories() {
|
||||||
|
localStorage.setItem(
|
||||||
|
'collapsedCategories',
|
||||||
|
JSON.stringify(collapsedCategories)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
const categoryGroup = document.createElement('div');
|
const categoryGroup = document.createElement('div');
|
||||||
categoryGroup.className = 'category-group col-span-full';
|
categoryGroup.className = 'category-group col-span-full';
|
||||||
|
|
||||||
const title = document.createElement('h2');
|
const header = document.createElement('button');
|
||||||
title.className =
|
header.className = 'category-header';
|
||||||
'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white';
|
header.type = 'button';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
const categoryKey = categoryTranslationKeys[category.name];
|
const categoryKey = categoryTranslationKeys[category.name];
|
||||||
title.textContent = categoryKey ? t(categoryKey) : category.name;
|
title.textContent = categoryKey ? t(categoryKey) : category.name;
|
||||||
|
|
||||||
|
const chevron = document.createElement('i');
|
||||||
|
chevron.setAttribute('data-lucide', 'chevron-down');
|
||||||
|
chevron.className =
|
||||||
|
'category-chevron w-5 h-5 text-gray-400 transition-transform duration-300';
|
||||||
|
|
||||||
|
header.append(title, chevron);
|
||||||
|
|
||||||
const toolsContainer = document.createElement('div');
|
const toolsContainer = document.createElement('div');
|
||||||
toolsContainer.className =
|
toolsContainer.className =
|
||||||
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6';
|
'category-tools grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6';
|
||||||
|
|
||||||
|
const isCollapsed = collapsedCategories.includes(category.name);
|
||||||
|
if (isCollapsed) {
|
||||||
|
categoryGroup.classList.add('collapsed');
|
||||||
|
toolsContainer.style.maxHeight = '0px';
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsContainer.addEventListener('transitionend', (e) => {
|
||||||
|
if ((e as TransitionEvent).propertyName !== 'max-height') return;
|
||||||
|
if (!categoryGroup.classList.contains('collapsed')) {
|
||||||
|
toolsContainer.style.maxHeight = 'none';
|
||||||
|
toolsContainer.style.overflow = 'visible';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const collapsed = categoryGroup.classList.toggle('collapsed');
|
||||||
|
if (collapsed) {
|
||||||
|
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
|
||||||
|
toolsContainer.style.overflow = 'hidden';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toolsContainer.style.maxHeight = '0px';
|
||||||
|
});
|
||||||
|
if (!collapsedCategories.includes(category.name)) {
|
||||||
|
collapsedCategories.push(category.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toolsContainer.style.overflow = 'hidden';
|
||||||
|
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
|
||||||
|
collapsedCategories = collapsedCategories.filter(
|
||||||
|
(n) => n !== category.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
saveCollapsedCategories();
|
||||||
|
});
|
||||||
|
|
||||||
category.tools.forEach((tool) => {
|
category.tools.forEach((tool) => {
|
||||||
let toolCard: HTMLDivElement | HTMLAnchorElement;
|
let toolCard: HTMLDivElement | HTMLAnchorElement;
|
||||||
@@ -312,8 +373,13 @@ const init = async () => {
|
|||||||
toolsContainer.appendChild(toolCard);
|
toolsContainer.appendChild(toolCard);
|
||||||
});
|
});
|
||||||
|
|
||||||
categoryGroup.append(title, toolsContainer);
|
categoryGroup.append(header, toolsContainer);
|
||||||
dom.toolGrid.appendChild(categoryGroup);
|
dom.toolGrid.appendChild(categoryGroup);
|
||||||
|
|
||||||
|
if (!isCollapsed) {
|
||||||
|
toolsContainer.style.maxHeight = 'none';
|
||||||
|
toolsContainer.style.overflow = 'visible';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchBar = document.getElementById('search-bar');
|
const searchBar = document.getElementById('search-bar');
|
||||||
@@ -547,6 +613,35 @@ const init = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compactModeToggle = document.getElementById(
|
||||||
|
'compact-mode-toggle'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const savedCompactMode = localStorage.getItem('compactMode') === 'true';
|
||||||
|
if (compactModeToggle) {
|
||||||
|
compactModeToggle.checked = savedCompactMode;
|
||||||
|
}
|
||||||
|
applyCompactMode(savedCompactMode);
|
||||||
|
|
||||||
|
function applyCompactMode(enabled: boolean) {
|
||||||
|
if (dom.toolGrid) {
|
||||||
|
dom.toolGrid.classList.toggle('compact-mode', enabled);
|
||||||
|
dom.toolGrid
|
||||||
|
.querySelectorAll('.category-group:not(.collapsed) .category-tools')
|
||||||
|
.forEach((container) => {
|
||||||
|
(container as HTMLElement).style.maxHeight = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compactModeToggle) {
|
||||||
|
compactModeToggle.addEventListener('change', (e) => {
|
||||||
|
const enabled = (e.target as HTMLInputElement).checked;
|
||||||
|
localStorage.setItem('compactMode', enabled.toString());
|
||||||
|
applyCompactMode(enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Shortcuts UI Handlers
|
// Shortcuts UI Handlers
|
||||||
if (dom.openShortcutsBtn) {
|
if (dom.openShortcutsBtn) {
|
||||||
dom.openShortcutsBtn.addEventListener('click', () => {
|
dom.openShortcutsBtn.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||||
|
|
||||||
export interface AddWatermarkState {
|
export interface AddWatermarkState {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
pdfDoc: PDFLibDocument | null;
|
pdfDoc: PDFLibDocument | null;
|
||||||
|
pdfBytes: Uint8Array | null;
|
||||||
|
previewCanvas: HTMLCanvasElement | null;
|
||||||
|
watermarkX: number; // 0–1, percentage from left
|
||||||
|
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface StylePreset {
|
||||||
|
template: string;
|
||||||
|
padding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Position =
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right';
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
file: File;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
@@ -49,3 +49,4 @@ export * from './email-to-pdf-type.ts';
|
|||||||
export * from './bookmark-pdf-type.ts';
|
export * from './bookmark-pdf-type.ts';
|
||||||
export * from './scanner-effect-type.ts';
|
export * from './scanner-effect-type.ts';
|
||||||
export * from './adjust-colors-type.ts';
|
export * from './adjust-colors-type.ts';
|
||||||
|
export * from './bates-numbering-type.ts';
|
||||||
|
|||||||
+12
-15
@@ -83,25 +83,22 @@ export async function performCondenseCompression(
|
|||||||
try {
|
try {
|
||||||
const result = await pymupdf.compressPdf(fileBlob, options);
|
const result = await pymupdf.compressPdf(fileBlob, options);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
const errorMessage = error?.message || String(error);
|
const fallbackOptions = {
|
||||||
if (
|
...options,
|
||||||
errorMessage.includes('PatternType') ||
|
images: {
|
||||||
errorMessage.includes('pattern')
|
...options.images,
|
||||||
) {
|
enabled: false,
|
||||||
const fallbackOptions = {
|
},
|
||||||
...options,
|
};
|
||||||
images: {
|
|
||||||
...options.images,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
|
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
|
||||||
return { ...result, usedFallback: true };
|
return { ...result, usedFallback: true };
|
||||||
|
} catch (fallbackError: any) {
|
||||||
|
const msg = fallbackError?.message || String(fallbackError);
|
||||||
|
throw new Error(`PDF compression failed: ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`PDF compression failed: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ export interface TextWatermarkOptions {
|
|||||||
color: { r: number; g: number; b: number };
|
color: { r: number; g: number; b: number };
|
||||||
opacity: number;
|
opacity: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
pageIndices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTextWatermark(
|
export async function addTextWatermark(
|
||||||
@@ -194,19 +197,60 @@ export async function addTextWatermark(
|
|||||||
options: TextWatermarkOptions
|
options: TextWatermarkOptions
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Failed to create canvas context');
|
||||||
|
|
||||||
|
const dpr = 2;
|
||||||
|
const colorR = Math.round(options.color.r * 255);
|
||||||
|
const colorG = Math.round(options.color.g * 255);
|
||||||
|
const colorB = Math.round(options.color.b * 255);
|
||||||
|
const fontStr = `bold ${options.fontSize * dpr}px "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif`;
|
||||||
|
|
||||||
|
ctx.font = fontStr;
|
||||||
|
const metrics = ctx.measureText(options.text);
|
||||||
|
|
||||||
|
canvas.width = Math.ceil(metrics.width) + 4;
|
||||||
|
canvas.height = Math.ceil(options.fontSize * dpr * 1.4);
|
||||||
|
|
||||||
|
ctx.font = fontStr;
|
||||||
|
ctx.fillStyle = `rgb(${colorR}, ${colorG}, ${colorB})`;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(options.text, 2, canvas.height / 2);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const imageBytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
|
||||||
|
const image = await pdfDoc.embedPng(imageBytes);
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
|
const posX = options.x ?? 0.5;
|
||||||
|
const posY = options.y ?? 0.5;
|
||||||
|
const imgWidth = image.width / dpr;
|
||||||
|
const imgHeight = image.height / dpr;
|
||||||
|
|
||||||
for (const page of pages) {
|
const rad = (options.angle * Math.PI) / 180;
|
||||||
|
const halfW = imgWidth / 2;
|
||||||
|
const halfH = imgHeight / 2;
|
||||||
|
|
||||||
|
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
|
||||||
|
for (const idx of targetIndices) {
|
||||||
|
const page = pages[idx];
|
||||||
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const textWidth = font.widthOfTextAtSize(options.text, options.fontSize);
|
const cx = posX * width;
|
||||||
|
const cy = posY * height;
|
||||||
|
|
||||||
page.drawText(options.text, {
|
page.drawImage(image, {
|
||||||
x: (width - textWidth) / 2,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
y: height / 2,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
font,
|
width: imgWidth,
|
||||||
size: options.fontSize,
|
height: imgHeight,
|
||||||
color: rgb(options.color.r, options.color.g, options.color.b),
|
|
||||||
opacity: options.opacity,
|
opacity: options.opacity,
|
||||||
rotate: degrees(options.angle),
|
rotate: degrees(options.angle),
|
||||||
});
|
});
|
||||||
@@ -221,6 +265,9 @@ export interface ImageWatermarkOptions {
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
pageIndices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addImageWatermark(
|
export async function addImageWatermark(
|
||||||
@@ -233,15 +280,26 @@ export async function addImageWatermark(
|
|||||||
? await pdfDoc.embedPng(options.imageBytes)
|
? await pdfDoc.embedPng(options.imageBytes)
|
||||||
: await pdfDoc.embedJpg(options.imageBytes);
|
: await pdfDoc.embedJpg(options.imageBytes);
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
|
const posX = options.x ?? 0.5;
|
||||||
|
const posY = options.y ?? 0.5;
|
||||||
|
|
||||||
for (const page of pages) {
|
const imgWidth = image.width * options.scale;
|
||||||
|
const imgHeight = image.height * options.scale;
|
||||||
|
const rad = (options.angle * Math.PI) / 180;
|
||||||
|
const halfW = imgWidth / 2;
|
||||||
|
const halfH = imgHeight / 2;
|
||||||
|
|
||||||
|
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
|
||||||
|
for (const idx of targetIndices) {
|
||||||
|
const page = pages[idx];
|
||||||
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const imgWidth = image.width * options.scale;
|
const cx = posX * width;
|
||||||
const imgHeight = image.height * options.scale;
|
const cy = posY * height;
|
||||||
|
|
||||||
page.drawImage(image, {
|
page.drawImage(image, {
|
||||||
x: (width - imgWidth) / 2,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
y: (height - imgHeight) / 2,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
width: imgWidth,
|
width: imgWidth,
|
||||||
height: imgHeight,
|
height: imgHeight,
|
||||||
opacity: options.opacity,
|
opacity: options.opacity,
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ export async function renderPageToCanvas(
|
|||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
const context = canvas.getContext('2d')!;
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`Failed to get 2D context for page ${pageNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
await page.render({
|
await page.render({
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
@@ -115,12 +118,9 @@ export async function renderPageToCanvas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a batch of pages in parallel
|
* Renders a batch of pages
|
||||||
*/
|
*/
|
||||||
async function renderPageBatch(
|
async function renderPageBatch(tasks: PageTask[]): Promise<void> {
|
||||||
tasks: PageTask[],
|
|
||||||
onProgress?: (current: number, total: number) => void
|
|
||||||
): Promise<void> {
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
try {
|
try {
|
||||||
const canvas = await renderPageToCanvas(
|
const canvas = await renderPageToCanvas(
|
||||||
@@ -147,6 +147,13 @@ async function renderPageBatch(
|
|||||||
parent.insertBefore(wrapper, placeholder);
|
parent.insertBefore(wrapper, placeholder);
|
||||||
parent.removeChild(placeholder);
|
parent.removeChild(placeholder);
|
||||||
} else {
|
} else {
|
||||||
|
const existingRendered = task.container.querySelector(
|
||||||
|
`[data-page-number="${task.pageNumber}"]:not([data-lazy-load="true"])`
|
||||||
|
);
|
||||||
|
if (existingRendered) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const allChildren = Array.from(
|
const allChildren = Array.from(
|
||||||
task.container.children
|
task.container.children
|
||||||
) as HTMLElement[];
|
) as HTMLElement[];
|
||||||
@@ -207,7 +214,7 @@ function setupLazyRendering(
|
|||||||
task.placeholderElement = placeholder;
|
task.placeholderElement = placeholder;
|
||||||
|
|
||||||
// Render this page immediately (not waiting for isRendering flag)
|
// Render this page immediately (not waiting for isRendering flag)
|
||||||
renderPageBatch([task], config.onProgress)
|
renderPageBatch([task])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Trigger callback after lazy load batch
|
// Trigger callback after lazy load batch
|
||||||
if (config.onBatchComplete) {
|
if (config.onBatchComplete) {
|
||||||
@@ -263,21 +270,19 @@ export async function renderPagesProgressively(
|
|||||||
config: RenderConfig = {}
|
config: RenderConfig = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
batchSize = 8, // Increased from 5 to 8 for faster initial render
|
batchSize = 8,
|
||||||
useLazyLoading = true,
|
useLazyLoading = true,
|
||||||
eagerLoadBatches = 2, // Eagerly load 1 batch ahead by default
|
eagerLoadBatches = 2,
|
||||||
onProgress,
|
onProgress,
|
||||||
onBatchComplete,
|
onBatchComplete,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
const totalPages = pdfjsDoc.numPages;
|
const totalPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
// Render more pages initially to reduce lazy loading issues
|
|
||||||
const initialRenderCount = useLazyLoading
|
const initialRenderCount = useLazyLoading
|
||||||
? Math.min(20, totalPages) // Increased from 12 to 20 pages
|
? Math.min(20, totalPages)
|
||||||
: totalPages;
|
: totalPages;
|
||||||
|
|
||||||
// CRITICAL FIX: Create placeholders for ALL pages first to maintain order
|
|
||||||
const placeholders: HTMLElement[] = [];
|
const placeholders: HTMLElement[] = [];
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
const placeholder = createPlaceholder(i);
|
const placeholder = createPlaceholder(i);
|
||||||
@@ -293,7 +298,7 @@ export async function renderPagesProgressively(
|
|||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
pdfjsDoc,
|
pdfjsDoc,
|
||||||
container,
|
container,
|
||||||
scale: config.useLazyLoading ? 0.3 : 0.5,
|
scale: useLazyLoading ? 0.3 : 0.5,
|
||||||
createWrapper,
|
createWrapper,
|
||||||
placeholderElement: placeholders[i - 1],
|
placeholderElement: placeholders[i - 1],
|
||||||
});
|
});
|
||||||
@@ -330,19 +335,24 @@ export async function renderPagesProgressively(
|
|||||||
|
|
||||||
const batch = initialTasks.slice(i, i + batchSize);
|
const batch = initialTasks.slice(i, i + batchSize);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
requestIdleCallbackPolyfill(async () => {
|
requestIdleCallbackPolyfill(() => {
|
||||||
await renderPageBatch(batch, onProgress);
|
renderPageBatch(batch)
|
||||||
|
.then(() => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(
|
||||||
|
Math.min(i + batchSize, initialRenderCount),
|
||||||
|
totalPages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (onProgress) {
|
if (onBatchComplete) {
|
||||||
onProgress(Math.min(i + batchSize, initialRenderCount), totalPages);
|
onBatchComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onBatchComplete) {
|
resolve();
|
||||||
onBatchComplete();
|
})
|
||||||
}
|
.catch(reject);
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -397,8 +407,11 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
requestIdleCallbackPolyfill(async () => {
|
requestIdleCallbackPolyfill(async () => {
|
||||||
if (config.shouldCancel?.()) return;
|
if (config.shouldCancel?.()) return;
|
||||||
|
|
||||||
// Remove these tasks from pending since we're rendering them eagerly
|
const tasksToRender = batch.filter((task) =>
|
||||||
batch.forEach((task) => {
|
lazyLoadState.pendingTasksByPageNumber.has(task.pageNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
tasksToRender.forEach((task) => {
|
||||||
const placeholder = task.placeholderElement;
|
const placeholder = task.placeholderElement;
|
||||||
if (placeholder && lazyLoadState.observer) {
|
if (placeholder && lazyLoadState.observer) {
|
||||||
lazyLoadState.observer.unobserve(placeholder);
|
lazyLoadState.observer.unobserve(placeholder);
|
||||||
@@ -407,7 +420,18 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await renderPageBatch(batch, config.onProgress);
|
if (tasksToRender.length === 0) {
|
||||||
|
lazyLoadState.nextEagerIndex = batchEnd;
|
||||||
|
const remainingBatches = Math.ceil(
|
||||||
|
(eagerLoadQueue.length - batchEnd) / batchSize
|
||||||
|
);
|
||||||
|
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
||||||
|
renderEagerBatch(config);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderPageBatch(tasksToRender);
|
||||||
|
|
||||||
if (config.onBatchComplete) {
|
if (config.onBatchComplete) {
|
||||||
config.onBatchComplete();
|
config.onBatchComplete();
|
||||||
@@ -421,7 +445,6 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
(eagerLoadQueue.length - batchEnd) / batchSize
|
(eagerLoadQueue.length - batchEnd) / batchSize
|
||||||
);
|
);
|
||||||
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
||||||
// Continue eager loading if we have more batches within the eager threshold
|
|
||||||
renderEagerBatch(config);
|
renderEagerBatch(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface WasmProviderConfig {
|
|||||||
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
||||||
|
|
||||||
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
||||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/',
|
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/',
|
||||||
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
||||||
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
||||||
};
|
};
|
||||||
|
|||||||
+174
-149
@@ -8,7 +8,7 @@ import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
|
|||||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||||
import { DataflowEngine } from 'rete-engine';
|
import { DataflowEngine } from 'rete-engine';
|
||||||
import type { DataflowEngineScheme } from 'rete-engine';
|
import type { DataflowEngineScheme } from 'rete-engine';
|
||||||
import { html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||||
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
|
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
|
||||||
import phosphorCSS from '@phosphor-icons/web/regular?inline';
|
import phosphorCSS from '@phosphor-icons/web/regular?inline';
|
||||||
@@ -49,6 +49,175 @@ function getStatusInfo(status: string, connected: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WorkflowNodeElement extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
data: { attribute: false },
|
||||||
|
emit: { attribute: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
declare data: BaseWorkflowNode | undefined;
|
||||||
|
declare emit: ((data: unknown) => void) | undefined;
|
||||||
|
|
||||||
|
createRenderRoot(): HTMLElement | ShadowRoot {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.data) return html``;
|
||||||
|
const node = this.data;
|
||||||
|
const inputs = Object.entries(node.inputs || {});
|
||||||
|
const outputs = Object.entries(node.outputs || {});
|
||||||
|
const color = categoryColors[node.category] || '#6b7280';
|
||||||
|
const emitFn = this.emit;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: relative; display: flex; flex-direction: column;
|
||||||
|
align-items: center; width: 280px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
${inputs.length > 0
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
|
||||||
|
>
|
||||||
|
${inputs.map(([key, input]) =>
|
||||||
|
input
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<rete-ref
|
||||||
|
.data=${{
|
||||||
|
type: 'socket',
|
||||||
|
side: 'input',
|
||||||
|
key,
|
||||||
|
nodeId: node.id,
|
||||||
|
payload: input.socket,
|
||||||
|
}}
|
||||||
|
.emit=${emitFn}
|
||||||
|
></rete-ref>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #1f2937; border: 1px solid #374151;
|
||||||
|
border-radius: 12px; width: 100%; overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-wf="bar"
|
||||||
|
style="
|
||||||
|
height: 100%; width: 100%;
|
||||||
|
background: #6b7280; opacity: 0.25;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-wf="dot"
|
||||||
|
style="
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
data-wf="label"
|
||||||
|
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
|
||||||
|
>Not connected</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-wf-delete="${node.id}"
|
||||||
|
style="
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
|
color: #6b7280; transition: all 0.15s;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="height: 1px; background: #374151; margin: 0 14px;"></div>
|
||||||
|
<div
|
||||||
|
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="ph ${node.icon}"
|
||||||
|
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
|
||||||
|
></i>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<div
|
||||||
|
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
|
||||||
|
>
|
||||||
|
${node.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
|
||||||
|
>
|
||||||
|
${node.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${outputs.length > 0
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
|
||||||
|
>
|
||||||
|
${outputs.map(([key, output]) =>
|
||||||
|
output
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<rete-ref
|
||||||
|
.data=${{
|
||||||
|
type: 'socket',
|
||||||
|
side: 'output',
|
||||||
|
key,
|
||||||
|
nodeId: node.id,
|
||||||
|
payload: output.socket,
|
||||||
|
}}
|
||||||
|
.emit=${emitFn}
|
||||||
|
></rete-ref>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('wf-node')) {
|
||||||
|
customElements.define('wf-node', WorkflowNodeElement);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateNodeDisplay(
|
export function updateNodeDisplay(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
editor: NodeEditor<ClassicScheme>,
|
editor: NodeEditor<ClassicScheme>,
|
||||||
@@ -111,154 +280,10 @@ export async function createWorkflowEditor(
|
|||||||
customize: {
|
customize: {
|
||||||
node(data) {
|
node(data) {
|
||||||
return ({ emit }: { emit: (data: unknown) => void }) => {
|
return ({ emit }: { emit: (data: unknown) => void }) => {
|
||||||
const node = data.payload as BaseWorkflowNode;
|
return html`<wf-node
|
||||||
const inputs = Object.entries(node.inputs || {});
|
.data=${data.payload}
|
||||||
const outputs = Object.entries(node.outputs || {});
|
.emit=${emit}
|
||||||
const color = categoryColors[node.category] || '#6b7280';
|
></wf-node>`;
|
||||||
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
position: relative; display: flex; flex-direction: column;
|
|
||||||
align-items: center; width: 280px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
${inputs.length > 0
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
|
|
||||||
>
|
|
||||||
${inputs.map(([key, input]) =>
|
|
||||||
input
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; align-items: center; justify-content: center;"
|
|
||||||
>
|
|
||||||
<rete-ref
|
|
||||||
.data=${{
|
|
||||||
type: 'socket',
|
|
||||||
side: 'input',
|
|
||||||
key,
|
|
||||||
nodeId: node.id,
|
|
||||||
payload: input.socket,
|
|
||||||
}}
|
|
||||||
.emit=${emit}
|
|
||||||
></rete-ref>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null}
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
background: #1f2937; border: 1px solid #374151;
|
|
||||||
border-radius: 12px; width: 100%; overflow: hidden;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-wf="bar"
|
|
||||||
style="
|
|
||||||
height: 100%; width: 100%;
|
|
||||||
background: #6b7280; opacity: 0.25;
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-wf="dot"
|
|
||||||
style="
|
|
||||||
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
data-wf="label"
|
|
||||||
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
|
|
||||||
>Not connected</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-wf-delete="${node.id}"
|
|
||||||
style="
|
|
||||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: 4px;
|
|
||||||
color: #6b7280; transition: all 0.15s;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="height: 1px; background: #374151; margin: 0 14px;"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="ph ${node.icon}"
|
|
||||||
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
|
|
||||||
></i>
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div
|
|
||||||
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
|
|
||||||
>
|
|
||||||
${node.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
|
|
||||||
>
|
|
||||||
${node.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${outputs.length > 0
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
|
|
||||||
>
|
|
||||||
${outputs.map(([key, output]) =>
|
|
||||||
output
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; align-items: center; justify-content: center;"
|
|
||||||
>
|
|
||||||
<rete-ref
|
|
||||||
.data=${{
|
|
||||||
type: 'socket',
|
|
||||||
side: 'output',
|
|
||||||
key,
|
|
||||||
nodeId: node.id,
|
|
||||||
payload: output.socket,
|
|
||||||
}}
|
|
||||||
.emit=${emit}
|
|
||||||
></rete-ref>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
socket() {
|
socket() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
|||||||
width = 280;
|
width = 280;
|
||||||
height = 140;
|
height = 140;
|
||||||
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
|
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
|
||||||
|
nodeType: string = '';
|
||||||
|
|
||||||
constructor(label: string) {
|
constructor(label: string) {
|
||||||
super(label);
|
super(label);
|
||||||
|
|||||||
@@ -593,7 +593,9 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
|||||||
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
||||||
const entry = nodeRegistry[type];
|
const entry = nodeRegistry[type];
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return entry.factory();
|
const node = entry.factory();
|
||||||
|
node.nodeType = type;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodesByCategory(): Record<
|
export function getNodesByCategory(): Record<
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { BaseWorkflowNode } from './base-node';
|
|||||||
import { pdfSocket } from '../sockets';
|
import { pdfSocket } from '../sockets';
|
||||||
import type { SocketData } from '../types';
|
import type { SocketData } from '../types';
|
||||||
import { requirePdfInput, processBatch } from '../types';
|
import { requirePdfInput, processBatch } from '../types';
|
||||||
import { addTextWatermark } from '../../utils/pdf-operations';
|
import { addTextWatermark, parsePageRange } from '../../utils/pdf-operations';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import { hexToRgb } from '../../utils/helpers.js';
|
import { hexToRgb } from '../../utils/helpers.js';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
export class WatermarkNode extends BaseWorkflowNode {
|
export class WatermarkNode extends BaseWorkflowNode {
|
||||||
readonly category = 'Edit & Annotate' as const;
|
readonly category = 'Edit & Annotate' as const;
|
||||||
@@ -39,6 +40,18 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
'angle',
|
'angle',
|
||||||
new ClassicPreset.InputControl('number', { initial: -45 })
|
new ClassicPreset.InputControl('number', { initial: -45 })
|
||||||
);
|
);
|
||||||
|
this.addControl(
|
||||||
|
'position',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'center' })
|
||||||
|
);
|
||||||
|
this.addControl(
|
||||||
|
'pages',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'all' })
|
||||||
|
);
|
||||||
|
this.addControl(
|
||||||
|
'flatten',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'no' })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async data(
|
async data(
|
||||||
@@ -67,16 +80,89 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
const opacity = getNum('opacity', 30) / 100;
|
const opacity = getNum('opacity', 30) / 100;
|
||||||
const angle = getNum('angle', -45);
|
const angle = getNum('angle', -45);
|
||||||
|
|
||||||
|
const positionPresets: Record<string, { x: number; y: number }> = {
|
||||||
|
'top-left': { x: 0.15, y: 0.15 },
|
||||||
|
top: { x: 0.5, y: 0.15 },
|
||||||
|
'top-right': { x: 0.85, y: 0.15 },
|
||||||
|
left: { x: 0.15, y: 0.5 },
|
||||||
|
center: { x: 0.5, y: 0.5 },
|
||||||
|
right: { x: 0.85, y: 0.5 },
|
||||||
|
'bottom-left': { x: 0.15, y: 0.85 },
|
||||||
|
bottom: { x: 0.5, y: 0.85 },
|
||||||
|
'bottom-right': { x: 0.85, y: 0.85 },
|
||||||
|
};
|
||||||
|
const posKey = getText('position', 'center').trim().toLowerCase();
|
||||||
|
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
|
||||||
|
|
||||||
|
const pagesStr = getText('pages', 'all').trim().toLowerCase();
|
||||||
|
const shouldFlatten =
|
||||||
|
getText('flatten', 'no').trim().toLowerCase() === 'yes';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pdf: await processBatch(pdfInputs, async (input) => {
|
pdf: await processBatch(pdfInputs, async (input) => {
|
||||||
const resultBytes = await addTextWatermark(input.bytes, {
|
const srcDoc = await PDFDocument.load(input.bytes);
|
||||||
|
const totalPages = srcDoc.getPageCount();
|
||||||
|
|
||||||
|
const pageIndices =
|
||||||
|
pagesStr === 'all' ? undefined : parsePageRange(pagesStr, totalPages);
|
||||||
|
|
||||||
|
let resultBytes = await addTextWatermark(input.bytes, {
|
||||||
text: watermarkText,
|
text: watermarkText,
|
||||||
fontSize,
|
fontSize,
|
||||||
color: { r: c.r, g: c.g, b: c.b },
|
color: { r: c.r, g: c.g, b: c.b },
|
||||||
opacity,
|
opacity,
|
||||||
angle,
|
angle,
|
||||||
|
x,
|
||||||
|
y: 1 - y,
|
||||||
|
pageIndices,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldFlatten) {
|
||||||
|
const watermarkedPdf = await pdfjsLib.getDocument({
|
||||||
|
data: resultBytes.slice(),
|
||||||
|
}).promise;
|
||||||
|
const flattenedDoc = await PDFDocument.create();
|
||||||
|
const renderScale = 2.5;
|
||||||
|
|
||||||
|
for (let i = 1; i <= watermarkedPdf.numPages; i++) {
|
||||||
|
const page = await watermarkedPdf.getPage(i);
|
||||||
|
const unscaledVP = page.getViewport({ scale: 1 });
|
||||||
|
const viewport = page.getViewport({ scale: renderScale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
|
||||||
|
|
||||||
|
const jpegBytes = await new Promise<ArrayBuffer>(
|
||||||
|
(resolve, reject) =>
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) =>
|
||||||
|
blob
|
||||||
|
? blob.arrayBuffer().then(resolve)
|
||||||
|
: reject(new Error(`Failed to rasterize page ${i}`)),
|
||||||
|
'image/jpeg',
|
||||||
|
0.92
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const image = await flattenedDoc.embedJpg(jpegBytes);
|
||||||
|
const newPage = flattenedDoc.addPage([
|
||||||
|
unscaledVP.width,
|
||||||
|
unscaledVP.height,
|
||||||
|
]);
|
||||||
|
newPage.drawImage(image, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: unscaledVP.width,
|
||||||
|
height: unscaledVP.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBytes = new Uint8Array(await flattenedDoc.save());
|
||||||
|
}
|
||||||
|
|
||||||
const resultDoc = await PDFDocument.load(resultBytes);
|
const resultDoc = await PDFDocument.load(resultBytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ interface SerializedConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNodeType(node: BaseWorkflowNode): string | null {
|
function getNodeType(node: BaseWorkflowNode): string | null {
|
||||||
const constructorName = node.constructor.name;
|
return node.nodeType || null;
|
||||||
return constructorName || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeWorkflow(
|
function serializeWorkflow(
|
||||||
@@ -79,6 +78,22 @@ async function deserializeWorkflow(
|
|||||||
editor: NodeEditor<ClassicScheme>,
|
editor: NodeEditor<ClassicScheme>,
|
||||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
!Array.isArray((data as any).nodes) ||
|
||||||
|
!Array.isArray((data as any).connections)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid workflow file: missing nodes or connections array.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((data as any).version !== WORKFLOW_VERSION) {
|
||||||
|
console.warn(
|
||||||
|
`Workflow version mismatch: expected ${WORKFLOW_VERSION}, got ${(data as any).version}. Attempting load anyway.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const conn of editor.getConnections()) {
|
for (const conn of editor.getConnections()) {
|
||||||
await editor.removeConnection(conn.id);
|
await editor.removeConnection(conn.id);
|
||||||
}
|
}
|
||||||
@@ -129,9 +144,6 @@ async function deserializeWorkflow(
|
|||||||
|
|
||||||
if (skippedTypes.length > 0) {
|
if (skippedTypes.length > 0) {
|
||||||
console.warn('Skipped unknown node types during load:', skippedTypes);
|
console.warn('Skipped unknown node types during load:', skippedTypes);
|
||||||
throw new Error(
|
|
||||||
`Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +227,11 @@ export function exportWorkflow(
|
|||||||
const json = JSON.stringify(data, null, 2);
|
const json = JSON.stringify(data, null, 2);
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
try {
|
const a = document.createElement('a');
|
||||||
const a = document.createElement('a');
|
a.href = url;
|
||||||
a.href = url;
|
a.download = 'workflow.json';
|
||||||
a.download = 'workflow.json';
|
a.click();
|
||||||
a.click();
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importWorkflow(
|
export async function importWorkflow(
|
||||||
@@ -233,13 +242,16 @@ export async function importWorkflow(
|
|||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.json';
|
input.accept = '.json';
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
|
settled = true;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const data = JSON.parse(text) as SerializedWorkflow;
|
const data = JSON.parse(text) as SerializedWorkflow;
|
||||||
@@ -250,6 +262,15 @@ export async function importWorkflow(
|
|||||||
reject(new Error(`Failed to import workflow: ${message}`));
|
reject(new Error(`Failed to import workflow: ${message}`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled) resolve();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
|
||||||
input.click();
|
input.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+378
-143
@@ -158,171 +158,406 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="options-panel" class="hidden mt-6 space-y-4">
|
<div id="editor-panel" class="hidden bg-gray-900 px-2 sm:px-4 py-4 sm:py-8">
|
||||||
<div class="flex gap-4 mb-4">
|
<div class="max-w-7xl mx-auto">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div
|
||||||
<input
|
class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-0 sm:justify-between mb-4 sm:mb-6"
|
||||||
type="radio"
|
>
|
||||||
name="watermark-type"
|
<button
|
||||||
value="text"
|
id="editor-back-btn"
|
||||||
checked
|
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-semibold text-sm"
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500"
|
>
|
||||||
/>
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||||
<span class="text-sm font-medium text-gray-300"
|
<span>Change File</span>
|
||||||
>Text Watermark</span
|
</button>
|
||||||
|
<div
|
||||||
|
id="editor-file-info"
|
||||||
|
class="text-xs sm:text-sm text-gray-400 truncate max-w-full sm:max-w-[60%]"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 sm:gap-6">
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 rounded-xl border border-gray-700 p-2 sm:p-4 flex-1 flex flex-col"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2 sm:mb-3">
|
||||||
|
<span class="text-xs sm:text-sm font-medium text-gray-300"
|
||||||
|
>Preview</span
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
id="prev-page-btn"
|
||||||
|
disabled
|
||||||
|
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-300">
|
||||||
|
<input
|
||||||
|
id="page-num-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value="1"
|
||||||
|
class="w-10 bg-gray-700 border border-gray-600 text-white rounded text-center text-xs py-0.5"
|
||||||
|
/>
|
||||||
|
<span>/ <span id="total-pages">1</span></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="next-page-btn"
|
||||||
|
disabled
|
||||||
|
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline"
|
||||||
|
>Drag watermark to position</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="preview-wrapper"
|
||||||
|
class="flex-1 min-h-[200px] lg:min-h-0 flex items-center justify-center bg-gray-900 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
</label>
|
<div
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
id="preview-container"
|
||||||
<input
|
class="relative overflow-hidden select-none"
|
||||||
type="radio"
|
style="touch-action: none"
|
||||||
name="watermark-type"
|
>
|
||||||
value="image"
|
<canvas id="preview-canvas"></canvas>
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500"
|
<div
|
||||||
/>
|
id="watermark-box"
|
||||||
<span class="text-sm font-medium text-gray-300"
|
class="absolute pointer-events-auto"
|
||||||
>Image Watermark</span
|
style="
|
||||||
>
|
left: 50%;
|
||||||
</label>
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative border border-dashed border-indigo-400/60 cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div id="watermark-overlay" class="whitespace-nowrap">
|
||||||
|
CONFIDENTIAL
|
||||||
|
</div>
|
||||||
|
<img id="image-watermark-overlay" class="hidden" alt="" />
|
||||||
|
<div
|
||||||
|
data-handle="nw"
|
||||||
|
class="resize-handle absolute -top-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-nw-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="ne"
|
||||||
|
class="resize-handle absolute -top-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-ne-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="sw"
|
||||||
|
class="resize-handle absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-sw-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="se"
|
||||||
|
class="resize-handle absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-se-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="text-watermark-options">
|
<div class="w-full lg:w-80 flex-shrink-0">
|
||||||
<div class="space-y-4">
|
<div
|
||||||
<div>
|
class="bg-gray-800 rounded-xl border border-gray-700 p-3 sm:p-5 space-y-3 sm:space-y-5"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
id="type-text-btn"
|
||||||
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="type-image-btn"
|
||||||
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="apply-all-pages"
|
||||||
|
checked
|
||||||
|
class="w-4 h-4 accent-indigo-500 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
<label
|
<label
|
||||||
for="watermark-text"
|
for="apply-all-pages"
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="text-sm text-gray-300 cursor-pointer"
|
||||||
>Watermark Text</label
|
data-i18n="tools:addWatermark.applyToAllPages"
|
||||||
|
>Apply to all pages</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-range-section" class="space-y-1.5">
|
||||||
|
<label
|
||||||
|
for="page-range-input"
|
||||||
|
class="block text-sm font-medium text-gray-300"
|
||||||
|
>Page Range</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="watermark-text"
|
id="page-range-input"
|
||||||
placeholder="CONFIDENTIAL"
|
value="all"
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
|
placeholder="all or 1-3, 5, 7-9"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Use "all" or specify pages, e.g. 1-3, 5, 7-9
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="text-watermark-options">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="watermark-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Text</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="watermark-text"
|
||||||
|
placeholder="CONFIDENTIAL"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-size"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Font Size</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="font-size"
|
||||||
|
value="72"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="text-color"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Color</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="text-color"
|
||||||
|
value="#888888"
|
||||||
|
class=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="opacity-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Opacity: <span id="opacity-value-text">0.3</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="opacity-text"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value="0.3"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="angle-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Angle: <span id="angle-value-text">-45</span>°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="angle-text"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
step="1"
|
||||||
|
value="-45"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="image-watermark-options" class="hidden">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="image-watermark-input"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Watermark Image</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="image-watermark-input"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="image-scale"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Scale: <span id="image-scale-value">100</span>%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="image-scale"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
step="5"
|
||||||
|
value="100"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="opacity-image"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Opacity: <span id="opacity-value-image">0.3</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="opacity-image"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value="0.3"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="angle-image"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Angle: <span id="angle-value-image">0</span>°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="angle-image"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
step="1"
|
||||||
|
value="0"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="font-size"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Font Size</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="font-size"
|
|
||||||
value="72"
|
|
||||||
min="10"
|
|
||||||
max="200"
|
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="text-color"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Color</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#888888"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="opacity-text"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Opacity: <span id="opacity-value-text">0.3</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="opacity-text"
|
|
||||||
min="0.1"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value="0.3"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="angle-text"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Angle: <span id="angle-value-text">-45</span>°</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="angle-text"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
step="5"
|
|
||||||
value="-45"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="image-watermark-options" class="hidden">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
for="image-watermark-input"
|
>Position</label
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Watermark Image (PNG/JPG)</label
|
|
||||||
>
|
>
|
||||||
|
<div class="grid grid-cols-3 gap-1.5">
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom Right
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3 bg-gray-900 rounded-lg p-3">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="checkbox"
|
||||||
id="image-watermark-input"
|
id="flatten-watermark"
|
||||||
accept="image/png, image/jpeg"
|
class="mt-0.5 w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="opacity-image"
|
for="flatten-watermark"
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||||
>Opacity: <span id="opacity-value-image">0.3</span></label
|
>Flatten watermark</label
|
||||||
>
|
>
|
||||||
<input
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
type="range"
|
Bakes the watermark into page pixels, making it
|
||||||
id="opacity-image"
|
tamper-resistant. Text will no longer be selectable.
|
||||||
min="0.1"
|
</p>
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value="0.3"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="angle-image"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Angle: <span id="angle-value-image">0</span>°</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="angle-image"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
step="5"
|
|
||||||
value="0"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="process-btn" class="btn-gradient w-full">
|
||||||
|
Add Watermark
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
|
||||||
Add Watermark
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,12 +182,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="background-color" value="#FFFFCC" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#FFFFCC"
|
|
||||||
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
Change Background Color
|
Change Background Color
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<title>Bates Numbering Online Free - Add Bates Stamps | BentoPDF</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Add sequential Bates numbers to PDF documents. Supports multi-file processing, custom templates, and configurable formatting. Free, secure, browser-based."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="bates numbering, bates stamp, pdf bates, legal pdf numbering"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="BentoPDF" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="canonical"
|
||||||
|
href="https://www.bentopdf.com/bates-numbering.html"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content="https://www.bentopdf.com/bates-numbering"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content="Bates Numbering Online Free | BentoPDF"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Add sequential Bates numbers to PDF documents. Multi-file support, custom templates. Free and private."
|
||||||
|
/>
|
||||||
|
<meta property="og:site_name" content="BentoPDF" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Bates Numbering Free" />
|
||||||
|
<meta name="twitter:site" content="@BentoPDF" />
|
||||||
|
|
||||||
|
<link href="/src/css/styles.css" rel="stylesheet" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/images/favicon-192x192.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/images/favicon-512x512.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/images/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="antialiased bg-gray-900">
|
||||||
|
{{> navbar }}
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="uploader"
|
||||||
|
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="tool-uploader"
|
||||||
|
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-gray-200 border border-gray-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id="back-to-tools"
|
||||||
|
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
|
||||||
|
>
|
||||||
|
<i data-lucide="arrow-left" class="cursor-pointer"></i>
|
||||||
|
<span class="cursor-pointer" data-i18n="tools.backToTools"
|
||||||
|
>Back to Tools</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="text-2xl font-bold text-white mb-2"
|
||||||
|
data-i18n="tools:batesNumbering.name"
|
||||||
|
>
|
||||||
|
Bates Numbering
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-400 mb-6" data-i18n="tools:batesNumbering.subtitle">
|
||||||
|
Add sequential Bates numbers across one or more PDF files.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="drop-zone"
|
||||||
|
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<i
|
||||||
|
data-lucide="upload-cloud"
|
||||||
|
class="w-10 h-10 mb-3 text-gray-400"
|
||||||
|
></i>
|
||||||
|
<p class="mb-2 text-sm text-gray-400">
|
||||||
|
<span class="font-semibold" data-i18n="upload.clickToSelect"
|
||||||
|
>Click to select files</span
|
||||||
|
>
|
||||||
|
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">PDF files (multiple supported)</p>
|
||||||
|
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
||||||
|
Your files never leave your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
multiple
|
||||||
|
accept="application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
id="add-more-btn"
|
||||||
|
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
<span>Add More Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="clear-files-btn"
|
||||||
|
class="btn bg-gray-700 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
<span>Clear All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-list" class="mt-4 space-y-2"></div>
|
||||||
|
|
||||||
|
<div id="options-panel" class="hidden mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="style-preset"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Select Style</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="style-preset"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<optgroup label="Exhibit + Case + Bates + Page">
|
||||||
|
<option value="full-6">Exhibit 1 Case XYZ 000001 Page 1</option>
|
||||||
|
<option value="full-5">Exhibit 1 Case XYZ 00001 Page 1</option>
|
||||||
|
<option value="full-4">Exhibit 1 Case XYZ 0001 Page 1</option>
|
||||||
|
<option value="full-3">Exhibit 1 Case XYZ 001 Page 1</option>
|
||||||
|
<option value="full-0">Exhibit 1 Case XYZ 1 Page 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Exhibit + Case + Bates">
|
||||||
|
<option value="no-page-6">Exhibit 1 Case XYZ 000001</option>
|
||||||
|
<option value="no-page-5">Exhibit 1 Case XYZ 00001</option>
|
||||||
|
<option value="no-page-4">Exhibit 1 Case XYZ 0001</option>
|
||||||
|
<option value="no-page-3">Exhibit 1 Case XYZ 001</option>
|
||||||
|
<option value="no-page-0">Exhibit 1 Case XYZ 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Case + Bates">
|
||||||
|
<option value="case-6">Case XYZ 000001</option>
|
||||||
|
<option value="case-5">Case XYZ 00001</option>
|
||||||
|
<option value="case-4">Case XYZ 0001</option>
|
||||||
|
<option value="case-3">Case XYZ 001</option>
|
||||||
|
<option value="case-0">Case XYZ 1</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Bates Number Only">
|
||||||
|
<option value="bates-6" selected>000001, 000002, 000003</option>
|
||||||
|
<option value="bates-5">00001, 00002, 00003</option>
|
||||||
|
<option value="bates-4">0001, 0002, 0003</option>
|
||||||
|
<option value="bates-3">001, 002, 003</option>
|
||||||
|
<option value="bates-0">1, 2, 3</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="custom">Custom style (other)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="bates-template"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Customize Style</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="bates-template"
|
||||||
|
value="[BATES]"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500 font-mono"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Placeholders: [BATES] [PAGE] [FILE] [FILENAME]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="preview-box"
|
||||||
|
class="bg-gray-900 border border-gray-700 rounded-lg p-4 text-sm text-gray-300 font-mono"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">Preview:</div>
|
||||||
|
<div id="preview-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div id="padding-group" class="hidden">
|
||||||
|
<label
|
||||||
|
for="bates-padding"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Bates Padding</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="bates-padding"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="6" selected>6 digits (000001)</option>
|
||||||
|
<option value="5">5 digits (00001)</option>
|
||||||
|
<option value="4">4 digits (0001)</option>
|
||||||
|
<option value="3">3 digits (001)</option>
|
||||||
|
<option value="0">No padding (1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="position"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Position</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="position"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="top-center">Header Center</option>
|
||||||
|
<option value="top-left">Header Left</option>
|
||||||
|
<option value="top-right">Header Right</option>
|
||||||
|
<option value="bottom-center" selected>Footer Center</option>
|
||||||
|
<option value="bottom-left">Footer Left</option>
|
||||||
|
<option value="bottom-right">Footer Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="bates-start"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Bates Starts From</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="bates-start"
|
||||||
|
value="1"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="file-start"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>File Counter Starts From</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="file-start"
|
||||||
|
value="1"
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-family"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Font</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="font-family"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="Helvetica">Helvetica</option>
|
||||||
|
<option value="TimesRoman">Times New Roman</option>
|
||||||
|
<option value="Courier">Courier</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-size"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Font Size</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="font-size"
|
||||||
|
value="10"
|
||||||
|
min="6"
|
||||||
|
max="72"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="text-color"
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
|
>Color</label
|
||||||
|
>
|
||||||
|
<input type="color" id="text-color" value="#000000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
|
Apply Bates Numbers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="loader-modal"
|
||||||
|
class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="solid-spinner"></div>
|
||||||
|
<p
|
||||||
|
id="loader-text"
|
||||||
|
class="text-white text-lg font-medium"
|
||||||
|
data-i18n="loader.processing"
|
||||||
|
>
|
||||||
|
Processing...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="alert-modal"
|
||||||
|
class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">
|
||||||
|
Alert
|
||||||
|
</h3>
|
||||||
|
<p id="alert-message" class="text-gray-300 mb-6"></p>
|
||||||
|
<button
|
||||||
|
id="alert-ok"
|
||||||
|
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
|
||||||
|
data-i18n="howItWorks.title"
|
||||||
|
>
|
||||||
|
How It Works
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Upload Files</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Upload one or more PDF files. Drag to reorder for multi-file
|
||||||
|
sequencing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">
|
||||||
|
Configure Style
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Choose a preset or create a custom template with placeholders like
|
||||||
|
[BATES], [PAGE], [FILE].
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||||
|
<p class="text-gray-400">
|
||||||
|
Get your bates-stamped PDFs instantly. Multiple files are
|
||||||
|
delivered as a ZIP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||||
|
data-i18n="relatedTools.title"
|
||||||
|
>
|
||||||
|
Related PDF Tools
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
<a
|
||||||
|
href="page-numbers.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Page Numbers</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Add page numbers to PDFs</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="header-footer.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Header & Footer</h3>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Add text to page headers and footers
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="add-watermark.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Add Watermark</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Watermark your PDF documents</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="merge-pdf.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Combine multiple PDFs</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="organize-pdf.html"
|
||||||
|
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Organize PDF</h3>
|
||||||
|
<p class="text-gray-400 text-sm">Reorder and organize pages</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<h2
|
||||||
|
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
|
||||||
|
data-i18n="faq.sectionTitle"
|
||||||
|
>
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
What is Bates numbering?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
Bates numbering is a method of indexing legal documents for easy
|
||||||
|
identification and retrieval. Each page receives a unique sequential
|
||||||
|
number, often combined with a prefix or suffix.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
Can I number multiple files at once?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
Yes! Upload multiple PDFs and the bates numbers will continue
|
||||||
|
sequentially across all files. Drag files to reorder them.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer font-semibold text-white flex items-center justify-between"
|
||||||
|
>
|
||||||
|
Are my files secure?
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5"></i>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 text-gray-400">
|
||||||
|
All processing happens entirely in your browser. Your files never
|
||||||
|
leave your device.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{> footer }}
|
||||||
|
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
|
||||||
|
<script type="module" src="/src/js/utils/full-width.ts"></script>
|
||||||
|
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
|
||||||
|
<script type="module" src="/src/version.ts"></script>
|
||||||
|
<script type="module" src="/src/js/logic/bates-numbering-page.ts"></script>
|
||||||
|
<script type="module" src="/src/js/mobileMenu.ts"></script>
|
||||||
|
<script type="module" src="/src/js/main.ts"></script>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Bates Numbering - BentoPDF",
|
||||||
|
"applicationCategory": "PDF Tool",
|
||||||
|
"operatingSystem": "Any - Web Browser",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -217,12 +217,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="background-color" value="#FFFFFF" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#FFFFFF"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -261,12 +256,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Separator Color</label
|
>Separator Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="separator-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="separator-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -477,12 +477,7 @@
|
|||||||
class="block text-sm font-medium text-gray-300 mb-2"
|
class="block text-sm font-medium text-gray-300 mb-2"
|
||||||
>Text Color</label
|
>Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="sig-text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="sig-text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -296,12 +296,7 @@
|
|||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input
|
<input type="color" id="background-color" value="#ffffff" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#ffffff"
|
|
||||||
class="h-10 w-20 rounded border border-gray-600 bg-gray-700 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-400"
|
<span class="text-sm text-gray-400"
|
||||||
>Color for margins/padding</span
|
>Color for margins/padding</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -202,12 +202,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Font Color</label
|
>Font Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="font-color" value="#000000" class="" />
|
||||||
type="color"
|
|
||||||
id="font-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,12 +253,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Border Color</label
|
>Border Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="border-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="border-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -219,12 +219,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Color</label
|
>Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -169,12 +169,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>New Text Color</label
|
>New Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color-input" value="#0000FF" />
|
||||||
type="color"
|
|
||||||
id="text-color-input"
|
|
||||||
value="#0000FF"
|
|
||||||
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
Change Text Color
|
Change Text Color
|
||||||
|
|||||||
@@ -250,12 +250,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Text Color</label
|
>Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -144,10 +144,10 @@
|
|||||||
<span class="text-xs text-gray-500">Recommended:</span>
|
<span class="text-xs text-gray-500">Recommended:</span>
|
||||||
<code
|
<code
|
||||||
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
||||||
>https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/</code
|
>https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/</code
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/"
|
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/"
|
||||||
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
|
|||||||
+7
-1
@@ -508,6 +508,12 @@
|
|||||||
category: 'editor',
|
category: 'editor',
|
||||||
icon: 'list-numbers',
|
icon: 'list-numbers',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'bates-numbering',
|
||||||
|
title: 'Bates Numbering',
|
||||||
|
category: 'editor',
|
||||||
|
icon: 'hash',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'background-color',
|
name: 'background-color',
|
||||||
title: 'Background Color',
|
title: 'Background Color',
|
||||||
@@ -821,7 +827,7 @@
|
|||||||
grid.innerHTML = filtered
|
grid.innerHTML = filtered
|
||||||
.map(
|
.map(
|
||||||
(tool) => `
|
(tool) => `
|
||||||
<a href="/src/pages/${tool.name}.html" class="tool-card">
|
<a href="/${tool.name}.html" class="tool-card">
|
||||||
<i class="ph ph-${tool.icon} text-4xl text-indigo-400 mb-2"></i>
|
<i class="ph ph-${tool.icon} text-4xl text-indigo-400 mb-2"></i>
|
||||||
<h3 class="font-semibold text-white text-sm">${tool.title}</h3>
|
<h3 class="font-semibold text-white text-sm">${tool.title}</h3>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+13
-8
@@ -14,19 +14,20 @@ import type { OutputBundle } from 'rollup';
|
|||||||
|
|
||||||
const SUPPORTED_LANGUAGES = [
|
const SUPPORTED_LANGUAGES = [
|
||||||
'en',
|
'en',
|
||||||
|
'ar',
|
||||||
'be',
|
'be',
|
||||||
|
'da',
|
||||||
'de',
|
'de',
|
||||||
'es',
|
'es',
|
||||||
|
'fr',
|
||||||
|
'id',
|
||||||
|
'it',
|
||||||
|
'nl',
|
||||||
|
'pt',
|
||||||
|
'tr',
|
||||||
|
'vi',
|
||||||
'zh',
|
'zh',
|
||||||
'zh-TW',
|
'zh-TW',
|
||||||
'vi',
|
|
||||||
'it',
|
|
||||||
'id',
|
|
||||||
'tr',
|
|
||||||
'fr',
|
|
||||||
'pt',
|
|
||||||
'nl',
|
|
||||||
'da',
|
|
||||||
] as const;
|
] as const;
|
||||||
const LANG_REGEX = new RegExp(
|
const LANG_REGEX = new RegExp(
|
||||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`
|
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`
|
||||||
@@ -559,6 +560,10 @@ export default defineConfig(() => {
|
|||||||
),
|
),
|
||||||
'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'),
|
'deskew-pdf': resolve(__dirname, 'src/pages/deskew-pdf.html'),
|
||||||
'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'),
|
'wasm-settings': resolve(__dirname, 'src/pages/wasm-settings.html'),
|
||||||
|
'bates-numbering': resolve(
|
||||||
|
__dirname,
|
||||||
|
'src/pages/bates-numbering.html'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user