Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
126b4dc1b2 | ||
|
|
9be7f9637e | ||
|
|
4cf106750c | ||
|
|
883b167e55 | ||
|
|
d12214c689 | ||
|
|
ef76de8320 | ||
|
|
23f97029a2 | ||
|
|
56efcba5d0 | ||
|
|
bc3bcb9c25 | ||
|
|
166f6d2d98 | ||
|
|
6441dc1d6f | ||
|
|
25bedf064f | ||
|
|
13eff6b333 | ||
|
|
46a4a978fc |
@@ -3,7 +3,15 @@ name: Auto PR V2 Deployment
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: "PR number to deploy"
|
||||
required: true
|
||||
allow_fork:
|
||||
description: "Allow deploying fork PR?"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,102 +20,93 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
if: github.event.action != 'closed'
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
|
||||
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
allow_fork: ${{ steps.decide.outputs.allow_fork }}
|
||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||
pr_repository: ${{ steps.resolve.outputs.repository }}
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check deployment conditions
|
||||
id: check-conditions
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
let prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(process.env.INPUT_PR, 10)
|
||||
: context.payload.number;
|
||||
|
||||
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('repository', pr.head.repo.full_name);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.fork));
|
||||
core.setOutput('base_ref', pr.base.ref);
|
||||
core.setOutput('author', pr.user.login);
|
||||
core.setOutput('state', pr.state);
|
||||
|
||||
- name: Decide deploy
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
STATE: ${{ steps.resolve.outputs.state }}
|
||||
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
|
||||
# nur bei workflow_dispatch gesetzt:
|
||||
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
|
||||
# für Auto-PR-Logik:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
|
||||
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
|
||||
run: |
|
||||
echo "PR Title: $PR_TITLE"
|
||||
echo "PR Author: $PR_AUTHOR"
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# Define authorized users
|
||||
authorized_users=(
|
||||
"Frooodle"
|
||||
"sf298"
|
||||
"Ludy87"
|
||||
"LaserKaspar"
|
||||
"sbplat"
|
||||
"reecebrowne"
|
||||
"DarioGii"
|
||||
"ConnorYoh"
|
||||
"EthanHealy01"
|
||||
"jbrunton96"
|
||||
)
|
||||
|
||||
# Check if author is in the authorized list
|
||||
is_authorized=false
|
||||
for user in "${authorized_users[@]}"; do
|
||||
if [[ "$PR_AUTHOR" == "$user" ]]; then
|
||||
is_authorized=true
|
||||
break
|
||||
set -e
|
||||
# Standard: nichts deployen
|
||||
should=false
|
||||
allow_fork="$(echo "${ALLOW_FORK_INPUT:-false}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
if [ "$STATE" != "open" ]; then
|
||||
echo "PR not open -> skip"
|
||||
else
|
||||
if [ "$IS_FORK" = "true" ] && [ "$allow_fork" != "true" ]; then
|
||||
echo "Fork PR and allow_fork=false -> skip"
|
||||
else
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If PR is targeting V2 and user is authorized, deploy unconditionally
|
||||
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
|
||||
echo "✅ Deployment forced: PR targets V2 and author is authorized."
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, continue with original keyword checks
|
||||
has_v2_keyword=false
|
||||
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
|
||||
has_v2_keyword=true
|
||||
fi
|
||||
|
||||
has_branch_keyword=false
|
||||
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
|
||||
has_branch_keyword=true
|
||||
fi
|
||||
|
||||
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
|
||||
echo "✅ Deployment conditions met"
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "❌ Deployment conditions not met"
|
||||
echo " - Authorized user: $is_authorized"
|
||||
echo " - Has V2 keyword in title: $has_v2_keyword"
|
||||
echo " - Has V2/React keyword in branch: $has_branch_keyword"
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
else
|
||||
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
|
||||
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
|
||||
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Get PR repository and ref
|
||||
id: get-pr-info
|
||||
if: steps.check-conditions.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
# For forks, use the full repository name, for internal PRs use the current repo
|
||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
||||
repository="${{ github.event.pull_request.head.repo.full_name }}"
|
||||
else
|
||||
repository="${{ github.repository }}"
|
||||
fi
|
||||
|
||||
echo "repository=$repository" >> $GITHUB_OUTPUT
|
||||
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
|
||||
echo "should_deploy=$should" >> $GITHUB_OUTPUT
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true'
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
@@ -119,7 +118,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -154,13 +153,13 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
|
||||
const v2Comments = comments.filter(comment =>
|
||||
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
|
||||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
|
||||
comment.body.includes('❌ **V2 Auto-deployment failed**')
|
||||
);
|
||||
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
console.log(`Deleting old V2 comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
@@ -177,7 +176,6 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
});
|
||||
|
||||
return newComment.id;
|
||||
|
||||
- name: Checkout PR
|
||||
@@ -188,7 +186,6 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
@@ -212,7 +209,7 @@ jobs:
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
@@ -321,7 +318,7 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
|
||||
stirling-pdf-v2-frontend:
|
||||
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
@@ -354,7 +351,7 @@ jobs:
|
||||
|
||||
# Clean up unused Docker resources to save space
|
||||
docker system prune -af --volumes || true
|
||||
|
||||
|
||||
# Clean up old backend/frontend images (older than 2 weeks)
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
@@ -492,7 +489,7 @@ jobs:
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
|
||||
|
||||
# Note: We don't remove the commit-based images since they can be reused across PRs
|
||||
# Only remove PR-specific containers and directories
|
||||
ENDSSH
|
||||
@@ -501,5 +498,4 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
continue-on-error: true
|
||||
|
||||
continue-on-error: true
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Clean and install frontend dependencies
|
||||
run: cd frontend && rm -rf node_modules package-lock.json && npm install
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
|
||||
@@ -32,18 +32,29 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
@@ -53,12 +64,45 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
@@ -69,7 +113,7 @@ jobs:
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -101,8 +145,28 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
@@ -18,7 +18,9 @@ public class PDFFile {
|
||||
@Schema(description = "The input PDF file", format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(description = "File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
@Schema(
|
||||
description =
|
||||
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
private String fileId;
|
||||
|
||||
@AssertTrue(message = "Either fileInput or fileId must be provided")
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
public interface ServerCertificateServiceInterface {
|
||||
|
||||
boolean isEnabled();
|
||||
|
||||
boolean hasServerCertificate();
|
||||
|
||||
void initializeServerCertificate();
|
||||
|
||||
KeyStore getServerKeyStore() throws Exception;
|
||||
|
||||
String getServerCertificatePassword();
|
||||
|
||||
X509Certificate getServerCertificate() throws Exception;
|
||||
|
||||
byte[] getServerCertificatePublicKey() throws Exception;
|
||||
|
||||
void uploadServerCertificate(InputStream p12Stream, String password) throws Exception;
|
||||
|
||||
void deleteServerCertificate() throws Exception;
|
||||
|
||||
ServerCertificateInfo getServerCertificateInfo() throws Exception;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
class ServerCertificateInfo {
|
||||
private final boolean exists;
|
||||
private final String subject;
|
||||
private final String issuer;
|
||||
private final Date validFrom;
|
||||
private final Date validTo;
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
@@ -366,6 +367,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
addEndpointToGroup("Java", "booklet-imposition");
|
||||
addEndpointToGroup("Java", "scale-pages");
|
||||
addEndpointToGroup("Java", "add-page-numbers");
|
||||
addEndpointToGroup("Java", "auto-rename");
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class BookletImpositionController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page layout. "
|
||||
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
|
||||
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
int pagesPerSheet = request.getPagesPerSheet();
|
||||
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
|
||||
String spineLocation =
|
||||
request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT";
|
||||
boolean addGutter = Boolean.TRUE.equals(request.getAddGutter());
|
||||
float gutterSize = request.getGutterSize();
|
||||
boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided());
|
||||
String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH";
|
||||
boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge());
|
||||
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
return (n + 3) / 4 * 4;
|
||||
}
|
||||
|
||||
private static class Side {
|
||||
final int left, right;
|
||||
final boolean isBack;
|
||||
|
||||
Side(int left, int right, boolean isBack) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.isBack = isBack;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Side> saddleStitchSides(
|
||||
int totalPagesOriginal,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge) {
|
||||
int N = padToMultipleOf4(totalPagesOriginal);
|
||||
List<Side> out = new ArrayList<>();
|
||||
int sheets = N / 4;
|
||||
|
||||
for (int s = 0; s < sheets; s++) {
|
||||
int a = N - 1 - (s * 2); // left, front
|
||||
int b = (s * 2); // right, front
|
||||
int c = (s * 2) + 1; // left, back
|
||||
int d = N - 2 - (s * 2); // right, back
|
||||
|
||||
// clamp to -1 (blank) if >= totalPagesOriginal
|
||||
a = (a < totalPagesOriginal) ? a : -1;
|
||||
b = (b < totalPagesOriginal) ? b : -1;
|
||||
c = (c < totalPagesOriginal) ? c : -1;
|
||||
d = (d < totalPagesOriginal) ? d : -1;
|
||||
|
||||
// Handle duplex pass selection
|
||||
boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass);
|
||||
boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass);
|
||||
|
||||
if (includeFront) {
|
||||
out.add(new Side(a, b, false)); // front side
|
||||
}
|
||||
|
||||
if (includeBack) {
|
||||
// For short-edge duplex, swap back-side left/right
|
||||
// Note: flipOnShortEdge is ignored in manual duplex mode since users physically
|
||||
// flip the stack
|
||||
if (doubleSided && flipOnShortEdge) {
|
||||
out.add(new Side(d, c, true)); // swapped back side (automatic duplex only)
|
||||
} else {
|
||||
out.add(new Side(c, d, true)); // normal back side
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private PDDocument createSaddleBooklet(
|
||||
PDDocument src,
|
||||
int totalPages,
|
||||
boolean addBorder,
|
||||
String spineLocation,
|
||||
boolean addGutter,
|
||||
float gutterSize,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge)
|
||||
throws IOException {
|
||||
|
||||
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
|
||||
|
||||
// Derive paper size from source document's first page CropBox
|
||||
PDRectangle srcBox = src.getPage(0).getCropBox();
|
||||
PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight());
|
||||
// Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait)
|
||||
PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth());
|
||||
|
||||
// Validate and clamp gutter size
|
||||
if (gutterSize < 0) gutterSize = 0;
|
||||
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
|
||||
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
|
||||
for (Side side : sides) {
|
||||
PDPage out = new PDPage(pageSize);
|
||||
dst.addPage(out);
|
||||
|
||||
float cellW = pageSize.getWidth() / 2f;
|
||||
float cellH = pageSize.getHeight();
|
||||
|
||||
// For RIGHT spine (RTL), swap left/right placements
|
||||
boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation);
|
||||
int leftCol = rtl ? 1 : 0;
|
||||
int rightCol = rtl ? 0 : 1;
|
||||
|
||||
// Apply gutter margins with centered gap option
|
||||
float g = addGutter ? gutterSize : 0f;
|
||||
float leftCellX = leftCol * cellW + (g / 2f);
|
||||
float rightCellX = rightCol * cellW - (g / 2f);
|
||||
float leftCellW = cellW - (g / 2f);
|
||||
float rightCellW = cellW - (g / 2f);
|
||||
|
||||
// Create LayerUtility once per page for efficiency
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
|
||||
if (addBorder) {
|
||||
cs.setLineWidth(1.5f);
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
// draw left cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.left,
|
||||
leftCellX,
|
||||
0f,
|
||||
leftCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
// draw right cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.right,
|
||||
rightCellX,
|
||||
0f,
|
||||
rightCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
private void drawCell(
|
||||
PDDocument src,
|
||||
PDDocument dst,
|
||||
PDPageContentStream cs,
|
||||
LayerUtility layerUtility,
|
||||
int pageIndex,
|
||||
float cellX,
|
||||
float cellY,
|
||||
float cellW,
|
||||
float cellH,
|
||||
boolean addBorder)
|
||||
throws IOException {
|
||||
|
||||
if (pageIndex < 0) {
|
||||
// Draw border for blank cell if needed
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
PDPage srcPage = src.getPage(pageIndex);
|
||||
PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox
|
||||
int rot = (srcPage.getRotation() + 360) % 360;
|
||||
|
||||
// Calculate scale factors, accounting for rotation
|
||||
float sx = cellW / r.getWidth();
|
||||
float sy = cellH / r.getHeight();
|
||||
float s = Math.min(sx, sy);
|
||||
|
||||
// If rotated 90/270 degrees, swap dimensions for fitting
|
||||
if (rot == 90 || rot == 270) {
|
||||
sx = cellW / r.getHeight();
|
||||
sy = cellH / r.getWidth();
|
||||
s = Math.min(sx, sy);
|
||||
}
|
||||
|
||||
float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s;
|
||||
float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s;
|
||||
|
||||
// Center in cell, accounting for CropBox offset
|
||||
float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s;
|
||||
float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s;
|
||||
|
||||
cs.saveGraphicsState();
|
||||
cs.transform(Matrix.getTranslateInstance(tx, ty));
|
||||
cs.transform(Matrix.getScaleInstance(s, s));
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
break;
|
||||
case 180:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
break;
|
||||
case 270:
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
break;
|
||||
default:
|
||||
// 0°: no-op
|
||||
}
|
||||
|
||||
// Reuse LayerUtility passed from caller
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex);
|
||||
cs.drawForm(form);
|
||||
|
||||
cs.restoreGraphicsState();
|
||||
|
||||
// Draw border on top of form to ensure visibility
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -10,21 +10,32 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
@@ -58,6 +69,11 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Server certificate settings
|
||||
configData.put(
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
|
||||
+45
-17
@@ -237,34 +237,54 @@ public class StampController {
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
|
||||
y =
|
||||
calculatePositionY(
|
||||
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
|
||||
}
|
||||
// Split the stampText into multiple lines
|
||||
String[] lines = stampText.split("\\\\n");
|
||||
String[] lines = stampText.split("\\r?\\n|\\\\n");
|
||||
|
||||
// Calculate dynamic line height based on font ascent and descent
|
||||
float ascent = font.getFontDescriptor().getAscent();
|
||||
float descent = font.getFontDescriptor().getDescent();
|
||||
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
||||
|
||||
// Compute a single pivot for the entire text block to avoid line-by-line wobble
|
||||
float capHeight = calculateTextCapHeight(font, fontSize);
|
||||
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
|
||||
float maxWidth = 0f;
|
||||
for (String ln : lines) {
|
||||
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
|
||||
}
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
// Base positioning on the true multi-line block size
|
||||
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
|
||||
y = calculatePositionY(pageSize, position, blockHeight, margin);
|
||||
}
|
||||
|
||||
// After anchoring the block, draw from the top line downward
|
||||
float adjustedX = x;
|
||||
float adjustedY = y;
|
||||
float pivotX = adjustedX + maxWidth / 2f;
|
||||
float pivotY = adjustedY + blockHeight / 2f;
|
||||
|
||||
// Apply rotation about the block center at the graphics state level
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
|
||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
|
||||
|
||||
contentStream.beginText();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
// Set the text matrix for each line with rotation
|
||||
contentStream.setTextMatrix(
|
||||
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
|
||||
// Start from top line: yTop = adjustedY + blockHeight - capHeight
|
||||
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
|
||||
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
|
||||
contentStream.showText(line);
|
||||
}
|
||||
contentStream.endText();
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
private void addImageStamp(
|
||||
@@ -308,9 +328,17 @@ public class StampController {
|
||||
}
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
// Rotate and scale about the center of the image
|
||||
float centerX = x + (desiredPhysicalWidth / 2f);
|
||||
float centerY = y + (desiredPhysicalHeight / 2f);
|
||||
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
|
||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
|
||||
contentStream.drawImage(
|
||||
xobject,
|
||||
-desiredPhysicalWidth / 2f,
|
||||
-desiredPhysicalHeight / 2f,
|
||||
desiredPhysicalWidth,
|
||||
desiredPhysicalHeight);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
|
||||
+31
-3
@@ -53,6 +53,7 @@ import org.bouncycastle.operator.InputDecryptorProvider;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
|
||||
import org.bouncycastle.pkcs.PKCSException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -68,13 +69,13 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -82,7 +83,6 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CertSignController {
|
||||
|
||||
static {
|
||||
@@ -102,6 +102,15 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
private static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -177,6 +186,7 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -195,6 +205,24 @@ public class CertSignController {
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
case "SERVER":
|
||||
if (serverCertificateService == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotAvailable",
|
||||
"Server certificate service is not available in this edition");
|
||||
}
|
||||
if (!serverCertificateService.isEnabled()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateDisabled",
|
||||
"Server certificate feature is disabled");
|
||||
}
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotFound", "No server certificate configured");
|
||||
}
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -202,7 +230,7 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray());
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
sign(
|
||||
pdfDocumentFactory,
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BookletImpositionRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The number of pages per side for booklet printing (always 2 for proper booklet).",
|
||||
type = "number",
|
||||
defaultValue = "2",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"2"})
|
||||
private int pagesPerSheet = 2;
|
||||
|
||||
@Schema(description = "Boolean for if you wish to add border around the pages")
|
||||
private Boolean addBorder = false;
|
||||
|
||||
@Schema(
|
||||
description = "The spine location for the booklet.",
|
||||
type = "string",
|
||||
defaultValue = "LEFT",
|
||||
allowableValues = {"LEFT", "RIGHT"})
|
||||
private String spineLocation = "LEFT";
|
||||
|
||||
@Schema(description = "Add gutter margin (inner margin for binding)")
|
||||
private Boolean addGutter = false;
|
||||
|
||||
@Schema(
|
||||
description = "Gutter margin size in points (used when addGutter is true)",
|
||||
type = "number",
|
||||
defaultValue = "12")
|
||||
private float gutterSize = 12f;
|
||||
|
||||
@Schema(description = "Generate both front and back sides (double-sided printing)")
|
||||
private Boolean doubleSided = true;
|
||||
|
||||
@Schema(
|
||||
description = "For manual duplex: which pass to generate",
|
||||
type = "string",
|
||||
defaultValue = "BOTH",
|
||||
allowableValues = {"BOTH", "FIRST", "SECOND"})
|
||||
private String duplexPass = "BOTH";
|
||||
|
||||
@Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)")
|
||||
private Boolean flipOnShortEdge = false;
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "JKS"},
|
||||
allowableValues = {"PEM", "PKCS12", "JKS", "SERVER"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ system:
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
validity: 365 # Certificate validity in days
|
||||
regenerateOnStartup: false # Generate new certificate on each startup
|
||||
html:
|
||||
urlSecurity:
|
||||
enabled: true # Enable URL security restrictions for HTML processing
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.configuration;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ServerCertificateInitializer {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.initializeServerCertificate();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize server certificate", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
@Slf4j
|
||||
@Tag(
|
||||
name = "Admin - Server Certificate",
|
||||
description = "Admin APIs for server certificate management")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class ServerCertificateController {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@GetMapping("/info")
|
||||
@Operation(
|
||||
summary = "Get server certificate information",
|
||||
description = "Returns information about the current server certificate")
|
||||
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
|
||||
getServerCertificateInfo() {
|
||||
try {
|
||||
ServerCertificateServiceInterface.ServerCertificateInfo info =
|
||||
serverCertificateService.getServerCertificateInfo();
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate info", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(
|
||||
summary = "Upload server certificate",
|
||||
description =
|
||||
"Upload a new PKCS12 certificate file to be used as the server certificate")
|
||||
public ResponseEntity<String> uploadServerCertificate(
|
||||
@Parameter(description = "PKCS12 certificate file", required = true)
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(description = "Certificate password", required = true)
|
||||
@RequestParam("password")
|
||||
String password) {
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
|
||||
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Only PKCS12 (.p12 or .pfx) files are supported");
|
||||
}
|
||||
|
||||
try {
|
||||
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
|
||||
return ResponseEntity.ok("Server certificate uploaded successfully");
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid certificate upload: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body("Invalid certificate or password.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Delete server certificate",
|
||||
description = "Delete the current server certificate")
|
||||
public ResponseEntity<String> deleteServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate();
|
||||
return ResponseEntity.ok("Server certificate deleted successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
@Operation(
|
||||
summary = "Generate new server certificate",
|
||||
description = "Generate a new self-signed server certificate")
|
||||
public ResponseEntity<String> generateServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate(); // Remove existing if any
|
||||
serverCertificateService.initializeServerCertificate(); // Generate new
|
||||
return ResponseEntity.ok("New server certificate generated successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Failed to generate server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/certificate")
|
||||
@Operation(
|
||||
summary = "Download server certificate",
|
||||
description = "Download the server certificate in DER format for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificate() {
|
||||
try {
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server-cert.cer\"")
|
||||
.contentType(MediaType.valueOf("application/pkix-cert"))
|
||||
.body(certificate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/enabled")
|
||||
@Operation(
|
||||
summary = "Check if server certificate feature is enabled",
|
||||
description =
|
||||
"Returns whether the server certificate feature is enabled in configuration")
|
||||
public ResponseEntity<Boolean> isServerCertificateEnabled() {
|
||||
return ResponseEntity.ok(serverCertificateService.isEnabled());
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ServerCertificateService implements ServerCertificateServiceInterface {
|
||||
|
||||
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
|
||||
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
|
||||
|
||||
@Value("${system.serverCertificate.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
|
||||
private String organizationName;
|
||||
|
||||
@Value("${system.serverCertificate.validity:365}")
|
||||
private int validityDays;
|
||||
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
private Path getKeystorePath() {
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
return Files.exists(getKeystorePath());
|
||||
}
|
||||
|
||||
public void initializeServerCertificate() {
|
||||
if (!enabled) {
|
||||
log.debug("Server certificate feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
try {
|
||||
generateServerCertificate();
|
||||
log.info("Generated new server certificate at: {}", keystorePath);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
}
|
||||
} else {
|
||||
log.info("Server certificate already exists at: {}", keystorePath);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) {
|
||||
keyStore.load(fis, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
return keyStore;
|
||||
}
|
||||
|
||||
public String getServerCertificatePassword() {
|
||||
return DEFAULT_PASSWORD;
|
||||
}
|
||||
|
||||
public X509Certificate getServerCertificate() throws Exception {
|
||||
KeyStore keyStore = getServerKeyStore();
|
||||
return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS);
|
||||
}
|
||||
|
||||
public byte[] getServerCertificatePublicKey() throws Exception {
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return cert.getEncoded();
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
|
||||
// Find the first private key entry
|
||||
String alias = null;
|
||||
for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) {
|
||||
if (uploadedKeyStore.isKeyEntry(a)) {
|
||||
alias = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alias == null) {
|
||||
throw new IllegalArgumentException("No private key found in uploaded certificate");
|
||||
}
|
||||
|
||||
// Create new keystore with our standard alias and password
|
||||
KeyStore newKeyStore = KeyStore.getInstance("PKCS12");
|
||||
newKeyStore.load(null, null);
|
||||
|
||||
PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray());
|
||||
Certificate[] chain = uploadedKeyStore.getCertificateChain(alias);
|
||||
|
||||
newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain);
|
||||
|
||||
// Save to server keystore location
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
log.info("Server certificate updated from uploaded file");
|
||||
}
|
||||
|
||||
public void deleteServerCertificate() throws Exception {
|
||||
Path keystorePath = getKeystorePath();
|
||||
if (Files.exists(keystorePath)) {
|
||||
Files.delete(keystorePath);
|
||||
log.info("Server certificate deleted");
|
||||
}
|
||||
}
|
||||
|
||||
public ServerCertificateInfo getServerCertificateInfo() throws Exception {
|
||||
if (!hasServerCertificate()) {
|
||||
return new ServerCertificateInfo(false, null, null, null, null);
|
||||
}
|
||||
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return new ServerCertificateInfo(
|
||||
true,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
cert.getNotBefore(),
|
||||
cert.getNotAfter());
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
// Certificate details
|
||||
X500Name subject =
|
||||
new X500Name(
|
||||
"CN=" + organizationName + " Server, O=" + organizationName + ", C=US");
|
||||
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Date notBefore = new Date();
|
||||
Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000));
|
||||
|
||||
// Build certificate
|
||||
JcaX509v3CertificateBuilder certBuilder =
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// 1) End-entity certificate, not a CA (critical)
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// 2) Key usage for PDF digital signatures (critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// 3) Extended key usage for document signing (non-critical, widely accepted)
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||
|
||||
// 4) Subject Key Identifier for chain building (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// 5) Authority Key Identifier for self-signed cert (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Sign certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
.setProvider("BC")
|
||||
.build(keyPair.getPrivate());
|
||||
|
||||
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||
|
||||
// Create keystore
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
DEFAULT_PASSWORD.toCharArray(),
|
||||
new Certificate[] {cert});
|
||||
|
||||
// Save keystore
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -90,7 +95,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache other static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
Generated
+3044
-3588
File diff suppressed because it is too large
Load Diff
+76
-59
@@ -5,52 +5,49 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||
"@embedpdf/core": "^1.1.1",
|
||||
"@embedpdf/engines": "^1.1.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.1.1",
|
||||
"@embedpdf/plugin-loader": "^1.1.1",
|
||||
"@embedpdf/plugin-pan": "^1.1.1",
|
||||
"@embedpdf/plugin-render": "^1.1.1",
|
||||
"@embedpdf/plugin-rotate": "^1.1.1",
|
||||
"@embedpdf/plugin-scroll": "^1.1.1",
|
||||
"@embedpdf/plugin-search": "^1.1.1",
|
||||
"@embedpdf/plugin-selection": "^1.1.1",
|
||||
"@embedpdf/plugin-spread": "^1.1.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.1.1",
|
||||
"@embedpdf/plugin-tiling": "^1.1.1",
|
||||
"@embedpdf/plugin-viewport": "^1.1.1",
|
||||
"@embedpdf/plugin-zoom": "^1.1.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@mantine/core": "^8.0.1",
|
||||
"@mantine/dates": "^8.0.1",
|
||||
"@mantine/dropzone": "^8.0.1",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@mantine/core": "8.3.1",
|
||||
"@mantine/dates": "8.3.1",
|
||||
"@mantine/dropzone": "8.3.1",
|
||||
"@mantine/hooks": "8.3.1",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.9.0",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"axios": "^1.12.2",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"web-vitals": "^2.1.4"
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"posthog-js": "^1.268.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"predev": "npm run generate-icons",
|
||||
@@ -70,12 +67,17 @@
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:install": "playwright install"
|
||||
"test:e2e:install": "playwright install",
|
||||
"update:minor": "npm outdated || npm update && npm audit fix && npm test",
|
||||
"update:major": "npx npm-check-updates -u && npm install",
|
||||
"update:interactive": "npx npm-check-updates -i",
|
||||
"update:minor-strict": "npx npm-check-updates -u --target minor && npm install"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -91,26 +93,41 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@iconify-json/material-symbols": "^1.2.33",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^1.0.0"
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"depcheck": {
|
||||
"ignoreMatches": [
|
||||
"@emotion/*",
|
||||
"tailwindcss",
|
||||
"@testing-library/user-event",
|
||||
"@vitest/coverage-v8"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"noFileSelected": "No file selected. Please upload one.",
|
||||
"legal": {
|
||||
"privacy": "Privacy Policy",
|
||||
"iAgreeToThe": "I agree to all of the",
|
||||
"terms": "Terms and Conditions",
|
||||
"accessibility": "Accessibility",
|
||||
"cookie": "Cookie Policy",
|
||||
@@ -427,6 +428,10 @@
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
},
|
||||
"repair": {
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
@@ -443,10 +448,6 @@
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"certSign": {
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
@@ -455,6 +456,10 @@
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
@@ -1179,7 +1184,9 @@
|
||||
},
|
||||
"pageSelection": {
|
||||
"tooltip": {
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"header": {
|
||||
"title": "Page Selection Guide"
|
||||
},
|
||||
"basic": {
|
||||
"title": "Basic Usage",
|
||||
"text": "Select specific pages from your PDF document using simple syntax.",
|
||||
@@ -1213,11 +1220,15 @@
|
||||
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
|
||||
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
|
||||
},
|
||||
"examples": { "title": "Examples" }
|
||||
"examples": {
|
||||
"title": "Examples"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkSelection": {
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"header": {
|
||||
"title": "Page Selection Guide"
|
||||
},
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
|
||||
@@ -1769,23 +1780,124 @@
|
||||
}
|
||||
},
|
||||
"certSign": {
|
||||
"tags": "authenticate,PEM,P12,official,encrypt",
|
||||
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
|
||||
"title": "Certificate Signing",
|
||||
"header": "Sign a PDF with your certificate (Work in progress)",
|
||||
"selectPDF": "Select a PDF File for Signing:",
|
||||
"jksNote": "Note: If your certificate type is not listed below, please convert it to a Java Keystore (.jks) file using the keytool command line tool. Then, choose the .jks file option below.",
|
||||
"selectKey": "Select Your Private Key File (PKCS#8 format, could be .pem or .der):",
|
||||
"selectCert": "Select Your Certificate File (X.509 format, could be .pem or .der):",
|
||||
"selectP12": "Select Your PKCS#12 Keystore File (.p12 or .pfx) (Optional, If provided, it should contain your private key and certificate):",
|
||||
"selectJKS": "Select Your Java Keystore File (.jks or .keystore):",
|
||||
"certType": "Certificate Type",
|
||||
"password": "Enter Your Keystore or Private Key Password (If Any):",
|
||||
"showSig": "Show Signature",
|
||||
"reason": "Reason",
|
||||
"location": "Location",
|
||||
"name": "Name",
|
||||
"showLogo": "Show Logo",
|
||||
"submit": "Sign PDF"
|
||||
"filenamePrefix": "signed",
|
||||
"signMode": {
|
||||
"stepTitle": "Sign Mode",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About PDF Signatures"
|
||||
},
|
||||
"overview": {
|
||||
"title": "How signatures work",
|
||||
"text": "Both modes seal the document (any edits are flagged as tampering) and record who/when/how for auditing. Viewer trust depends on the certificate chain."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Manual - Bring your certificate",
|
||||
"text": "Use your own certificate files for brand-aligned identity. Can display <b>Trusted</b> when your CA/chain is recognised.",
|
||||
"use": "Use for: customer-facing, legal, compliance."
|
||||
},
|
||||
"auto": {
|
||||
"title": "Auto - Zero-setup, instant system seal",
|
||||
"text": "Signs with a server <b>self-signed</b> certificate. Same <b>tamper-evident seal</b> and <b>audit trail</b>; typically shows <b>Unverified</b> in viewers.",
|
||||
"use": "Use when: you need speed and consistent internal identity across reviews and records."
|
||||
},
|
||||
"rule": {
|
||||
"title": "Rule of thumb",
|
||||
"text": "Need recipient <b>Trusted</b> status? <b>Manual</b>. Need a fast, tamper-evident seal and audit trail with no setup? <b>Auto</b>."
|
||||
}
|
||||
}
|
||||
},
|
||||
"certTypeStep": {
|
||||
"stepTitle": "Certificate Format"
|
||||
},
|
||||
"certFiles": {
|
||||
"stepTitle": "Certificate Files"
|
||||
},
|
||||
"appearance": {
|
||||
"stepTitle": "Signature Appearance",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Signature Appearance"
|
||||
},
|
||||
"invisible": {
|
||||
"title": "Invisible Signatures",
|
||||
"text": "The signature is added to the PDF for security but won't be visible when viewing the document. Perfect for legal requirements without changing the document's appearance.",
|
||||
"bullet1": "Provides security without visual changes",
|
||||
"bullet2": "Meets legal requirements for digital signing",
|
||||
"bullet3": "Doesn't affect document layout or design"
|
||||
},
|
||||
"visible": {
|
||||
"title": "Visible Signatures",
|
||||
"text": "Shows a signature block on the PDF with your name, date, and optional details. Useful when you want readers to clearly see the document is signed.",
|
||||
"bullet1": "Shows signer name and date on the document",
|
||||
"bullet2": "Can include reason and location for signing",
|
||||
"bullet3": "Choose which page to place the signature",
|
||||
"bullet4": "Optional logo can be included"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"submit": "Sign PDF",
|
||||
"results": "Signed PDF"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred whilst processing signatures."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Managing Signatures"
|
||||
},
|
||||
"overview": {
|
||||
"title": "What can this tool do?",
|
||||
"text": "This tool lets you check if your PDFs are digitally signed and add new digital signatures. Digital signatures prove who created or approved a document and show if it has been changed since signing.",
|
||||
"bullet1": "Check existing signatures and their validity",
|
||||
"bullet2": "View detailed information about signers and certificates",
|
||||
"bullet3": "Add new digital signatures to secure your documents",
|
||||
"bullet4": "Multiple files supported with easy navigation"
|
||||
},
|
||||
"validation": {
|
||||
"title": "Checking Signatures",
|
||||
"text": "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing.",
|
||||
"bullet1": "Shows if signatures are valid or invalid",
|
||||
"bullet2": "Displays signer information and signing date",
|
||||
"bullet3": "Checks if the document was modified after signing",
|
||||
"bullet4": "Can use custom certificates for verification"
|
||||
},
|
||||
"signing": {
|
||||
"title": "Adding Signatures",
|
||||
"text": "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only.",
|
||||
"bullet1": "Supports PEM, PKCS12, JKS, and server certificate formats",
|
||||
"bullet2": "Option to show or hide signature on the PDF",
|
||||
"bullet3": "Add reason, location, and signer name",
|
||||
"bullet4": "Choose which page to place visible signatures",
|
||||
"bullet5": "Use server certificate for simple 'Sign with Stirling-PDF' option"
|
||||
}
|
||||
},
|
||||
"certType": {
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Certificate Types"
|
||||
},
|
||||
"what": {
|
||||
"title": "What's a certificate?",
|
||||
"text": "It's a secure ID for your signature that proves you signed. Unless you're required to sign via certificate, we recommend using another secure method like Type, Draw, or Upload."
|
||||
},
|
||||
"which": {
|
||||
"title": "Which option should I use?",
|
||||
"text": "Choose the format that matches your certificate file:",
|
||||
"bullet1": "PKCS#12 (.p12 / .pfx) – one combined file (most common)",
|
||||
"bullet2": "PFX (.pfx) – Microsoft's version of PKCS12",
|
||||
"bullet3": "PEM – separate private-key and certificate .pem files",
|
||||
"bullet4": "JKS – Java .jks keystore for dev / CI-CD workflows"
|
||||
},
|
||||
"convert": {
|
||||
"title": "Key not listed?",
|
||||
"text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "authenticate,PEM,P12,official,decrypt",
|
||||
@@ -1813,6 +1925,99 @@
|
||||
"addBorder": "Add Borders",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
"title": "Booklet Imposition",
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"spineLocation": {
|
||||
"label": "Spine Location",
|
||||
"left": "Left (Standard)",
|
||||
"right": "Right (RTL)"
|
||||
},
|
||||
"doubleSided": {
|
||||
"label": "Double-sided printing",
|
||||
"tooltip": "Creates both front and back sides for proper booklet printing"
|
||||
},
|
||||
"manualDuplex": {
|
||||
"title": "Manual Duplex Mode",
|
||||
"instructions": "For printers without automatic duplex. You'll need to run this twice:"
|
||||
},
|
||||
"duplexPass": {
|
||||
"label": "Print Pass",
|
||||
"first": "1st Pass",
|
||||
"second": "2nd Pass",
|
||||
"firstInstructions": "Prints front sides → stack face-down → run again with 2nd Pass",
|
||||
"secondInstructions": "Load printed stack face-down → prints back sides"
|
||||
},
|
||||
"rtlBinding": {
|
||||
"label": "Right-to-left binding",
|
||||
"tooltip": "For Arabic, Hebrew, or other right-to-left languages"
|
||||
},
|
||||
"addBorder": {
|
||||
"label": "Add borders around pages",
|
||||
"tooltip": "Adds borders around each page section to help with cutting and alignment"
|
||||
},
|
||||
"addGutter": {
|
||||
"label": "Add gutter margin",
|
||||
"tooltip": "Adds inner margin space for binding"
|
||||
},
|
||||
"gutterSize": {
|
||||
"label": "Gutter size (points)"
|
||||
},
|
||||
"flipOnShortEdge": {
|
||||
"label": "Flip on short edge (automatic duplex only)",
|
||||
"tooltip": "Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)",
|
||||
"manualNote": "Not needed in manual mode - you flip the stack yourself"
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Advanced Options"
|
||||
},
|
||||
"paperSizeNote": "Paper size is automatically derived from your first page.",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Booklet Creation Guide"
|
||||
},
|
||||
"description": {
|
||||
"title": "What is Booklet Imposition?",
|
||||
"text": "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book."
|
||||
},
|
||||
"example": {
|
||||
"title": "Example: 8-Page Booklet",
|
||||
"text": "Your 8-page document becomes 2 sheets:",
|
||||
"bullet1": "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7",
|
||||
"bullet2": "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5",
|
||||
"bullet3": "When folded & stacked: Reads 1→2→3→4→5→6→7→8"
|
||||
},
|
||||
"printing": {
|
||||
"title": "How to Print & Assemble",
|
||||
"text": "Follow these steps for perfect booklets:",
|
||||
"bullet1": "Print double-sided with 'Flip on long edge'",
|
||||
"bullet2": "Stack sheets in order, fold in half",
|
||||
"bullet3": "Staple or bind along the folded spine",
|
||||
"bullet4": "For short-edge printers: Enable 'Flip on short edge' option"
|
||||
},
|
||||
"manualDuplex": {
|
||||
"title": "Manual Duplex (Single-sided Printers)",
|
||||
"text": "For printers without automatic duplex:",
|
||||
"bullet1": "Turn OFF 'Double-sided printing'",
|
||||
"bullet2": "Select '1st Pass' → Print → Stack face-down",
|
||||
"bullet3": "Select '2nd Pass' → Load stack → Print backs",
|
||||
"bullet4": "Fold and assemble as normal"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Options",
|
||||
"text": "Fine-tune your booklet:",
|
||||
"bullet1": "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages",
|
||||
"bullet2": "Borders: Shows cut lines for trimming",
|
||||
"bullet3": "Gutter Margin: Adds space for binding/stapling",
|
||||
"bullet4": "Short-edge Flip: Only for automatic duplex printers"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
}
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Adjust page-scale",
|
||||
"header": "Adjust page-scale",
|
||||
@@ -2178,6 +2383,7 @@
|
||||
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise",
|
||||
"header": "Stamp PDF",
|
||||
"title": "Stamp PDF",
|
||||
"stampSetup": "Stamp Setup",
|
||||
"stampType": "Stamp Type",
|
||||
"stampText": "Stamp Text",
|
||||
"stampImage": "Stamp Image",
|
||||
@@ -2190,7 +2396,8 @@
|
||||
"overrideY": "Override Y Coordinate",
|
||||
"customMargin": "Custom Margin",
|
||||
"customColor": "Custom Text Colour",
|
||||
"submit": "Submit"
|
||||
"submit": "Submit",
|
||||
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||
},
|
||||
"removeImagePdf": {
|
||||
"tags": "Remove Image,Page operations,Back end,server side"
|
||||
@@ -2269,6 +2476,8 @@
|
||||
"title": "Sign in",
|
||||
"header": "Sign in",
|
||||
"signin": "Sign in",
|
||||
"signInWith": "Sign in with",
|
||||
"signInAnonymously": "Sign Up as a Guest",
|
||||
"rememberme": "Remember me",
|
||||
"invalid": "Invalid username or password.",
|
||||
"locked": "Your account has been locked.",
|
||||
@@ -2287,7 +2496,54 @@
|
||||
"alreadyLoggedIn": "You are already logged in to",
|
||||
"alreadyLoggedIn2": "devices. Please log out of the devices and try again.",
|
||||
"toManySessions": "You have too many active sessions",
|
||||
"logoutMessage": "You have been logged out."
|
||||
"logoutMessage": "You have been logged out.",
|
||||
"youAreLoggedIn": "You are logged in!",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"enterEmail": "Enter your email",
|
||||
"enterPassword": "Enter your password",
|
||||
"loggingIn": "Logging In...",
|
||||
"signingIn": "Signing in...",
|
||||
"login": "Login",
|
||||
"or": "Or",
|
||||
"useMagicLink": "Use magic link instead",
|
||||
"enterEmailForMagicLink": "Enter your email for magic link",
|
||||
"sending": "Sending…",
|
||||
"sendMagicLink": "Send Magic Link",
|
||||
"cancel": "Cancel",
|
||||
"dontHaveAccount": "Don't have an account? Sign up",
|
||||
"home": "Home",
|
||||
"debug": "Debug",
|
||||
"signOut": "Sign Out",
|
||||
"pleaseEnterBoth": "Please enter both email and password",
|
||||
"pleaseEnterEmail": "Please enter your email address",
|
||||
"magicLinkSent": "Magic link sent to {{email}}! Check your email and click the link to sign in.",
|
||||
"passwordResetSent": "Password reset link sent to {{email}}! Check your email and follow the instructions.",
|
||||
"failedToSignIn": "Failed to sign in with {{provider}}: {{message}}",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
"subtitle": "Join Stirling PDF to get started",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"enterName": "Enter your name",
|
||||
"enterEmail": "Enter your email",
|
||||
"enterPassword": "Enter your password",
|
||||
"confirmPasswordPlaceholder": "Confirm password",
|
||||
"or": "or",
|
||||
"creatingAccount": "Creating Account...",
|
||||
"signUp": "Sign Up",
|
||||
"alreadyHaveAccount": "Already have an account? Sign in",
|
||||
"pleaseFillAllFields": "Please fill in all fields",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
|
||||
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF To Single Page",
|
||||
@@ -2345,6 +2601,28 @@
|
||||
"grayscale": {
|
||||
"label": "Apply Grayscale for Compression"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Compress Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
|
||||
},
|
||||
"qualityAdjustment": {
|
||||
"title": "Quality Adjustment",
|
||||
"text": "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity.",
|
||||
"bullet1": "Lower values preserve quality",
|
||||
"bullet2": "Higher values reduce file size"
|
||||
},
|
||||
"grayscale": {
|
||||
"title": "Grayscale",
|
||||
"text": "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while compressing the PDF."
|
||||
},
|
||||
"selectText": {
|
||||
"1": {
|
||||
"_value": "Compression Settings",
|
||||
@@ -2566,20 +2844,14 @@
|
||||
"actualSize": "Actual Size"
|
||||
},
|
||||
"viewer": {
|
||||
"noPdfLoaded": "No PDF loaded. Click to upload a PDF.",
|
||||
"choosePdf": "Choose PDF",
|
||||
"noPagesToDisplay": "No pages to display.",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View",
|
||||
"hideSidebars": "Hide Sidebars",
|
||||
"showSidebars": "Show Sidebars",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"pageNavigation": "Page Navigation",
|
||||
"currentPage": "Current Page",
|
||||
"totalPages": "Total Pages"
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View"
|
||||
},
|
||||
"rightRail": {
|
||||
"closeSelected": "Close Selected Files",
|
||||
@@ -2599,6 +2871,16 @@
|
||||
"rotateRight": "Rotate Right",
|
||||
"toggleSidebar": "Toggle Sidebar"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
"placeholder": "Enter search term..."
|
||||
},
|
||||
"guestBanner": {
|
||||
"title": "You're using Stirling PDF as a guest!",
|
||||
"message": "Create a free account to save your work, access more features, and support the project.",
|
||||
"dismiss": "Dismiss banner",
|
||||
"signUp": "Sign Up Free"
|
||||
},
|
||||
"toolPicker": {
|
||||
"searchPlaceholder": "Search tools...",
|
||||
"noToolsFound": "No tools found",
|
||||
@@ -2964,14 +3246,62 @@
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"viewer": {
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View"
|
||||
}
|
||||
}
|
||||
"common": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"remaining": "remaining",
|
||||
"used": "used",
|
||||
"available": "available",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"config": {
|
||||
"account": {
|
||||
"overview": {
|
||||
"title": "Account Settings",
|
||||
"manageAccountPreferences": "Manage your account preferences",
|
||||
"guestDescription": "You are signed in as a guest. Consider upgrading your account above."
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Upgrade Guest Account",
|
||||
"description": "Link your account to preserve your history and access more features!",
|
||||
"socialLogin": "Upgrade with Social Account",
|
||||
"linkWith": "Link with",
|
||||
"emailPassword": "or enter your email & password",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"password": "Password (optional)",
|
||||
"passwordPlaceholder": "Set a password",
|
||||
"passwordNote": "Leave empty to use email verification only",
|
||||
"upgradeButton": "Upgrade Account"
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
|
||||
"publicKeyAriaLabel": "Public API key",
|
||||
"copyKeyAriaLabel": "Copy API key",
|
||||
"refreshAriaLabel": "Refresh API key",
|
||||
"includedCredits": "Included credits",
|
||||
"purchasedCredits": "Purchased credits",
|
||||
"totalCredits": "Total Credits",
|
||||
"chartAriaLabel": "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
|
||||
"nextReset": "Next Reset",
|
||||
"lastApiUse": "Last API Use",
|
||||
"overlayMessage": "Generate a key to see credits and available credits",
|
||||
"label": "API Key",
|
||||
"guestInfo": "Guest users do not receive API keys. Create an account to get an API key you can use in your applications.",
|
||||
"goToAccount": "Go to Account",
|
||||
"refreshModal": {
|
||||
"title": "Refresh API Keys",
|
||||
"warning": "⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
|
||||
"impact": "Any applications or services currently using these keys will stop working until you update them with the new keys.",
|
||||
"confirmPrompt": "Are you sure you want to continue?",
|
||||
"confirmCta": "Refresh Keys"
|
||||
},
|
||||
"generateError": "We couldn't generate your API key."
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Terms & Conditions",
|
||||
"logOut": "Log out"
|
||||
}
|
||||
@@ -495,6 +495,10 @@
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
@@ -1230,6 +1234,17 @@
|
||||
"addBorder": "Add Borders",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
"title": "Booklet Imposition",
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"files": {
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
}
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust page-scale",
|
||||
@@ -1455,6 +1470,7 @@
|
||||
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize",
|
||||
"header": "Stamp PDF",
|
||||
"title": "Stamp PDF",
|
||||
"stampSetup": "Stamp Setup",
|
||||
"stampType": "Stamp Type",
|
||||
"stampText": "Stamp Text",
|
||||
"stampImage": "Stamp Image",
|
||||
@@ -1467,7 +1483,8 @@
|
||||
"overrideY": "Override Y Coordinate",
|
||||
"customMargin": "Custom Margin",
|
||||
"customColor": "Custom Text Color",
|
||||
"submit": "Submit"
|
||||
"submit": "Submit",
|
||||
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||
},
|
||||
"removeImagePdf": {
|
||||
"tags": "Remove Image,Page operations,Back end,server side"
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
-58353
File diff suppressed because one or more lines are too long
@@ -1,8 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { argv } from 'node:process';
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Generate 3rd party licenses for frontend dependencies
|
||||
@@ -14,59 +23,54 @@ const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
// Ensure the output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_FILE);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('🔍 Generating frontend license report...');
|
||||
|
||||
try {
|
||||
// Install license-checker if not present
|
||||
try {
|
||||
require.resolve('license-checker');
|
||||
} catch {
|
||||
console.log('📦 Installing license-checker...');
|
||||
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
|
||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
|
||||
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Generate license report using license-checker (more reliable)
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON)
|
||||
});
|
||||
|
||||
let licenseData;
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
// Generate license report using pinned license-checker; disable lifecycle scripts
|
||||
if (POSTPROCESS_ONLY) {
|
||||
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
|
||||
console.error('❌ --input file missing or not found');
|
||||
process.exit(1);
|
||||
}
|
||||
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
|
||||
} else {
|
||||
const licenseReport = execSync(
|
||||
// 'npx --yes license-checker@25.0.1 --production --json',
|
||||
'npx --yes license-report --only=prod --output=json',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON),
|
||||
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
|
||||
}
|
||||
);
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseData || typeof licenseData !== 'object') {
|
||||
if (!Array.isArray(licenseData)) {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
|
||||
let name, version;
|
||||
|
||||
// Handle scoped packages like @mantine/core@1.0.0
|
||||
if (key.startsWith('@')) {
|
||||
const parts = key.split('@');
|
||||
name = `@${parts[1]}`;
|
||||
version = parts[2];
|
||||
} else {
|
||||
// Handle regular packages like react@18.0.0
|
||||
const lastAtIndex = key.lastIndexOf('@');
|
||||
name = key.substring(0, lastAtIndex);
|
||||
version = key.substring(lastAtIndex + 1);
|
||||
}
|
||||
|
||||
// Normalize license types for edge cases
|
||||
let licenseType = value.licenses;
|
||||
const licenseArray = licenseData.map(dep => {
|
||||
let licenseType = dep.licenseType;
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
@@ -88,13 +92,17 @@ try {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
version: version || value.version || 'unknown',
|
||||
name: dep.name,
|
||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
|
||||
licenseType: licenseType,
|
||||
repository: value.repository,
|
||||
url: value.url,
|
||||
link: value.licenseUrl
|
||||
repository: dep.link,
|
||||
url: dep.link,
|
||||
link: dep.link
|
||||
};
|
||||
});
|
||||
|
||||
@@ -153,7 +161,7 @@ try {
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
|
||||
fs.writeFileSync(warningsFile, JSON.stringify({
|
||||
writeFileSync(warningsFile, JSON.stringify({
|
||||
warnings: problematicLicenses,
|
||||
generated: new Date().toISOString()
|
||||
}, null, 2));
|
||||
@@ -163,7 +171,7 @@ try {
|
||||
}
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
@@ -274,7 +282,8 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
|
||||
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
|
||||
]);
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { useToolManagement } from '../../hooks/useToolManagement';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
@@ -39,8 +38,8 @@ export default function Workbench() {
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
// Get tool registry from context (instead of direct hook call)
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import FitText from "./FitText";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
@@ -13,15 +14,19 @@ interface ButtonSelectorProps<T> {
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
buttonClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const ButtonSelector = <T extends string>({
|
||||
const ButtonSelector = <T extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label = undefined,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
buttonClassName,
|
||||
textClassName,
|
||||
}: ButtonSelectorProps<T>) => {
|
||||
return (
|
||||
<Stack gap='var(--mantine-spacing-sm)'>
|
||||
@@ -41,14 +46,24 @@ const ButtonSelector = <T extends string>({
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled || option.disabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)'
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -5,6 +5,7 @@ import LocalIcon from './LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -72,7 +73,7 @@ const LandingPage = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
@@ -98,7 +99,7 @@ const LandingPage = () => {
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoWhiteText.svg' : '/branding/StirlingPDFLogoGreyText.svg'}
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import styles from './ObscuredOverlay/ObscuredOverlay.module.css';
|
||||
|
||||
type ObscuredOverlayProps = {
|
||||
obscured: boolean;
|
||||
overlayMessage?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
onButtonClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
// Optional border radius for the overlay container. If undefined, no radius is applied.
|
||||
borderRadius?: string | number;
|
||||
};
|
||||
|
||||
export default function ObscuredOverlay({
|
||||
obscured,
|
||||
overlayMessage,
|
||||
buttonText,
|
||||
onButtonClick,
|
||||
children,
|
||||
borderRadius,
|
||||
}: ObscuredOverlayProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{children}
|
||||
{obscured && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
style={{
|
||||
...(borderRadius !== undefined ? { borderRadius } : {}),
|
||||
}}
|
||||
>
|
||||
<div className={styles.overlayContent}>
|
||||
{overlayMessage && (
|
||||
<div className={styles.overlayMessage}>
|
||||
{overlayMessage}
|
||||
</div>
|
||||
)}
|
||||
{buttonText && onButtonClick && (
|
||||
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 18, 27, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.overlayContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overlayMessage {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overlayButton {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
||||
import { TooltipTip } from '../../types/tips';
|
||||
import { TooltipContent } from './tooltip/TooltipContent';
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './tooltip/Tooltip.module.css';
|
||||
|
||||
export interface TooltipProps {
|
||||
@@ -328,7 +329,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
<div className={styles['tooltip-logo']}>
|
||||
{header.logo || (
|
||||
<img
|
||||
src="/logo-tooltip.svg"
|
||||
src={`${BASE_PATH}/logo-tooltip.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { useToolManagement } from "../../hooks/useToolManagement";
|
||||
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
|
||||
import { BaseToolProps } from "../../types/tool";
|
||||
import ToolLoadingFallback from "./ToolLoadingFallback";
|
||||
|
||||
@@ -14,8 +14,8 @@ const ToolRenderer = ({
|
||||
onComplete,
|
||||
onError,
|
||||
}: ToolRendererProps) => {
|
||||
// Get the tool from registry
|
||||
const { toolRegistry } = useToolManagement();
|
||||
// Get the tool from context (instead of direct hook call)
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = toolRegistry[selectedToolKey];
|
||||
|
||||
if (!selectedTool || !selectedTool.component) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/* StampPreview.module.css */
|
||||
|
||||
/* Container styles */
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.containerWithThumbnail {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.containerWithoutThumbnail {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.containerBorder {
|
||||
border: 1px solid var(--border-default, #333);
|
||||
}
|
||||
|
||||
/* Page thumbnail styles */
|
||||
.pageThumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: grayscale(10%) contrast(95%) brightness(105%);
|
||||
}
|
||||
|
||||
/* Stamp item styles */
|
||||
.stampItem {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
line-height: 1;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.stampItemDraggable {
|
||||
cursor: move;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.stampItemGridMode {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text stamp styles */
|
||||
.textLine {
|
||||
white-space: pre;
|
||||
display: block;
|
||||
word-break: keep-all;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Image stamp styles */
|
||||
.stampImage {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Quick grid overlay styles */
|
||||
.quickGrid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gridTile {
|
||||
border: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
background-color: transparent;
|
||||
color: transparent;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gridTileSelected,
|
||||
.gridTileHovered {
|
||||
border: 2px solid var(--mantine-primary-color-filled, #3b82f6);
|
||||
}
|
||||
|
||||
/* Preview header */
|
||||
.previewHeader {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-default, #333);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Preview disclaimer */
|
||||
.previewDisclaimer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* AddStamp.tsx specific styles */
|
||||
|
||||
/* Information text container */
|
||||
.informationContainer {
|
||||
background-color: var(--information-text-bg);
|
||||
padding: 2px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 10px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.informationText {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--information-text-color);
|
||||
}
|
||||
|
||||
/* Mode toggle buttons */
|
||||
.modeToggleGroup {
|
||||
gap: 0.25rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.modeToggleButton {
|
||||
border-radius: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Icon pill buttons */
|
||||
.iconPillGroup {
|
||||
gap: 0.25rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.iconPillButton {
|
||||
border-radius: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Slider controls */
|
||||
.sliderGroup {
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 80px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sliderWide {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
/* Label text */
|
||||
.labelText {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AddStampParameters } from './useAddStampParameters';
|
||||
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||
import { A4_ASPECT_RATIO, getFirstSelectedPage, getFontFamily, computeStampPreviewStyle, getAlphabetPreviewScale } from './StampPreviewUtils';
|
||||
import styles from './StampPreview.module.css';
|
||||
|
||||
type Props = {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
file?: File | null;
|
||||
showQuickGrid?: boolean;
|
||||
};
|
||||
|
||||
export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
const [imageMeta, setImageMeta] = useState<{ url: string; width: number; height: number } | null>(null);
|
||||
const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null);
|
||||
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
|
||||
const { requestThumbnail } = useThumbnailGeneration();
|
||||
const [hoverTile, setHoverTile] = useState<number | null>(null);
|
||||
|
||||
// Load image URL and meta for aspect ratio if an image is selected
|
||||
useEffect(() => {
|
||||
if (parameters.stampType === 'image' && parameters.stampImage) {
|
||||
const url = URL.createObjectURL(parameters.stampImage);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageMeta({ url, width: img.width, height: img.height });
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setImageMeta(null);
|
||||
}
|
||||
}, [parameters.stampType, parameters.stampImage]);
|
||||
|
||||
// Observe container size for responsive positioning
|
||||
useEffect(() => {
|
||||
const node = containerRef.current;
|
||||
if (!node) return;
|
||||
const resize = () => {
|
||||
const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO;
|
||||
setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect });
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
}, [pageSize]);
|
||||
|
||||
// Load first PDF page size in points for accurate scaling
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
setPageSize(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true });
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
if (!cancelled) {
|
||||
setPageSize({ widthPts: viewport.width, heightPts: viewport.height });
|
||||
}
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
} catch {
|
||||
// Fallback to A4 if we cannot read page
|
||||
if (!cancelled) setPageSize(null);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [file]);
|
||||
|
||||
// Load first-page thumbnail for background preview so users see the content
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const loadThumb = async () => {
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
setPageThumbnail(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers));
|
||||
const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`;
|
||||
const thumb = await requestThumbnail(pageId, file, pageNumber);
|
||||
if (isActive) setPageThumbnail(thumb || null);
|
||||
} catch {
|
||||
if (isActive) setPageThumbnail(null);
|
||||
}
|
||||
};
|
||||
loadThumb();
|
||||
return () => { isActive = false; };
|
||||
}, [file, parameters.pageNumbers, requestThumbnail]);
|
||||
|
||||
const style = useMemo(() => (
|
||||
computeStampPreviewStyle(
|
||||
parameters,
|
||||
imageMeta,
|
||||
pageSize,
|
||||
containerSize,
|
||||
showQuickGrid,
|
||||
hoverTile,
|
||||
!!pageThumbnail
|
||||
)
|
||||
), [containerSize, parameters, imageMeta, pageSize, showQuickGrid, hoverTile, pageThumbnail]);
|
||||
|
||||
// Keep center fixed when scaling via slider (or any fontSize changes)
|
||||
const prevDimsRef = useRef<{ fontSize: number; widthPx: number; heightPx: number; leftPx: number; bottomPx: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const itemStyle = style.item as any;
|
||||
if (!itemStyle || containerSize.width <= 0 || containerSize.height <= 0) return;
|
||||
|
||||
const parse = (v: any) => parseFloat(String(v).replace('px', '')) || 0;
|
||||
const leftPx = parse(itemStyle.left);
|
||||
const bottomPx = parse(itemStyle.bottom);
|
||||
const widthPx = parse(itemStyle.width);
|
||||
const heightPx = parse(itemStyle.height);
|
||||
|
||||
const prev = prevDimsRef.current;
|
||||
const hasOverrides = parameters.overrideX >= 0 && parameters.overrideY >= 0;
|
||||
const canAdjust = hasOverrides && !showQuickGrid;
|
||||
if (
|
||||
prev &&
|
||||
canAdjust &&
|
||||
parameters.fontSize !== prev.fontSize &&
|
||||
prev.widthPx > 0 &&
|
||||
prev.heightPx > 0 &&
|
||||
widthPx > 0 &&
|
||||
heightPx > 0
|
||||
) {
|
||||
const centerX = prev.leftPx + prev.widthPx / 2;
|
||||
const centerY = prev.bottomPx + prev.heightPx / 2;
|
||||
const newLeftPx = centerX - widthPx / 2;
|
||||
const newBottomPx = centerY - heightPx / 2;
|
||||
|
||||
const widthPts = pageSize?.widthPts ?? 595.28;
|
||||
const heightPts = pageSize?.heightPts ?? 841.89;
|
||||
const scaleX = containerSize.width / widthPts;
|
||||
const scaleY = containerSize.height / heightPts;
|
||||
const newLeftPts = Math.max(0, Math.min(containerSize.width, newLeftPx)) / scaleX;
|
||||
const newBottomPts = Math.max(0, Math.min(containerSize.height, newBottomPx)) / scaleY;
|
||||
onParameterChange('overrideX', newLeftPts as any);
|
||||
onParameterChange('overrideY', newBottomPts as any);
|
||||
}
|
||||
|
||||
prevDimsRef.current = { fontSize: parameters.fontSize, widthPx, heightPx, leftPx, bottomPx };
|
||||
}, [parameters.fontSize, style.item, containerSize, pageSize, showQuickGrid, parameters.overrideX, parameters.overrideY, onParameterChange]);
|
||||
|
||||
// Drag/resize/rotate interactions
|
||||
const draggingRef = useRef<{ type: 'move' | 'resize' | 'rotate'; startX: number; startY: number; initLeft: number; initBottom: number; initHeight: number; centerX: number; centerY: number } | null>(null);
|
||||
|
||||
const ensureOverrides = () => {
|
||||
const pageWidth = containerSize.width;
|
||||
const pageHeight = containerSize.height;
|
||||
if (pageWidth <= 0 || pageHeight <= 0) return;
|
||||
|
||||
// Recompute current x,y from style (so that we start from visual position)
|
||||
const itemStyle = style.item as any;
|
||||
const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0;
|
||||
const bottomPx = parseFloat(String(itemStyle.bottom).replace('px', '')) || 0;
|
||||
const widthPts = pageSize?.widthPts ?? 595.28;
|
||||
const heightPts = pageSize?.heightPts ?? 841.89;
|
||||
const scaleX = containerSize.width / widthPts;
|
||||
const scaleY = containerSize.height / heightPts;
|
||||
if (parameters.overrideX < 0 || parameters.overrideY < 0) {
|
||||
onParameterChange('overrideX', Math.max(0, Math.min(pageWidth, leftPx)) / scaleX as any);
|
||||
onParameterChange('overrideY', Math.max(0, Math.min(pageHeight, bottomPx)) / scaleY as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, type: 'move' | 'resize' | 'rotate') => {
|
||||
e.preventDefault();
|
||||
ensureOverrides();
|
||||
|
||||
const item = style.item as any;
|
||||
const left = parseFloat(String(item.left).replace('px', '')) || 0;
|
||||
const bottom = parseFloat(String(item.bottom).replace('px', '')) || 0;
|
||||
const width = parseFloat(String(item.width).replace('px', '')) || parameters.fontSize;
|
||||
const height = parseFloat(String(item.height).replace('px', '')) || parameters.fontSize;
|
||||
|
||||
const rect = (e.currentTarget.parentElement as HTMLElement)?.getBoundingClientRect();
|
||||
const centerX = left + width / 2;
|
||||
const centerY = bottom + height / 2;
|
||||
|
||||
draggingRef.current = {
|
||||
type,
|
||||
startX: e.clientX - (rect?.left || 0),
|
||||
startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords
|
||||
initLeft: left,
|
||||
initBottom: bottom,
|
||||
initHeight: height,
|
||||
centerX,
|
||||
centerY,
|
||||
};
|
||||
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const node = containerRef.current;
|
||||
if (!node) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = rect.bottom - e.clientY; // bottom-based
|
||||
|
||||
const drag = draggingRef.current;
|
||||
|
||||
if (drag.type === 'move') {
|
||||
const dx = x - drag.startX;
|
||||
const dy = y - drag.startY;
|
||||
const newLeftPx = Math.max(0, Math.min(containerSize.width, drag.initLeft + dx));
|
||||
const newBottomPx = Math.max(0, Math.min(containerSize.height, drag.initBottom + dy));
|
||||
const widthPts = pageSize?.widthPts ?? 595.28;
|
||||
const heightPts = pageSize?.heightPts ?? 841.89;
|
||||
const scaleX = containerSize.width / widthPts;
|
||||
const scaleY = containerSize.height / heightPts;
|
||||
const newLeftPts = newLeftPx / scaleX;
|
||||
const newBottomPts = newBottomPx / scaleY;
|
||||
onParameterChange('overrideX', newLeftPts as any);
|
||||
onParameterChange('overrideY', newBottomPts as any);
|
||||
}
|
||||
|
||||
if (drag.type === 'resize') {
|
||||
// Height is our canonical size (fontSize)
|
||||
const heightPts = pageSize?.heightPts ?? 841.89;
|
||||
const scaleY = containerSize.height / heightPts;
|
||||
const newHeightPx = Math.max(1, drag.initHeight + (y - drag.startY));
|
||||
const newHeightPts = newHeightPx / scaleY;
|
||||
onParameterChange('fontSize', newHeightPts as any);
|
||||
}
|
||||
|
||||
if (drag.type === 'rotate') {
|
||||
const angle = Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI);
|
||||
onParameterChange('rotation', angle as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = null;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const itemHandles = null; // Drag-only per request
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.previewHeader}>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.previewLabel}>Preview Stamp</div>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
|
||||
style={style.container as React.CSSProperties}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{pageThumbnail && (
|
||||
<img
|
||||
src={pageThumbnail}
|
||||
alt="page preview"
|
||||
className={styles.pageThumbnail}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{parameters.stampType === 'text' && (
|
||||
<div
|
||||
className={`${styles.stampItem} ${styles.stampItemGridMode}`}
|
||||
style={style.item as React.CSSProperties}
|
||||
>
|
||||
{(parameters.stampText || '').split('\n').map((line, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={styles.textLine}
|
||||
style={{
|
||||
fontFamily: getFontFamily(parameters.alphabet),
|
||||
fontSize: `${Math.max(1, (parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet)) / 2)}px`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{line || '\u00A0'}
|
||||
</span>
|
||||
))}
|
||||
{itemHandles}
|
||||
</div>
|
||||
)}
|
||||
{parameters.stampType === 'image' && imageMeta && (
|
||||
<div
|
||||
className={`${styles.stampItem} ${showQuickGrid ? styles.stampItemGridMode : styles.stampItemDraggable}`}
|
||||
style={style.item as React.CSSProperties}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'move')}
|
||||
>
|
||||
<img
|
||||
src={imageMeta.url}
|
||||
alt="stamp preview"
|
||||
className={styles.stampImage}
|
||||
/>
|
||||
{itemHandles}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick position overlay grid */}
|
||||
{showQuickGrid && (
|
||||
<div className={styles.quickGrid}>
|
||||
{Array.from({ length: 9 }).map((_, i) => {
|
||||
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
|
||||
const selected = parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
|
||||
onClick={() => {
|
||||
// Clear overrides to use grid positioning and set position
|
||||
onParameterChange('overrideX', -1 as any);
|
||||
onParameterChange('overrideY', -1 as any);
|
||||
onParameterChange('position', idx as any);
|
||||
}}
|
||||
onMouseEnter={() => setHoverTile(idx)}
|
||||
onMouseLeave={() => setHoverTile(null)}
|
||||
>
|
||||
{idx}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.previewDisclaimer}>
|
||||
Preview is approximate. Final output may vary due to PDF font metrics.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { AddStampParameters } from './useAddStampParameters';
|
||||
|
||||
export type ContainerSize = { width: number; height: number };
|
||||
export type PageSizePts = { widthPts: number; heightPts: number } | null;
|
||||
export type ImageMeta = { url: string; width: number; height: number } | null;
|
||||
|
||||
// Map UI margin option to backend margin factor
|
||||
export const marginFactorMap: Record<AddStampParameters['customMargin'], number> = {
|
||||
'small': 0.02,
|
||||
'medium': 0.035,
|
||||
'large': 0.05,
|
||||
'x-large': 0.075,
|
||||
};
|
||||
|
||||
export const A4_ASPECT_RATIO = 0.707; // width/height used elsewhere in legacy UI
|
||||
|
||||
// Get font family based on selected alphabet (matching backend logic)
|
||||
export const getFontFamily = (alphabet: string): string => {
|
||||
switch (alphabet) {
|
||||
case 'arabic':
|
||||
return 'Noto Sans Arabic, Arial Unicode MS, sans-serif';
|
||||
case 'japanese':
|
||||
return 'Meiryo, Yu Gothic, Hiragino Sans, sans-serif';
|
||||
case 'korean':
|
||||
return 'Malgun Gothic, Dotum, sans-serif';
|
||||
case 'chinese':
|
||||
return 'SimSun, Microsoft YaHei, sans-serif';
|
||||
case 'thai':
|
||||
return 'Noto Sans Thai, Tahoma, sans-serif';
|
||||
case 'roman':
|
||||
default:
|
||||
return 'Noto Sans, Arial, Helvetica, sans-serif';
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight parser: returns first page number from CSV/range input, otherwise 1
|
||||
export const getFirstSelectedPage = (input: string): number => {
|
||||
if (!input) return 1;
|
||||
const parts = input.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (/^\d+\s*-\s*\d+$/.test(part)) {
|
||||
const low = parseInt(part.split('-')[0].trim(), 10);
|
||||
if (Number.isFinite(low) && low > 0) return low;
|
||||
}
|
||||
const n = parseInt(part, 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export type StampPreviewStyle = { container: any; item: any };
|
||||
|
||||
// Unified per-alphabet preview adjustments
|
||||
export type Alphabet = 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai';
|
||||
export type AlphabetTweaks = { scale: number; rowOffsetRem: [number, number, number]; lineHeight: number; capHeightRatio: number; defaultFontSize: number };
|
||||
export const ALPHABET_PREVIEW_TWEAKS: Record<Alphabet, AlphabetTweaks> = {
|
||||
// [top, middle, bottom] row offsets in rem
|
||||
roman: { scale: 1.0/1.18, rowOffsetRem: [0, 1, 2.2], lineHeight: 1.28, capHeightRatio: 0.70, defaultFontSize: 80 },
|
||||
arabic: { scale: 1.2, rowOffsetRem: [0, 1.5, 2.5], lineHeight: 1, capHeightRatio: 0.68, defaultFontSize: 80 },
|
||||
japanese: { scale: 1/1.2, rowOffsetRem: [-0.1, 1, 2], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 },
|
||||
korean: { scale: 1.0/1.05, rowOffsetRem: [-0.2, 0.5, 1.4], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 },
|
||||
chinese: { scale: 1/1.2, rowOffsetRem: [0, 2, 2.8], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 30 }, // temporary default font size so that it fits on the PDF
|
||||
thai: { scale: 1/1.2, rowOffsetRem: [-1, 0, .8], lineHeight: 1, capHeightRatio: 0.66, defaultFontSize: 80 },
|
||||
};
|
||||
export const getAlphabetPreviewScale = (alphabet: string): number => (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.scale ?? 1.0;
|
||||
|
||||
export const getDefaultFontSizeForAlphabet = (alphabet: string): number => {
|
||||
return (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.defaultFontSize ?? 80;
|
||||
};
|
||||
|
||||
export function computeStampPreviewStyle(
|
||||
parameters: AddStampParameters,
|
||||
imageMeta: ImageMeta,
|
||||
pageSize: PageSizePts,
|
||||
containerSize: ContainerSize,
|
||||
showQuickGrid: boolean | undefined,
|
||||
_hoverTile: number | null,
|
||||
hasPageThumbnail: boolean
|
||||
): StampPreviewStyle {
|
||||
const pageWidthPx = containerSize.width;
|
||||
const pageHeightPx = containerSize.height;
|
||||
const widthPts = pageSize?.widthPts ?? 595.28; // A4 width at 72 DPI
|
||||
const heightPts = pageSize?.heightPts ?? 841.89; // A4 height at 72 DPI
|
||||
const scaleX = pageWidthPx / widthPts;
|
||||
const scaleY = pageHeightPx / heightPts;
|
||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) return { item: {}, container: {} } as any;
|
||||
|
||||
const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035);
|
||||
|
||||
// Compute content dimensions
|
||||
const heightPtsContent = parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet);
|
||||
let widthPtsContent = heightPtsContent;
|
||||
|
||||
|
||||
if (parameters.stampType === 'image' && imageMeta) {
|
||||
const aspect = imageMeta.width / imageMeta.height;
|
||||
widthPtsContent = heightPtsContent * aspect;
|
||||
} else if (parameters.stampType === 'text') {
|
||||
// Use Canvas 2D to measure text width for better fidelity than DOM spans
|
||||
const textLine = (parameters.stampText || '').split('\n')[0] ?? '';
|
||||
const fontPx = heightPtsContent * scaleY; // Convert point size to px using vertical scale
|
||||
const fontFamily = getFontFamily(parameters.alphabet);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.font = `${fontPx}px ${fontFamily}`;
|
||||
const metrics = ctx.measureText(textLine);
|
||||
const measuredWidthPx = metrics.width;
|
||||
// Convert measured px width back to PDF points using horizontal scale
|
||||
widthPtsContent = measuredWidthPx / scaleX;
|
||||
|
||||
let adjustmentFactor = 1.0;
|
||||
switch (parameters.alphabet) {
|
||||
case 'roman':
|
||||
adjustmentFactor = 0.90;
|
||||
break;
|
||||
case 'arabic':
|
||||
case 'thai':
|
||||
adjustmentFactor = 0.92;
|
||||
break;
|
||||
case 'japanese':
|
||||
case 'korean':
|
||||
case 'chinese':
|
||||
adjustmentFactor = 0.88;
|
||||
break;
|
||||
default:
|
||||
adjustmentFactor = 0.93;
|
||||
}
|
||||
widthPtsContent *= adjustmentFactor;
|
||||
}
|
||||
}
|
||||
|
||||
// Positioning helpers - mirror backend logic
|
||||
const position = parameters.position;
|
||||
const calcX = () => {
|
||||
if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideX;
|
||||
switch (position % 3) {
|
||||
case 1: // Left
|
||||
return marginPts;
|
||||
case 2: // Center
|
||||
return (widthPts - widthPtsContent) / 2;
|
||||
case 0: // Right
|
||||
return widthPts - widthPtsContent - marginPts;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const calcY = () => {
|
||||
if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideY;
|
||||
// For text, backend positions using cap height, not full font size
|
||||
const heightForY = parameters.stampType === 'text'
|
||||
? heightPtsContent * ((ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.capHeightRatio ?? 0.70)
|
||||
: heightPtsContent;
|
||||
switch (Math.floor((position - 1) / 3)) {
|
||||
case 0: // Top
|
||||
return heightPts - heightForY - marginPts;
|
||||
case 1: // Middle
|
||||
return (heightPts - heightForY) / 2;
|
||||
case 2: // Bottom
|
||||
return marginPts;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const xPts = calcX();
|
||||
const yPts = calcY();
|
||||
let xPx = xPts * scaleX;
|
||||
let yPx = yPts * scaleY;
|
||||
if (parameters.stampType === 'text') {
|
||||
try {
|
||||
const rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
|
||||
const rowIndex = Math.floor((position - 1) / 3); // 0 top, 1 middle, 2 bottom
|
||||
const offsets = (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.rowOffsetRem ?? [0, 0, 0];
|
||||
const offsetRem = offsets[rowIndex] ?? 0;
|
||||
yPx += offsetRem * rootFontSizePx;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
const widthPx = widthPtsContent * scaleX;
|
||||
const heightPx = heightPtsContent * scaleY;
|
||||
|
||||
xPx = Math.max(0, Math.min(xPx, pageWidthPx - widthPx));
|
||||
yPx = Math.max(0, Math.min(yPx, pageHeightPx - heightPx));
|
||||
|
||||
const opacity = Math.max(0, Math.min(1, parameters.opacity / 100));
|
||||
const displayOpacity = opacity;
|
||||
|
||||
let alignItems: 'flex-start' | 'center' | 'flex-end' = 'flex-start';
|
||||
if (parameters.stampType === 'text') {
|
||||
const colIndex = position % 3; // 1: left, 2: center, 0: right
|
||||
switch (colIndex) {
|
||||
case 2: // center column
|
||||
alignItems = 'center';
|
||||
break;
|
||||
case 0: // right column
|
||||
alignItems = 'flex-end';
|
||||
break;
|
||||
default:
|
||||
alignItems = 'flex-start';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`,
|
||||
backgroundColor: hasPageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid var(--border-default, #333)',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
item: {
|
||||
position: 'absolute',
|
||||
left: `${xPx}px`,
|
||||
bottom: `${yPx}px`,
|
||||
width: `${widthPx}px`,
|
||||
height: `${heightPx}px`,
|
||||
opacity: displayOpacity,
|
||||
transform: `rotate(${-parameters.rotation}deg)`,
|
||||
transformOrigin: 'center center',
|
||||
color: parameters.customColor,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
lineHeight: (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.lineHeight ?? 1,
|
||||
alignItems,
|
||||
cursor: showQuickGrid ? 'default' : 'move',
|
||||
pointerEvents: showQuickGrid ? 'none' : 'auto',
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddStampParameters, defaultParameters } from './useAddStampParameters';
|
||||
|
||||
export const buildAddStampFormData = (parameters: AddStampParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('pageNumbers', parameters.pageNumbers);
|
||||
formData.append('customMargin', parameters.customMargin || 'medium');
|
||||
formData.append('position', String(parameters.position));
|
||||
const effectiveFontSize = parameters.fontSize;
|
||||
formData.append('fontSize', String(effectiveFontSize));
|
||||
formData.append('rotation', String(parameters.rotation));
|
||||
formData.append('opacity', String(parameters.opacity / 100));
|
||||
formData.append('overrideX', String(parameters.overrideX));
|
||||
formData.append('overrideY', String(parameters.overrideY));
|
||||
formData.append('customColor', parameters.customColor.startsWith('#') ? parameters.customColor : `#${parameters.customColor}`);
|
||||
formData.append('alphabet', parameters.alphabet);
|
||||
|
||||
// Stamp type and payload
|
||||
formData.append('stampType', parameters.stampType || 'text');
|
||||
if (parameters.stampType === 'text') {
|
||||
formData.append('stampText', parameters.stampText);
|
||||
} else if (parameters.stampType === 'image' && parameters.stampImage) {
|
||||
formData.append('stampImage', parameters.stampImage);
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const addStampOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildAddStampFormData,
|
||||
operationType: 'addStamp',
|
||||
endpoint: '/api/v1/misc/add-stamp',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useAddStampOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<AddStampParameters>({
|
||||
...addStampOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.')
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters';
|
||||
|
||||
export interface AddStampParameters extends BaseParameters {
|
||||
stampType?: 'text' | 'image';
|
||||
stampText: string;
|
||||
stampImage?: File;
|
||||
alphabet: 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai';
|
||||
fontSize: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
overrideX: number;
|
||||
overrideY: number;
|
||||
customMargin: 'small' | 'medium' | 'large' | 'x-large';
|
||||
customColor: string;
|
||||
pageNumbers: string;
|
||||
_activePill: 'fontSize' | 'rotation' | 'opacity';
|
||||
}
|
||||
|
||||
export const defaultParameters: AddStampParameters = {
|
||||
stampType: 'text',
|
||||
stampText: '',
|
||||
alphabet: 'roman',
|
||||
fontSize: 80,
|
||||
rotation: 0,
|
||||
opacity: 50,
|
||||
position: 5,
|
||||
overrideX: -1,
|
||||
overrideY: -1,
|
||||
customMargin: 'medium',
|
||||
customColor: '#d3d3d3',
|
||||
pageNumbers: '1',
|
||||
_activePill: 'fontSize',
|
||||
};
|
||||
|
||||
export type AddStampParametersHook = BaseParametersHook<AddStampParameters>;
|
||||
|
||||
export const useAddStampParameters = (): AddStampParametersHook => {
|
||||
return useBaseParameters<AddStampParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'add-stamp',
|
||||
validateFn: (params): boolean => {
|
||||
if (!params.stampType) return false;
|
||||
if (params.stampType === 'text') {
|
||||
return params.stampText.trim().length > 0;
|
||||
}
|
||||
return params.stampImage !== undefined;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core";
|
||||
import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface BookletImpositionSettingsProps {
|
||||
parameters: BookletImpositionParameters;
|
||||
onParameterChange: (key: keyof BookletImpositionParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Divider ml='-md'></Divider>
|
||||
|
||||
|
||||
{/* Double Sided */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.doubleSided}
|
||||
onChange={(e) => {
|
||||
const isDoubleSided = e.target.checked;
|
||||
onParameterChange('doubleSided', isDoubleSided);
|
||||
// Reset to BOTH when turning double-sided back on
|
||||
if (isDoubleSided) {
|
||||
onParameterChange('duplexPass', 'BOTH');
|
||||
} else {
|
||||
// Default to FIRST pass when going to manual duplex
|
||||
onParameterChange('duplexPass', 'FIRST');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Manual Duplex Pass Selection - only show when double-sided is OFF */}
|
||||
{!parameters.doubleSided && (
|
||||
<Stack gap="xs" ml="lg">
|
||||
<Text size="sm" fw={500} c="orange">
|
||||
{t('bookletImposition.manualDuplex.title', 'Manual Duplex Mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('bookletImposition.manualDuplex.instructions', 'For printers without automatic duplex. You\'ll need to run this twice:')}
|
||||
</Text>
|
||||
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.duplexPass.label', 'Print Pass')}
|
||||
value={parameters.duplexPass}
|
||||
onChange={(value) => onParameterChange('duplexPass', value)}
|
||||
options={[
|
||||
{ value: 'FIRST', label: t('bookletImposition.duplexPass.first', '1st Pass') },
|
||||
{ value: 'SECOND', label: t('bookletImposition.duplexPass.second', '2nd Pass') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="blue" fs="italic">
|
||||
{parameters.duplexPass === 'FIRST'
|
||||
? t('bookletImposition.duplexPass.firstInstructions', 'Prints front sides → stack face-down → run again with 2nd Pass')
|
||||
: t('bookletImposition.duplexPass.secondInstructions', 'Load printed stack face-down → prints back sides')
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '▲' : '▼'}
|
||||
</Button>
|
||||
|
||||
<Collapse in={advancedOpen}>
|
||||
<Stack gap="md" mt="md">
|
||||
{/* Right-to-Left Binding */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.spineLocation === 'RIGHT'}
|
||||
onChange={(e) => onParameterChange('spineLocation', e.target.checked ? 'RIGHT' : 'LEFT')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Add Border Option */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.addBorder}
|
||||
onChange={(e) => onParameterChange('addBorder', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Gutter Margin */}
|
||||
<Stack gap="xs">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.addGutter}
|
||||
onChange={(e) => onParameterChange('addGutter', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
|
||||
</label>
|
||||
|
||||
{parameters.addGutter && (
|
||||
<NumberInput
|
||||
label={t('bookletImposition.gutterSize.label', 'Gutter size (points)')}
|
||||
value={parameters.gutterSize}
|
||||
onChange={(value) => onParameterChange('gutterSize', value || 12)}
|
||||
min={6}
|
||||
max={72}
|
||||
step={6}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Flip on Short Edge */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={!parameters.doubleSided
|
||||
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
|
||||
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.flipOnShortEdge}
|
||||
onChange={(e) => onParameterChange('flipOnShortEdge', e.target.checked)}
|
||||
disabled={disabled || !parameters.doubleSided}
|
||||
/>
|
||||
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
|
||||
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
|
||||
</Text>
|
||||
</label>
|
||||
|
||||
{/* Paper Size Note */}
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t('bookletImposition.paperSizeNote', 'Paper size is automatically derived from your first page.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookletImpositionSettings;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
import FileUploadButton from "../../shared/FileUploadButton";
|
||||
|
||||
interface CertificateFilesSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFilesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Certificate Files based on type */}
|
||||
{parameters.certType === 'PEM' && (
|
||||
<Stack gap="sm">
|
||||
<FileUploadButton
|
||||
file={parameters.privateKeyFile}
|
||||
onChange={(file) => onParameterChange('privateKeyFile', file || undefined)}
|
||||
accept=".pem,.der,.key"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.choosePrivateKey', 'Choose Private Key File')}
|
||||
/>
|
||||
{parameters.privateKeyFile && (
|
||||
<FileUploadButton
|
||||
file={parameters.certFile}
|
||||
onChange={(file) => onParameterChange('certFile', file || undefined)}
|
||||
accept=".pem,.der,.crt,.cer"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.chooseCertificate', 'Choose Certificate File')}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.certType === 'PKCS12' && (
|
||||
<FileUploadButton
|
||||
file={parameters.p12File}
|
||||
onChange={(file) => onParameterChange('p12File', file || undefined)}
|
||||
accept=".p12"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.chooseP12File', 'Choose PKCS12 File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.certType === 'PFX' && (
|
||||
<FileUploadButton
|
||||
file={parameters.p12File}
|
||||
onChange={(file) => onParameterChange('p12File', file || undefined)}
|
||||
accept=".pfx"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.choosePfxFile', 'Choose PFX File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.certType === 'JKS' && (
|
||||
<FileUploadButton
|
||||
file={parameters.jksFile}
|
||||
onChange={(file) => onParameterChange('jksFile', file || undefined)}
|
||||
accept=".jks,.keystore"
|
||||
disabled={disabled}
|
||||
placeholder={t('certSign.chooseJksFile', 'Choose JKS File')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.signMode === 'AUTO' && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('certSign.serverCertMessage', 'Using server certificate - no files or password required')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Password - only show when files are uploaded */}
|
||||
{parameters.certType && (
|
||||
(parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) ||
|
||||
(parameters.certType === 'PKCS12' && parameters.p12File) ||
|
||||
(parameters.certType === 'PFX' && parameters.p12File) ||
|
||||
(parameters.certType === 'JKS' && parameters.jksFile)
|
||||
) && (
|
||||
<TextInput
|
||||
label={t('certSign.password', 'Certificate Password')}
|
||||
placeholder={t('certSign.passwordOptional', 'Leave empty if no password')}
|
||||
type="password"
|
||||
value={parameters.password}
|
||||
onChange={(event) => onParameterChange('password', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateFilesSettings;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
|
||||
interface CertificateFormatSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFormatSettingsProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{/* First row - PKCS#12 and PFX */}
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.certType === 'PKCS12' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PKCS12' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PKCS12')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
PKCS12
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.certType === 'PFX' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PFX' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PFX')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
PFX
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Second row - PEM and JKS */}
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.certType === 'PEM' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'PEM' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'PEM')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
PEM
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.certType === 'JKS' ? 'filled' : 'outline'}
|
||||
color={parameters.certType === 'JKS' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('certType', 'JKS')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
JKS
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateFormatSettings;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
import { useAppConfig } from "../../../hooks/useAppConfig";
|
||||
|
||||
interface CertificateTypeSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = false }: CertificateTypeSettingsProps) => {
|
||||
const { config } = useAppConfig();
|
||||
const isServerCertificateEnabled = config?.serverCertificateEnabled ?? false;
|
||||
|
||||
// Reset to MANUAL if AUTO is selected but feature is disabled
|
||||
if (parameters.signMode === 'AUTO' && !isServerCertificateEnabled) {
|
||||
onParameterChange('signMode', 'MANUAL');
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.signMode === 'MANUAL' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'MANUAL' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'MANUAL');
|
||||
// Reset cert type when switching to manual
|
||||
if (parameters.signMode === 'AUTO') {
|
||||
onParameterChange('certType', '');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Manual
|
||||
</div>
|
||||
</Button>
|
||||
{isServerCertificateEnabled && (
|
||||
<Button
|
||||
variant={parameters.signMode === 'AUTO' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'AUTO' ? 'green' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'AUTO');
|
||||
// Clear cert type and files when switching to auto
|
||||
onParameterChange('certType', '');
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Auto (server)
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateTypeSettings;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Stack, Text, Button, TextInput, NumberInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
|
||||
interface SignatureAppearanceSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SignatureAppearanceSettings = ({ parameters, onParameterChange, disabled = false }: SignatureAppearanceSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Signature Visibility */}
|
||||
<Stack gap="sm">
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={!parameters.showSignature ? 'filled' : 'outline'}
|
||||
color={!parameters.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showSignature', false)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.appearance.invisible', 'Invisible')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.showSignature ? 'filled' : 'outline'}
|
||||
color={parameters.showSignature ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showSignature', true)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.appearance.visible', 'Visible')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Visible Signature Options */}
|
||||
{parameters.showSignature && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.appearance.options.title', 'Signature Details')}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={t('certSign.reason', 'Reason')}
|
||||
value={parameters.reason}
|
||||
onChange={(event) => onParameterChange('reason', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('certSign.location', 'Location')}
|
||||
value={parameters.location}
|
||||
onChange={(event) => onParameterChange('location', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('certSign.name', 'Name')}
|
||||
value={parameters.name}
|
||||
onChange={(event) => onParameterChange('name', event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('certSign.pageNumber', 'Page Number')}
|
||||
value={parameters.pageNumber}
|
||||
onChange={(value) => onParameterChange('pageNumber', value || 1)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('certSign.logoTitle', 'Logo')}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={!parameters.showLogo ? 'filled' : 'outline'}
|
||||
color={!parameters.showLogo ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showLogo', false)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.noLogo', 'No Logo')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.showLogo ? 'filled' : 'outline'}
|
||||
color={parameters.showLogo ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('showLogo', true)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('certSign.showLogo', 'Show Logo')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureAppearanceSettings;
|
||||
@@ -15,6 +15,8 @@ export interface ReviewToolStepProps<TParams = unknown> {
|
||||
title?: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
}
|
||||
|
||||
function ReviewStepContent<TParams = unknown>({
|
||||
@@ -111,6 +113,8 @@ export function createReviewToolStep<TParams = unknown>(
|
||||
t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
isCollapsed: props.isCollapsed,
|
||||
onCollapsedClick: props.onCollapsedClick,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true,
|
||||
},
|
||||
|
||||
@@ -113,4 +113,4 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
</ToolStepProvider>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useBookletImpositionTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("bookletImposition.tooltip.header.title", "Booklet Creation Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("bookletImposition.tooltip.description.title", "What is Booklet Imposition?"),
|
||||
description: t("bookletImposition.tooltip.description.text", "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book.")
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.example.title", "Example: 8-Page Booklet"),
|
||||
description: t("bookletImposition.tooltip.example.text", "Your 8-page document becomes 2 sheets:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.example.bullet1", "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7"),
|
||||
t("bookletImposition.tooltip.example.bullet2", "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5"),
|
||||
t("bookletImposition.tooltip.example.bullet3", "When folded & stacked: Reads 1→2→3→4→5→6→7→8")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.printing.title", "How to Print & Assemble"),
|
||||
description: t("bookletImposition.tooltip.printing.text", "Follow these steps for perfect booklets:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.printing.bullet1", "Print double-sided with 'Flip on long edge'"),
|
||||
t("bookletImposition.tooltip.printing.bullet2", "Stack sheets in order, fold in half"),
|
||||
t("bookletImposition.tooltip.printing.bullet3", "Staple or bind along the folded spine"),
|
||||
t("bookletImposition.tooltip.printing.bullet4", "For short-edge printers: Enable 'Flip on short edge' option")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.manualDuplex.title", "Manual Duplex (Single-sided Printers)"),
|
||||
description: t("bookletImposition.tooltip.manualDuplex.text", "For printers without automatic duplex:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet1", "Turn OFF 'Double-sided printing'"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet2", "Select '1st Pass' → Print → Stack face-down"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet3", "Select '2nd Pass' → Load stack → Print backs"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet4", "Fold and assemble as normal")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.advanced.title", "Advanced Options"),
|
||||
description: t("bookletImposition.tooltip.advanced.text", "Fine-tune your booklet:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.advanced.bullet1", "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages"),
|
||||
t("bookletImposition.tooltip.advanced.bullet2", "Borders: Shows cut lines for trimming"),
|
||||
t("bookletImposition.tooltip.advanced.bullet3", "Gutter Margin: Adds space for binding/stapling"),
|
||||
t("bookletImposition.tooltip.advanced.bullet4", "Short-edge Flip: Only for automatic duplex printers")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useCertSignTooltips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("certSign.tooltip.header.title", "About Managing Signatures")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("certSign.tooltip.overview.title", "What can this tool do?"),
|
||||
description: t("certSign.tooltip.overview.text", "This tool lets you check if your PDFs are digitally signed and add new digital signatures. Digital signatures prove who created or approved a document and show if it has been changed since signing."),
|
||||
bullets: [
|
||||
t("certSign.tooltip.overview.bullet1", "Check existing signatures and their validity"),
|
||||
t("certSign.tooltip.overview.bullet2", "View detailed information about signers and certificates"),
|
||||
t("certSign.tooltip.overview.bullet3", "Add new digital signatures to secure your documents"),
|
||||
t("certSign.tooltip.overview.bullet4", "Multiple files supported with easy navigation")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.tooltip.validation.title", "Checking Signatures"),
|
||||
description: t("certSign.tooltip.validation.text", "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing."),
|
||||
bullets: [
|
||||
t("certSign.tooltip.validation.bullet1", "Shows if signatures are valid or invalid"),
|
||||
t("certSign.tooltip.validation.bullet2", "Displays signer information and signing date"),
|
||||
t("certSign.tooltip.validation.bullet3", "Checks if the document was modified after signing"),
|
||||
t("certSign.tooltip.validation.bullet4", "Can use custom certificates for verification")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.tooltip.signing.title", "Adding Signatures"),
|
||||
description: t("certSign.tooltip.signing.text", "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only."),
|
||||
bullets: [
|
||||
t("certSign.tooltip.signing.bullet1", "Supports PEM, PKCS12, JKS, and server certificate formats"),
|
||||
t("certSign.tooltip.signing.bullet2", "Option to show or hide signature on the PDF"),
|
||||
t("certSign.tooltip.signing.bullet3", "Add reason, location, and signer name"),
|
||||
t("certSign.tooltip.signing.bullet4", "Choose which page to place visible signatures"),
|
||||
t("certSign.tooltip.signing.bullet5", "Use server certificate for simple 'Sign with Stirling-PDF' option")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useCertificateTypeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("certSign.certType.tooltip.header.title", "About Certificate Types")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("certSign.certType.tooltip.what.title", "What's a certificate?"),
|
||||
description: t("certSign.certType.tooltip.what.text", "It's a secure ID for your signature that proves you signed. Unless you're required to sign via certificate, we recommend using another secure method like Type, Draw, or Upload.")
|
||||
},
|
||||
{
|
||||
title: t("certSign.certType.tooltip.which.title", "Which option should I use?"),
|
||||
description: t("certSign.certType.tooltip.which.text", "Choose the format that matches your certificate file:"),
|
||||
bullets: [
|
||||
t("certSign.certType.tooltip.which.bullet1", "PKCS12 (.p12) – one combined file (most common)"),
|
||||
t("certSign.certType.tooltip.which.bullet2", "PFX (.pfx) – Microsoft's version of PKCS12"),
|
||||
t("certSign.certType.tooltip.which.bullet3", "PEM – separate private-key and certificate .pem files"),
|
||||
t("certSign.certType.tooltip.which.bullet4", "JKS – Java .jks keystore for dev / CI-CD workflows")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.certType.tooltip.convert.title", "Key not listed?"),
|
||||
description: t("certSign.certType.tooltip.convert.text", "Convert your file to a Java keystore (.jks) with keytool, then pick JKS.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useSignModeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("certSign.signMode.tooltip.header.title", "About PDF Signatures")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("certSign.signMode.tooltip.overview.title", "How signatures work"),
|
||||
description: t("certSign.signMode.tooltip.overview.text", "Both modes seal the document (any edits are flagged as tampering) and record who/when/how for auditing. Viewer trust depends on the certificate chain.")
|
||||
},
|
||||
{
|
||||
title: t("certSign.signMode.tooltip.manual.title", "Manual - Bring your certificate"),
|
||||
description: t("certSign.signMode.tooltip.manual.text", "Use your own certificate files for brand-aligned identity. Can display <b>Trusted</b> when your CA/chain is recognized."),
|
||||
bullets: [
|
||||
t("certSign.signMode.tooltip.manual.use", "Use for: customer-facing, legal, compliance.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.signMode.tooltip.auto.title", "Auto - Zero-setup, instant system seal"),
|
||||
description: t("certSign.signMode.tooltip.auto.text", "Signs with a server <b>self-signed</b> certificate. Same <b>tamper-evident seal</b> and <b>audit trail</b>; typically shows <b>Unverified</b> in viewers."),
|
||||
bullets: [
|
||||
t("certSign.signMode.tooltip.auto.use", "Use when: you need speed and consistent internal identity across reviews and records.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.signMode.tooltip.rule.title", "Rule of thumb"),
|
||||
description: t("certSign.signMode.tooltip.rule.text", "Need recipient <b>Trusted</b> status? <b>Manual</b>. Need a fast, tamper-evident seal and audit trail with no setup? <b>Auto</b>.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useSignatureAppearanceTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("certSign.appearance.tooltip.header.title", "About Signature Appearance")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("certSign.appearance.tooltip.invisible.title", "Invisible Signatures"),
|
||||
description: t("certSign.appearance.tooltip.invisible.text", "The signature is added to the PDF for security but won't be visible when viewing the document. Perfect for legal requirements without changing the document's appearance."),
|
||||
bullets: [
|
||||
t("certSign.appearance.tooltip.invisible.bullet1", "Provides security without visual changes"),
|
||||
t("certSign.appearance.tooltip.invisible.bullet2", "Meets legal requirements for digital signing"),
|
||||
t("certSign.appearance.tooltip.invisible.bullet3", "Doesn't affect document layout or design")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("certSign.appearance.tooltip.visible.title", "Visible Signatures"),
|
||||
description: t("certSign.appearance.tooltip.visible.text", "Shows a signature block on the PDF with your name, date, and optional details. Useful when you want readers to clearly see the document is signed."),
|
||||
bullets: [
|
||||
t("certSign.appearance.tooltip.visible.bullet1", "Shows signer name and date on the document"),
|
||||
t("certSign.appearance.tooltip.visible.bullet2", "Can include reason and location for signing"),
|
||||
t("certSign.appearance.tooltip.visible.bullet3", "Choose which page to place the signature"),
|
||||
t("certSign.appearance.tooltip.visible.bullet4", "Optional logo can be included")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,19 @@ export const getBaseUrl = (): string => {
|
||||
const { config } = useAppConfig();
|
||||
return config?.baseUrl || 'https://stirling.com';
|
||||
};
|
||||
|
||||
// Base path from Vite config - build-time constant, normalized (no trailing slash)
|
||||
// When no subpath, use empty string instead of '.' to avoid relative path issues
|
||||
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
|
||||
|
||||
/** For in-app navigations when you must touch window.location (rare). */
|
||||
export const withBasePath = (path: string): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${BASE_PATH}${clean}`;
|
||||
};
|
||||
|
||||
/** For OAuth (needs absolute URL with scheme+host) */
|
||||
export const absoluteWithBasePath = (path: string): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${window.location.origin}${BASE_PATH}${clean}`;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: any; // From useToolManagement
|
||||
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@@ -247,6 +248,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
selectedToolKey: navigationState.selectedTool,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
|
||||
// Actions
|
||||
setSidebarsVisible,
|
||||
@@ -276,6 +278,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
navigationState.selectedTool,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
|
||||
@@ -13,12 +13,15 @@ import RemovePages from "../tools/RemovePages";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
import Merge from '../tools/Merge';
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
import SingleLargePage from "../tools/SingleLargePage";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import CertSign from "../tools/CertSign";
|
||||
import BookletImposition from "../tools/BookletImposition";
|
||||
import Flatten from "../tools/Flatten";
|
||||
import Rotate from "../tools/Rotate";
|
||||
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||
@@ -30,12 +33,15 @@ import { removePasswordOperationConfig } from "../hooks/tools/removePassword/use
|
||||
import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOperation";
|
||||
import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation";
|
||||
import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation";
|
||||
import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||
import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||
import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
||||
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
||||
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { certSignOperationConfig } from "../hooks/tools/certSign/useCertSignOperation";
|
||||
import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||
@@ -54,6 +60,8 @@ import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/Add
|
||||
import OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings";
|
||||
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
@@ -159,11 +167,15 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
|
||||
certSign: {
|
||||
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.certSign.title", "Sign with Certificate"),
|
||||
component: null,
|
||||
description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"),
|
||||
name: t("home.certSign.title", "Certificate Sign"),
|
||||
component: CertSign,
|
||||
description: t("home.certSign.desc", "Sign PDF documents using digital certificates"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
settingsComponent: CertificateTypeSettings,
|
||||
},
|
||||
sign: {
|
||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -203,10 +215,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
addStamp: {
|
||||
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.addStamp.title", "Add Stamp to PDF"),
|
||||
component: null,
|
||||
component: AddStamp,
|
||||
description: t("home.addStamp.desc", "Add text or add image stamps at set locations"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-stamp"],
|
||||
operationConfig: addStampOperationConfig,
|
||||
},
|
||||
sanitize: {
|
||||
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -267,8 +282,6 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
settingsComponent: ChangePermissionsSettings,
|
||||
},
|
||||
// Verification
|
||||
|
||||
getPdfInfo: {
|
||||
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
|
||||
@@ -390,7 +403,18 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
},
|
||||
bookletImposition: {
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.bookletImposition.title", "Booklet Imposition"),
|
||||
component: BookletImposition,
|
||||
operationConfig: bookletImpositionOperationConfig,
|
||||
settingsComponent: BookletImpositionSettings,
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
},
|
||||
pdfToSinglePage: {
|
||||
|
||||
icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
|
||||
component: SingleLargePage,
|
||||
|
||||
Vendored
+1
@@ -17,6 +17,7 @@ declare module '../assets/material-symbols-icons.json' {
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
// TODO: Add proper EmbedPDF types for local submodule integration
|
||||
|
||||
export {};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { BookletImpositionParameters, defaultParameters } from './useBookletImpositionParameters';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildBookletImpositionFormData = (parameters: BookletImpositionParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
|
||||
formData.append("addBorder", parameters.addBorder.toString());
|
||||
formData.append("spineLocation", parameters.spineLocation);
|
||||
formData.append("addGutter", parameters.addGutter.toString());
|
||||
formData.append("gutterSize", parameters.gutterSize.toString());
|
||||
formData.append("doubleSided", parameters.doubleSided.toString());
|
||||
formData.append("duplexPass", parameters.duplexPass);
|
||||
formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const bookletImpositionOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildBookletImpositionFormData,
|
||||
operationType: 'bookletImposition',
|
||||
endpoint: '/api/v1/general/booklet-imposition',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useBookletImpositionOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<BookletImpositionParameters>({
|
||||
...bookletImpositionOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('bookletImposition.error.failed', 'An error occurred while creating the booklet imposition.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface BookletImpositionParameters extends BaseParameters {
|
||||
pagesPerSheet: 2;
|
||||
addBorder: boolean;
|
||||
spineLocation: 'LEFT' | 'RIGHT';
|
||||
addGutter: boolean;
|
||||
gutterSize: number;
|
||||
doubleSided: boolean;
|
||||
duplexPass: 'BOTH' | 'FIRST' | 'SECOND';
|
||||
flipOnShortEdge: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: BookletImpositionParameters = {
|
||||
pagesPerSheet: 2,
|
||||
addBorder: false,
|
||||
spineLocation: 'LEFT',
|
||||
addGutter: false,
|
||||
gutterSize: 12,
|
||||
doubleSided: true,
|
||||
duplexPass: 'BOTH',
|
||||
flipOnShortEdge: false,
|
||||
};
|
||||
|
||||
export type BookletImpositionParametersHook = BaseParametersHook<BookletImpositionParameters>;
|
||||
|
||||
export const useBookletImpositionParameters = (): BookletImpositionParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'booklet-imposition',
|
||||
validateFn: (params) => {
|
||||
return params.pagesPerSheet === 2;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { CertSignParameters, defaultParameters } from './useCertSignParameters';
|
||||
|
||||
// Build form data for signing
|
||||
export const buildCertSignFormData = (parameters: CertSignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
// Handle sign mode
|
||||
if (parameters.signMode === 'AUTO') {
|
||||
formData.append('certType', 'SERVER');
|
||||
} else {
|
||||
formData.append('certType', parameters.certType);
|
||||
formData.append('password', parameters.password);
|
||||
|
||||
// Add certificate files based on type (only for manual mode)
|
||||
switch (parameters.certType) {
|
||||
case 'PEM':
|
||||
if (parameters.privateKeyFile) {
|
||||
formData.append('privateKeyFile', parameters.privateKeyFile);
|
||||
}
|
||||
if (parameters.certFile) {
|
||||
formData.append('certFile', parameters.certFile);
|
||||
}
|
||||
break;
|
||||
case 'PKCS12':
|
||||
if (parameters.p12File) {
|
||||
formData.append('p12File', parameters.p12File);
|
||||
}
|
||||
break;
|
||||
case 'JKS':
|
||||
if (parameters.jksFile) {
|
||||
formData.append('jksFile', parameters.jksFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add signature appearance options if enabled
|
||||
if (parameters.showSignature) {
|
||||
formData.append('showSignature', 'true');
|
||||
formData.append('reason', parameters.reason);
|
||||
formData.append('location', parameters.location);
|
||||
formData.append('name', parameters.name);
|
||||
formData.append('pageNumber', parameters.pageNumber.toString());
|
||||
formData.append('showLogo', parameters.showLogo.toString());
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const certSignOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildCertSignFormData,
|
||||
operationType: 'certSign',
|
||||
endpoint: '/api/v1/security/cert-sign',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useCertSignOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<CertSignParameters>({
|
||||
...certSignOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('certSign.error.failed', 'An error occurred while processing signatures.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface CertSignParameters extends BaseParameters {
|
||||
// Sign mode selection
|
||||
signMode: 'MANUAL' | 'AUTO';
|
||||
// Certificate signing options (only for manual mode)
|
||||
certType: '' | 'PEM' | 'PKCS12' | 'PFX' | 'JKS';
|
||||
privateKeyFile?: File;
|
||||
certFile?: File;
|
||||
p12File?: File;
|
||||
jksFile?: File;
|
||||
password: string;
|
||||
|
||||
// Signature appearance options
|
||||
showSignature: boolean;
|
||||
reason: string;
|
||||
location: string;
|
||||
name: string;
|
||||
pageNumber: number;
|
||||
showLogo: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: CertSignParameters = {
|
||||
signMode: 'MANUAL',
|
||||
certType: '',
|
||||
password: '',
|
||||
showSignature: false,
|
||||
reason: '',
|
||||
location: '',
|
||||
name: '',
|
||||
pageNumber: 1,
|
||||
showLogo: true,
|
||||
};
|
||||
|
||||
export type CertSignParametersHook = BaseParametersHook<CertSignParameters>;
|
||||
|
||||
export const useCertSignParameters = (): CertSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'cert-sign',
|
||||
validateFn: (params) => {
|
||||
// Auto mode (server certificate) - no additional validation needed
|
||||
if (params.signMode === 'AUTO') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual mode - requires certificate type and files
|
||||
if (!params.certType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required files based on cert type
|
||||
switch (params.certType) {
|
||||
case 'PEM':
|
||||
return !!(params.privateKeyFile && params.certFile);
|
||||
case 'PKCS12':
|
||||
case 'PFX':
|
||||
return !!params.p12File;
|
||||
case 'JKS':
|
||||
return !!params.jksFile;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export interface AppConfig {
|
||||
license?: string;
|
||||
GoogleDriveEnabled?: boolean;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASE_PATH } from '../constants/app';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -37,17 +38,17 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
|
||||
// Load the cookie consent CSS files first
|
||||
const mainCSS = document.createElement('link');
|
||||
mainCSS.rel = 'stylesheet';
|
||||
mainCSS.href = '/css/cookieconsent.css';
|
||||
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
|
||||
document.head.appendChild(mainCSS);
|
||||
|
||||
const customCSS = document.createElement('link');
|
||||
customCSS.rel = 'stylesheet';
|
||||
customCSS.href = '/css/cookieconsentCustomisation.css';
|
||||
customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`;
|
||||
document.head.appendChild(customCSS);
|
||||
|
||||
// Load the cookie consent library
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/thirdParty/cookieconsent.umd.js';
|
||||
script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`;
|
||||
script.onload = () => {
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -8,31 +8,31 @@ export function usePDFProcessor() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generatePageThumbnail = useCallback(async (
|
||||
file: File,
|
||||
pageNumber: number,
|
||||
file: File,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
|
||||
// Clean up using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnail:', error);
|
||||
@@ -42,22 +42,22 @@ export function usePDFProcessor() {
|
||||
|
||||
// Internal function to generate thumbnail from already-opened PDF
|
||||
const generateThumbnailFromPDF = useCallback(async (
|
||||
pdf: any,
|
||||
pageNumber: number,
|
||||
pdf: any,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
): Promise<string> => {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
return canvas.toDataURL();
|
||||
}, []);
|
||||
@@ -65,14 +65,14 @@ export function usePDFProcessor() {
|
||||
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
|
||||
// Create pages without thumbnails initially - load them lazily
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
@@ -84,7 +84,7 @@ export function usePDFProcessor() {
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Generate thumbnails for first 10 pages immediately using the same PDF instance
|
||||
const priorityPages = Math.min(10, totalPages);
|
||||
for (let i = 1; i <= priorityPages; i++) {
|
||||
@@ -95,10 +95,10 @@ export function usePDFProcessor() {
|
||||
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clean up using worker manager
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
|
||||
const document: PDFDocument = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
@@ -106,7 +106,7 @@ export function usePDFProcessor() {
|
||||
pages,
|
||||
totalPages
|
||||
};
|
||||
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF';
|
||||
@@ -123,4 +123,4 @@ export function usePDFProcessor() {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useToolNavigation } from './useToolNavigation';
|
||||
import { useToolManagement } from './useToolManagement';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
|
||||
|
||||
export interface SidebarNavigationProps {
|
||||
@@ -19,7 +19,7 @@ export function useSidebarNavigation(): {
|
||||
getToolNavigation: (toolId: string) => SidebarNavigationProps | null;
|
||||
} {
|
||||
const { getToolNavigation: getToolNavProps } = useToolNavigation();
|
||||
const { getSelectedTool } = useToolManagement();
|
||||
const { getSelectedTool } = useToolWorkflow();
|
||||
|
||||
const defaultNavClick = useCallback((e: React.MouseEvent) => {
|
||||
handleUnlessSpecialClick(e, () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigationState } from '../contexts/NavigationContext';
|
||||
import { useToolNavigation } from './useToolNavigation';
|
||||
import { useToolManagement } from './useToolManagement';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
// Material UI Icons
|
||||
@@ -50,7 +50,7 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'href' | 'onClick'>[] = [
|
||||
export function useSuggestedTools(): SuggestedTool[] {
|
||||
const { selectedTool } = useNavigationState();
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { getSelectedTool } = useToolManagement();
|
||||
const { getSelectedTool } = useToolWorkflow();
|
||||
|
||||
return useMemo(() => {
|
||||
// Filter out the current tool
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToolId } from '../types/toolId';
|
||||
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
|
||||
import { ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { firePixel } from '../utils/scarfTracking';
|
||||
import { withBasePath } from '../constants/app';
|
||||
|
||||
/**
|
||||
* Hook to sync workbench and tool with URL using registry
|
||||
@@ -51,7 +52,8 @@ export function useNavigationUrlSync(
|
||||
} else if (prevSelectedTool.current !== null) {
|
||||
// Only clear URL if we had a tool before (user navigated away)
|
||||
// Don't clear on initial load when both current and previous are null
|
||||
if (window.location.pathname !== '/') {
|
||||
const homePath = withBasePath('/');
|
||||
if (window.location.pathname !== homePath) {
|
||||
clearToolRoute(false); // Use pushState for user navigation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ i18n
|
||||
loadPath: (lngs: string[], namespaces: string[]) => {
|
||||
// Map 'en' to 'en-GB' for loading translations
|
||||
const lng = lngs[0] === 'en' ? 'en-GB' : lngs[0];
|
||||
return `/locales/${lng}/${namespaces[0]}.json`;
|
||||
const basePath = import.meta.env.BASE_URL || '/';
|
||||
const cleanBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
||||
return `${cleanBasePath}/locales/${lng}/${namespaces[0]}.json`;
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import App from './App';
|
||||
import './i18n'; // Initialize i18next
|
||||
import posthog from 'posthog-js';
|
||||
import { PostHogProvider } from 'posthog-js/react';
|
||||
import { BASE_PATH } from './constants/app';
|
||||
|
||||
// Compute initial color scheme
|
||||
function getInitialScheme(): 'light' | 'dark' {
|
||||
@@ -60,7 +61,7 @@ root.render(
|
||||
<PostHogProvider
|
||||
client={posthog}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={BASE_PATH}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</PostHogProvider>
|
||||
|
||||
@@ -110,7 +110,7 @@ export class PDFProcessingService {
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pages.push({
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
* and ensuring proper cleanup when operations complete.
|
||||
*/
|
||||
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
import { GlobalWorkerOptions, getDocument, PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
|
||||
class PDFWorkerManager {
|
||||
private static instance: PDFWorkerManager;
|
||||
@@ -32,7 +30,10 @@ class PDFWorkerManager {
|
||||
*/
|
||||
private initializeWorker(): void {
|
||||
if (!this.isInitialized) {
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Service for detecting signatures in PDF files using PDF.js
|
||||
* This provides a quick client-side check to determine if a PDF contains signatures
|
||||
* without needing to make API calls
|
||||
*/
|
||||
|
||||
// PDF.js types (simplified)
|
||||
declare global {
|
||||
interface Window {
|
||||
pdfjsLib?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SignatureDetectionResult {
|
||||
hasSignatures: boolean;
|
||||
signatureCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileSignatureStatus {
|
||||
file: File;
|
||||
result: SignatureDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect signatures in a single PDF file using PDF.js
|
||||
*/
|
||||
const detectSignaturesInFile = async (file: File): Promise<SignatureDetectionResult> => {
|
||||
try {
|
||||
// Ensure PDF.js is available
|
||||
if (!window.pdfjsLib) {
|
||||
return {
|
||||
hasSignatures: false,
|
||||
error: 'PDF.js not available'
|
||||
};
|
||||
}
|
||||
|
||||
// Convert file to ArrayBuffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Load the PDF document
|
||||
const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
let totalSignatures = 0;
|
||||
|
||||
// Check each page for signature annotations
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
// Count signature annotations (Type: /Sig)
|
||||
const signatureAnnotations = annotations.filter((annotation: any) =>
|
||||
annotation.subtype === 'Widget' &&
|
||||
annotation.fieldType === 'Sig'
|
||||
);
|
||||
|
||||
totalSignatures += signatureAnnotations.length;
|
||||
}
|
||||
|
||||
// Also check for document-level signatures in AcroForm
|
||||
const metadata = await pdf.getMetadata();
|
||||
if (metadata?.info?.Signature || metadata?.metadata?.has('dc:signature')) {
|
||||
totalSignatures = Math.max(totalSignatures, 1);
|
||||
}
|
||||
|
||||
// Clean up PDF.js document
|
||||
pdf.destroy();
|
||||
|
||||
return {
|
||||
hasSignatures: totalSignatures > 0,
|
||||
signatureCount: totalSignatures
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.warn('PDF signature detection failed:', error);
|
||||
return {
|
||||
hasSignatures: false,
|
||||
signatureCount: 0,
|
||||
error: error instanceof Error ? error.message : 'Detection failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if PDF files contain signatures using PDF.js client-side processing
|
||||
*/
|
||||
export const detectSignaturesInFiles = async (files: File[]): Promise<FileSignatureStatus[]> => {
|
||||
const results: FileSignatureStatus[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await detectSignaturesInFile(file);
|
||||
results.push({ file, result });
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing signature detection state
|
||||
*/
|
||||
export const useSignatureDetection = () => {
|
||||
const [detectionResults, setDetectionResults] = React.useState<FileSignatureStatus[]>([]);
|
||||
const [isDetecting, setIsDetecting] = React.useState(false);
|
||||
|
||||
const detectSignatures = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
setDetectionResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDetecting(true);
|
||||
try {
|
||||
const results = await detectSignaturesInFiles(files);
|
||||
setDetectionResults(results);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileSignatureStatus = (file: File): SignatureDetectionResult | null => {
|
||||
const result = detectionResults.find(r => r.file === file);
|
||||
return result ? result.result : null;
|
||||
};
|
||||
|
||||
const hasAnySignatures = detectionResults.some(r => r.result.hasSignatures);
|
||||
const totalSignatures = detectionResults.reduce((sum, r) => sum + (r.result.signatureCount || 0), 0);
|
||||
|
||||
return {
|
||||
detectionResults,
|
||||
isDetecting,
|
||||
detectSignatures,
|
||||
getFileSignatureStatus,
|
||||
hasAnySignatures,
|
||||
totalSignatures,
|
||||
reset: () => setDetectionResults([])
|
||||
};
|
||||
};
|
||||
|
||||
// Import React for the hook
|
||||
import React from 'react';
|
||||
@@ -191,6 +191,8 @@
|
||||
--checkbox-checked-bg: #3FAFFF;
|
||||
--checkbox-tick: #FFFFFF;
|
||||
|
||||
--information-text-bg: #eaeaea;
|
||||
--information-text-color: #5e5e5e;
|
||||
/* Bulk selection panel specific colors (light mode) */
|
||||
--bulk-panel-bg: #ffffff; /* white background for parent container */
|
||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||
@@ -351,6 +353,9 @@
|
||||
/* Tool panel search bar background colors (dark mode) */
|
||||
--tool-panel-search-bg: #1F2329;
|
||||
--tool-panel-search-border-bottom: #4B525A;
|
||||
|
||||
--information-text-bg: #292e34;
|
||||
--information-text-color: #ececec;
|
||||
|
||||
/* Bulk selection panel specific colors (dark mode) */
|
||||
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "../contexts/FileContext";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters";
|
||||
import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation";
|
||||
import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput, Divider } from "@mantine/core";
|
||||
import StampPreview from "../components/tools/addStamp/StampPreview";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import styles from "../components/tools/addStamp/StampPreview.module.css";
|
||||
import { Tooltip } from "../components/shared/Tooltip";
|
||||
import ButtonSelector from "../components/shared/ButtonSelector";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
import ObscuredOverlay from "../components/shared/ObscuredOverlay";
|
||||
import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils";
|
||||
|
||||
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
|
||||
const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false);
|
||||
const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true);
|
||||
|
||||
const params = useAddStampParameters();
|
||||
const operation = useAddStampOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-stamp");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(error?.message || t("AddStampRequest.error.failed", "Add stamp operation failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum AddStampStep {
|
||||
NONE = 'none',
|
||||
STAMP_SETUP = 'stampSetup',
|
||||
POSITION_FORMATTING = 'positionFormatting'
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<AddStampStep>({
|
||||
noneValue: AddStampStep.NONE,
|
||||
initialStep: AddStampStep.STAMP_SETUP,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
});
|
||||
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Stamp Setup
|
||||
steps.push({
|
||||
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
|
||||
value={params.parameters.pageNumbers}
|
||||
onChange={(e) => params.updateParameter('pageNumbers', e.currentTarget.value)}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Divider/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
|
||||
<ButtonSelector
|
||||
value={params.parameters.stampType}
|
||||
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
|
||||
options={[
|
||||
{ value: 'text', label: t('watermark.type.1', 'Text') },
|
||||
{ value: 'image', label: t('watermark.type.2', 'Image') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{params.parameters.stampType === 'text' && (
|
||||
<>
|
||||
<Textarea
|
||||
label={t('AddStampRequest.stampText', 'Stamp Text')}
|
||||
value={params.parameters.stampText}
|
||||
onChange={(e) => params.updateParameter('stampText', e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Select
|
||||
label={t('AddStampRequest.alphabet', 'Alphabet')}
|
||||
value={params.parameters.alphabet}
|
||||
onChange={(v) => {
|
||||
const nextAlphabet = (v as any) || 'roman';
|
||||
params.updateParameter('alphabet', nextAlphabet);
|
||||
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
|
||||
params.updateParameter('fontSize', nextDefault);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'roman', label: 'Roman' },
|
||||
{ value: 'arabic', label: 'العربية' },
|
||||
{ value: 'japanese', label: '日本語' },
|
||||
{ value: 'korean', label: '한국어' },
|
||||
{ value: 'chinese', label: '简体中文' },
|
||||
{ value: 'thai', label: 'ไทย' },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{params.parameters.stampType === 'image' && (
|
||||
<Stack gap="xs">
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) params.updateParameter('stampImage', file);
|
||||
}}
|
||||
disabled={endpointLoading}
|
||||
style={{ display: 'none' }}
|
||||
id="stamp-image-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
component="label"
|
||||
htmlFor="stamp-image-input"
|
||||
disabled={endpointLoading}
|
||||
>
|
||||
{t('chooseFile', 'Choose File')}
|
||||
</Button>
|
||||
{params.parameters.stampImage && (
|
||||
<Stack gap="xs">
|
||||
<img
|
||||
src={URL.createObjectURL(params.parameters.stampImage)}
|
||||
alt="Selected stamp image"
|
||||
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{params.parameters.stampImage.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 3: Formatting & Position
|
||||
steps.push({
|
||||
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
|
||||
{params.parameters.stampType === 'image' && (
|
||||
<ButtonSelector
|
||||
value={quickPositionModeSelected ? 'quick' : 'custom'}
|
||||
onChange={(v: 'quick' | 'custom') => {
|
||||
const isQuick = v === 'quick';
|
||||
setQuickPositionModeSelected(isQuick);
|
||||
setCustomPositionModeSelected(!isQuick);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'quick', label: t('quickPosition', 'Quick Position') },
|
||||
{ value: 'custom', label: t('customPosition', 'Custom Position') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{params.parameters.stampType === 'image' && customPositionModeSelected && (
|
||||
<div className={styles.informationContainer}>
|
||||
<Text className={styles.informationText}>{t('AddStampRequest.customPosition', 'Drag the stamp to the desired location in the preview window.')}</Text>
|
||||
</div>
|
||||
)}
|
||||
{params.parameters.stampType === 'image' && !customPositionModeSelected && (
|
||||
<div className={styles.informationContainer}>
|
||||
<Text className={styles.informationText}>{t('AddStampRequest.quickPosition', 'Select a position on the page to place the stamp.')}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Icon pill buttons row */}
|
||||
<div className="flex justify-between gap-[0.5rem]">
|
||||
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'rotation' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'rotation')}
|
||||
>
|
||||
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'opacity' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'opacity')}
|
||||
>
|
||||
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={params.parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'fontSize')}
|
||||
>
|
||||
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Single slider bound to selected pill */}
|
||||
{params.parameters._activePill === 'fontSize' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{params.parameters.stampType === 'image'
|
||||
? t('AddStampRequest.imageSize', 'Image Size')
|
||||
: t('AddStampRequest.fontSize', 'Font Size')
|
||||
}
|
||||
</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.fontSize}
|
||||
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.fontSize}
|
||||
onChange={(v) => params.updateParameter('fontSize', v as number)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{params.parameters._activePill === 'rotation' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.rotation}
|
||||
onChange={(v) => params.updateParameter('rotation', typeof v === 'number' ? v : 0)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.rotation}
|
||||
onChange={(v) => params.updateParameter('rotation', v as number)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{params.parameters._activePill === 'opacity' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.opacity}
|
||||
onChange={(v) => params.updateParameter('opacity', typeof v === 'number' ? v : 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.opacity}
|
||||
onChange={(v) => params.updateParameter('opacity', v as number)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
{params.parameters.stampType !== 'image' && (
|
||||
<ColorInput
|
||||
label={t('AddStampRequest.customColor', 'Custom Text Color')}
|
||||
value={params.parameters.customColor}
|
||||
onChange={(value) => params.updateParameter('customColor', value)}
|
||||
format="hex"
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Margin selection appears when using quick grid (and for text stamps) */}
|
||||
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
|
||||
<Select
|
||||
label={t('AddStampRequest.margin', 'Margin')}
|
||||
value={params.parameters.customMargin}
|
||||
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('margin.small', 'Small') },
|
||||
{ value: 'medium', label: t('margin.medium', 'Medium') },
|
||||
{ value: 'large', label: t('margin.large', 'Large') },
|
||||
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
|
||||
<ObscuredOverlay
|
||||
obscured={
|
||||
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&
|
||||
((params.parameters.stampType === 'text' && params.parameters.stampText.trim().length === 0) ||
|
||||
(params.parameters.stampType === 'image' && !params.parameters.stampImage))
|
||||
}
|
||||
overlayMessage={
|
||||
<Text size="sm" c="white" fw={600}>
|
||||
{t('AddStampRequest.noStampSelected', 'No stamp selected. Return to Step 1.')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<StampPreview
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
file={selectedFiles[0] || null}
|
||||
showQuickGrid={params.parameters.stampType === 'text' ? true : quickPositionModeSelected}
|
||||
/>
|
||||
</ObscuredOverlay>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t('AddStampRequest.submit', 'Add Stamp'),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t('loading'),
|
||||
onClick: handleExecute,
|
||||
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t('AddStampRequest.results.title', 'Stamp Results'),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
AddStamp.tool = () => useAddStampOperation;
|
||||
|
||||
export default AddStamp as ToolComponent;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import { useBookletImpositionParameters } from "../hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import { useBookletImpositionOperation } from "../hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { useBookletImpositionTips } from "../components/tooltips/useBookletImpositionTips";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const BookletImposition = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'bookletImposition',
|
||||
useBookletImpositionParameters,
|
||||
useBookletImpositionOperation,
|
||||
props
|
||||
);
|
||||
|
||||
const bookletTips = useBookletImpositionTips();
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: bookletTips,
|
||||
content: (
|
||||
<BookletImpositionSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("bookletImposition.submit", "Create Booklet"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("bookletImposition.title", "Booklet Imposition Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default BookletImposition as ToolComponent;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings";
|
||||
import CertificateFormatSettings from "../components/tools/certSign/CertificateFormatSettings";
|
||||
import CertificateFilesSettings from "../components/tools/certSign/CertificateFilesSettings";
|
||||
import SignatureAppearanceSettings from "../components/tools/certSign/SignatureAppearanceSettings";
|
||||
import { useCertSignParameters } from "../hooks/tools/certSign/useCertSignParameters";
|
||||
import { useCertSignOperation } from "../hooks/tools/certSign/useCertSignOperation";
|
||||
import { useCertificateTypeTips } from "../components/tooltips/useCertificateTypeTips";
|
||||
import { useSignatureAppearanceTips } from "../components/tooltips/useSignatureAppearanceTips";
|
||||
import { useSignModeTips } from "../components/tooltips/useSignModeTips";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
|
||||
const CertSign = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'certSign',
|
||||
useCertSignParameters,
|
||||
useCertSignOperation,
|
||||
props
|
||||
);
|
||||
|
||||
const certTypeTips = useCertificateTypeTips();
|
||||
const appearanceTips = useSignatureAppearanceTips();
|
||||
const signModeTips = useSignModeTips();
|
||||
|
||||
// Check if certificate files are configured for appearance step
|
||||
const areCertFilesConfigured = () => {
|
||||
const params = base.params.parameters;
|
||||
|
||||
// Auto mode (server certificate) - always configured
|
||||
if (params.signMode === 'AUTO') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual mode - check for required files based on cert type
|
||||
switch (params.certType) {
|
||||
case 'PEM':
|
||||
return !!(params.privateKeyFile && params.certFile);
|
||||
case 'PKCS12':
|
||||
case 'PFX':
|
||||
return !!params.p12File;
|
||||
case 'JKS':
|
||||
return !!params.jksFile;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
forceStepNumbers: true,
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("certSign.signMode.stepTitle", "Sign Mode"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: signModeTips,
|
||||
content: (
|
||||
<CertificateTypeSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(base.params.parameters.signMode === 'MANUAL' ? [{
|
||||
title: t("certSign.certTypeStep.stepTitle", "Certificate Format"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: certTypeTips,
|
||||
content: (
|
||||
<CertificateFormatSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
}] : []),
|
||||
...(base.params.parameters.signMode === 'MANUAL' ? [{
|
||||
title: t("certSign.certFiles.stepTitle", "Certificate Files"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
content: (
|
||||
<CertificateFilesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
}] : []),
|
||||
{
|
||||
title: t("certSign.appearance.stepTitle", "Signature Appearance"),
|
||||
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),
|
||||
onCollapsedClick: (base.settingsCollapsed || !areCertFilesConfigured()) ? base.handleSettingsReset : undefined,
|
||||
tooltip: appearanceTips,
|
||||
content: (
|
||||
<SignatureAppearanceSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("certSign.sign.submit", "Sign PDF"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("certSign.sign.results", "Signed PDF"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
CertSign.tool = () => useCertSignOperation;
|
||||
|
||||
export default CertSign as ToolComponent;
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
import { withBasePath } from "../constants/app";
|
||||
|
||||
const SwaggerUI: React.FC<BaseToolProps> = () => {
|
||||
useEffect(() => {
|
||||
// Redirect to Swagger UI
|
||||
window.open("/swagger-ui/5.21.0/index.html", "_blank");
|
||||
window.open(withBasePath("/swagger-ui/5.21.0/index.html"), "_blank");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -12,7 +13,7 @@ const SwaggerUI: React.FC<BaseToolProps> = () => {
|
||||
<p>Opening Swagger UI in a new tab...</p>
|
||||
<p>
|
||||
If it didn't open automatically,{" "}
|
||||
<a href="/swagger-ui/5.21.0/index.html" target="_blank" rel="noopener noreferrer">
|
||||
<a href={withBasePath("/swagger-ui/5.21.0/index.html")} target="_blank" rel="noopener noreferrer">
|
||||
click here
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Define all possible tool IDs as source of truth
|
||||
const TOOL_IDS = [
|
||||
export const TOOL_IDS = [
|
||||
'certSign',
|
||||
'sign',
|
||||
'addPassword',
|
||||
@@ -55,6 +55,7 @@ const TOOL_IDS = [
|
||||
'devFolderScanning',
|
||||
'devSsoGuide',
|
||||
'devAirgapped',
|
||||
'bookletImposition',
|
||||
] as const;
|
||||
|
||||
// Tool identity - what PDF operation we're performing (type-safe)
|
||||
|
||||
@@ -410,7 +410,7 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
@@ -2,10 +2,20 @@ import { ToolId } from '../types/toolId';
|
||||
|
||||
// Map URL paths to tool keys (multiple URLs can map to same tool)
|
||||
export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/split-pdfs': 'split',
|
||||
// Basic tools - standard patterns
|
||||
'/split': 'split',
|
||||
'/split-pdfs': 'split',
|
||||
'/merge': 'merge',
|
||||
'/merge-pdfs': 'merge',
|
||||
'/compress': 'compress',
|
||||
'/compress-pdf': 'compress',
|
||||
'/rotate': 'rotate',
|
||||
'/rotate-pdf': 'rotate',
|
||||
'/repair': 'repair',
|
||||
'/flatten': 'flatten',
|
||||
'/crop': 'crop',
|
||||
|
||||
// Convert tool and all its variants
|
||||
'/convert': 'convert',
|
||||
'/convert-pdf': 'convert',
|
||||
'/file-to-pdf': 'convert',
|
||||
@@ -18,17 +28,101 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/pdf-to-pdfa': 'convert',
|
||||
'/pdf-to-word': 'convert',
|
||||
'/pdf-to-xml': 'convert',
|
||||
|
||||
// Security tools
|
||||
'/add-password': 'addPassword',
|
||||
'/remove-password': 'removePassword',
|
||||
'/change-permissions': 'changePermissions',
|
||||
'/cert-sign': 'certSign',
|
||||
'/manage-signatures': 'certSign',
|
||||
'/remove-certificate-sign': 'removeCertSign',
|
||||
'/remove-cert-sign': 'removeCertSign',
|
||||
'/unlock-pdf-forms': 'unlockPDFForms',
|
||||
'/validate-signature': 'validateSignature',
|
||||
'/manage-certificates': 'manageCertificates',
|
||||
|
||||
// Content manipulation
|
||||
'/sanitize': 'sanitize',
|
||||
'/sanitize-pdf': 'sanitize',
|
||||
'/ocr': 'ocr',
|
||||
'/ocr-pdf': 'ocr',
|
||||
'/watermark': 'watermark',
|
||||
'/add-watermark': 'watermark',
|
||||
'/remove-password': 'removePassword',
|
||||
'/add-image': 'addImage',
|
||||
'/add-stamp': 'addStamp',
|
||||
'/add-page-numbers': 'addPageNumbers',
|
||||
'/redact': 'redact',
|
||||
|
||||
// Page manipulation
|
||||
'/remove-pages': 'removePages',
|
||||
'/remove-blanks': 'removeBlanks',
|
||||
'/extract-pages': 'extractPages',
|
||||
'/reorganize-pages': 'reorganizePages',
|
||||
'/single-large-page': 'pdfToSinglePage',
|
||||
'/repair': 'repair',
|
||||
'/rotate-pdf': 'rotate',
|
||||
'/unlock-pdf-forms': 'unlockPDFForms',
|
||||
'/remove-certificate-sign': 'removeCertSign',
|
||||
'/remove-cert-sign': 'removeCertSign',
|
||||
'/page-layout': 'pageLayout',
|
||||
'/scale-pages': 'scalePages',
|
||||
'/booklet-imposition': 'bookletImposition',
|
||||
|
||||
// Splitting tools
|
||||
'/auto-split-pdf': 'autoSplitPDF',
|
||||
'/auto-size-split-pdf': 'autoSizeSplitPDF',
|
||||
'/scanner-image-split': 'scannerImageSplit',
|
||||
|
||||
// Annotation and content removal
|
||||
'/remove-annotations': 'removeAnnotations',
|
||||
'/remove-image': 'removeImage',
|
||||
|
||||
// Image and visual tools
|
||||
'/extract-images': 'extractImages',
|
||||
'/adjust-contrast': 'adjustContrast',
|
||||
'/fake-scan': 'fakeScan',
|
||||
'/replace-color-pdf': 'replaceColorPdf',
|
||||
|
||||
// Metadata and info
|
||||
'/change-metadata': 'changeMetadata',
|
||||
'/get-pdf-info': 'getPdfInfo',
|
||||
'/add-attachments': 'addAttachments',
|
||||
|
||||
// Advanced tools
|
||||
'/overlay-pdfs': 'overlayPdfs',
|
||||
'/edit-table-of-contents': 'editTableOfContents',
|
||||
'/auto-rename': 'autoRename',
|
||||
'/compare': 'compare',
|
||||
'/multi-tool': 'multiTool',
|
||||
'/show-js': 'showJS',
|
||||
|
||||
// Special/utility tools
|
||||
'/read': 'read',
|
||||
'/automate': 'automate',
|
||||
'/sign': 'sign',
|
||||
|
||||
// Developer tools
|
||||
'/dev-api': 'devApi',
|
||||
'/dev-folder-scanning': 'devFolderScanning',
|
||||
'/dev-sso-guide': 'devSsoGuide',
|
||||
'/dev-airgapped': 'devAirgapped',
|
||||
|
||||
// Legacy URL mappings from sitemap
|
||||
'/pdf-organizer': 'reorganizePages',
|
||||
'/multi-page-layout': 'pageLayout',
|
||||
'/extract-page': 'extractPages',
|
||||
'/pdf-to-single-page': 'pdfToSinglePage',
|
||||
'/img-to-pdf': 'convert',
|
||||
'/pdf-to-presentation': 'convert',
|
||||
'/pdf-to-text': 'convert',
|
||||
'/pdf-to-html': 'convert',
|
||||
'/auto-redact': 'redact',
|
||||
'/stamp': 'addStamp',
|
||||
'/view-pdf': 'read',
|
||||
'/get-info-on-pdf': 'getPdfInfo',
|
||||
'/remove-image-pdf': 'removeImage',
|
||||
'/replace-and-invert-color-pdf': 'replaceColorPdf',
|
||||
'/pipeline': 'automate',
|
||||
'/extract-image-scans': 'scannerImageSplit',
|
||||
'/show-javascript': 'showJS',
|
||||
'/scanner-effect': 'fakeScan',
|
||||
'/split-by-size-or-count': 'autoSizeSplitPDF',
|
||||
'/overlay-pdf': 'overlayPdfs',
|
||||
'/split-pdf-by-sections': 'autoSplitPDF',
|
||||
'/split-pdf-by-chapters': 'autoSplitPDF',
|
||||
};
|
||||
|
||||
@@ -8,12 +8,17 @@ import { getDefaultWorkbench } from '../types/workbench';
|
||||
import { ToolRegistry, getToolWorkbench, getToolUrlPath } from '../data/toolsTaxonomy';
|
||||
import { firePixel } from './scarfTracking';
|
||||
import { URL_TO_TOOL_MAP } from './urlMapping';
|
||||
import { BASE_PATH, withBasePath } from '../constants/app';
|
||||
|
||||
/**
|
||||
* Parse the current URL to extract tool routing information
|
||||
*/
|
||||
export function parseToolRoute(registry: ToolRegistry): ToolRoute {
|
||||
const path = window.location.pathname;
|
||||
const fullPath = window.location.pathname;
|
||||
// Remove base path to get app-relative path
|
||||
const path = BASE_PATH && fullPath.startsWith(BASE_PATH)
|
||||
? fullPath.slice(BASE_PATH.length) || '/'
|
||||
: fullPath;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// First, check URL mapping for multiple URL aliases
|
||||
@@ -83,7 +88,8 @@ export function updateToolRoute(toolId: ToolId, registry: ToolRegistry, replace:
|
||||
return;
|
||||
}
|
||||
|
||||
const newPath = getToolUrlPath(toolId, tool);
|
||||
const toolPath = getToolUrlPath(toolId, tool);
|
||||
const newPath = withBasePath(toolPath);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Remove tool query parameter since we're using path-based routing
|
||||
@@ -99,7 +105,7 @@ export function clearToolRoute(replace: boolean = false): void {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.delete('tool');
|
||||
|
||||
updateUrl('/', searchParams, replace);
|
||||
updateUrl(withBasePath('/'), searchParams, replace);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,11 +123,12 @@ export function generateShareableUrl(toolId: ToolId | null, registry: ToolRegist
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
if (!toolId || !registry[toolId]) {
|
||||
return baseUrl;
|
||||
return `${baseUrl}${BASE_PATH || ''}`;
|
||||
}
|
||||
|
||||
const tool = registry[toolId];
|
||||
|
||||
const path = getToolUrlPath(toolId, tool);
|
||||
return `${baseUrl}${path}`;
|
||||
const toolPath = getToolUrlPath(toolId, tool);
|
||||
const fullPath = withBasePath(toolPath);
|
||||
return `${baseUrl}${fullPath}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -12,5 +12,10 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
base: "./",
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: 'es2020'
|
||||
}
|
||||
},
|
||||
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -37,4 +37,4 @@ export default defineConfig({
|
||||
'@': '/src'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user