Compare commits

...
Author SHA1 Message Date
Reece 126b4dc1b2 Nginx again 2025-09-25 15:38:04 +01:00
Reece 9be7f9637e nginx changes 2025-09-25 14:45:58 +01:00
Reece 4cf106750c nginx update 2025-09-25 14:04:49 +01:00
Reece BrowneandGitHub 883b167e55 Update vite.config.ts 2025-09-25 13:49:30 +01:00
Reece d12214c689 Clean and install frontend dependencies in build workflow 2025-09-25 12:40:43 +01:00
Reece ef76de8320 Lock mantine package versions 2025-09-25 12:31:23 +01:00
Reece 23f97029a2 Lock mantine version 2025-09-25 12:26:39 +01:00
Reece BrowneandGitHub 56efcba5d0 Remove HTTPS configuration from vite.config.ts
Removed HTTPS configuration from Vite server settings.
2025-09-25 11:44:55 +01:00
Reece bc3bcb9c25 fix 2025-09-25 11:02:05 +01:00
Anthony StirlingandGitHub 166f6d2d98 path (#4488)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-24 20:37:51 +01:00
Anthony StirlingandGitHub 6441dc1d6f fixes V2 (#4483)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-24 15:01:18 +01:00
25bedf064f Update PDF.js integration and dependencies (#4360)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-24 13:07:27 +01:00
EthanHealy01andGitHub 13eff6b333 Addition of the Add Stamp to PDF tool (#4440) 2025-09-23 14:01:42 +01:00
Anthony StirlingGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>a <a>ConnorYohReece Browne
46a4a978fc Booklet and server sign (#4371)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: a <a>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-23 11:24:48 +01:00
80 changed files with 7678 additions and 64297 deletions
+85 -89
View File
@@ -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
+2 -2
View File
@@ -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
+69 -5
View File
@@ -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")
@@ -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");
@@ -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();
}
}
}
@@ -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());
@@ -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();
}
@@ -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,
@@ -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;
}
@@ -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
@@ -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);
}
}
}
@@ -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());
}
}
@@ -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());
}
}
}
+14 -1
View File
@@ -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";
+1
View File
@@ -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" />
+3044 -3588
View File
File diff suppressed because it is too large Load Diff
+76 -59
View File
@@ -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"
]
}
}
+378 -48
View File
@@ -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"
}
+18 -1
View File
@@ -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
-58353
View File
File diff suppressed because one or more lines are too long
+57 -48
View File
@@ -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
+2 -3
View File
@@ -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;
}
+2 -1
View File
@@ -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")
]
}
]
};
};
+16
View File
@@ -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,
+1
View File
@@ -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;
}
},
});
};
+1
View File
@@ -23,6 +23,7 @@ export interface AppConfig {
license?: string;
GoogleDriveEnabled?: boolean;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;
}
+4 -3
View File
@@ -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(() => {
+21 -21
View File
@@ -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
};
}
}
+2 -2
View File
@@ -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, () => {
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
}
}
+3 -1
View File
@@ -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`;
},
},
+2 -1
View File
@@ -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 -4
View File
@@ -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';
+5
View File
@@ -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 */
+413
View File
@@ -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;
+59
View File
@@ -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;
+131
View File
@@ -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;
+3 -2
View File
@@ -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>
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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);
+101 -7
View File
@@ -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',
};
+13 -6
View File
@@ -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}`;
}
+7 -2
View File
@@ -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}` : './',
});
+2 -2
View File
@@ -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'
}
}
})
})