Compare commits

..
Author SHA1 Message Date
EthanHealy01 ff59e6a96a feat(review): give recorded failures a Review screen in the processor
Lifts the failures list out of the Documents view onto a screen of its own,
rendered as a filterable table: faceted filters with live per-value counts,
an Open/Closed scope switch, and the raw log behind a modal rather than
printed into every row.

Row actions are server-executed only, so a reviewer finishes in the processor
instead of being sent to the editor. Adds a reusable DataTableFilterBar to the
shared UI, and a `closed` flag on the file-run-events read so the settled queue
can be listed at all - the default read has only ever returned open rows, and
the page limit is applied in the query, so this cannot be filtered client-side.

The screen, its nav tab, its search entry and the bell action that links to it
are all gated on one dev-only flag.
2026-09-03 17:22:03 +01:00
EthanHealy01 970ab58b0b test: keep the policy fixtures runnable under both eligibility filters
Main gates the upload chain on an enabled flag; this branch still gates
it on status. A fixture carrying only one of them is a policy the other
filter drops, so the same three tests fail on whichever side lacks it.
Both flags, so the branch is green alone and green merged.
2026-09-03 16:43:19 +01:00
EthanHealy01 6b9509dfdc test: follow main's policy eligibility from status to enabled
Main replaced status === "active" with an enabled flag on PolicyState,
so these fixtures no longer produced a policy the chain would run and
three tests failed on the merge rather than on anything they assert.
2026-09-03 16:40:13 +01:00
EthanHealy01 097efd74e4 fix: keep a batch's retries whole, and tidy the stash's edges
Three of the review's findings, all in this PR's own code:

- Eviction treated a multi-file failure as 30 unrelated records sharing
  one recordedAt, so a batch over the cap kept an arbitrary 25 of its
  files and dropped the rest. The batch being written is now exempt from
  eviction and the fileId breaks the recordedAt tie, so what goes is the
  older unrelated stash rather than half of the failure on screen.
- A resolved failure's stash is deleted rather than left to age out.
- pendingUnlocks clears on FileContext teardown: the store outlives the
  provider, and a hold nobody can answer would stall that file's policy
  for the rest of the session.

KIND_ERROR_CODES now names the server-side test that pins what it
mirrors, and that test asserts the codes rather than only the lookup, so
adding one server-side fails until the mirror moves too.
2026-09-02 15:12:58 +01:00
EthanHealy01 1021307d5a fix: carry the retry work onto the new editor pipeline
Rebased onto main after 5a squash-merged, which also brought #7581's
rewrite of the upload chain. Where that moved first, this follows it:

- The AI escalation decision belongs to the local-pass engine now, so
  the localPassFailed gate and its store helper go. A pass that produces
  no verdict leaves the file eligible and a reload retries it, which is
  #7581's answer to what that gate was for.
- uploadChain.ts went the same way: main already orders the chain, as
  orderedRewritingCategories, and keeps its own nextUploadCategory. The
  policy retry resumes off main's ordering instead. An annotating policy
  is no longer in the chain, so a retry of one re-runs just that policy.
- aiEnabled was threaded through the retry path only to reach the chain
  ordering, which no longer takes it, so it goes from all three hooks.
- Test fixtures move from sources: ["editor"] to the runsOnEditor flag
  that replaced it.

The unlock hold-back is ported onto main's dispatch loop unchanged.
2026-09-01 19:07:19 +01:00
EthanHealy01 83256a0af4 test: match the slot fixtures to what FailureKind declares
The reviewer's point: VIEW_FILE and VIEW_IN_PROCESSOR were swapped
against FailureKind.java in both kinds' fixtures, so the promotion tests
pinned outcomes for a shape no server sends - a reviewer's password
failure was asserted to put the queue in the secondary slot when the
server ranks the document there. Key and argument order now copy the
declarations too, since declared order is the tiebreak within a slot.

The same drift in useResolutionContinuation's kind-shaped rows is fixed
with it. No production code changes: the server was always right.
2026-09-01 18:59:29 +01:00
EthanHealy01 5d75f96f20 docs: cut the comments on the review fixes back to one line
The reviewer's point on the action ids: the docs said at length what the
names and the slot already say. Each is now a single line where it still
carries something, and gone where the code carries it.
2026-09-01 18:59:29 +01:00
EthanHealy01 9df2c64029 style: run oxfmt over the renamed action ids 2026-09-01 18:59:29 +01:00
EthanHealy01 35256def38 review: follow the action renames through the retry handlers
OPEN_IN_TOOL and DECRYPT replace RETRY and DECRYPT_AND_RETRY in the
client registry, and the specs take the names of the ids they answer
for. Behaviour is unchanged: OPEN_IN_TOOL still opens the failed tool
with the document selected rather than re-running it, and DECRYPT still
unlocks, adopts and re-runs.
2026-09-01 18:59:29 +01:00
EthanHealy01 c7343f1d69 feat(failure): retry and decrypt-and-retry for recorded failures
The client half of retry, on top of the slot/resolve PR. useToolOperation
stashes what a failed run needs to run again (endpoint, parameters, file
ids - passwords stripped at any depth, 25 records, oldest evicted) in its
own IndexedDB database. Retry on an editor failure opens the failed tool
with the document selected. Decrypt and retry opens the app's unlock
modal, re-runs the stashed operation - or, for a policy failure, unlocks
via /security/remove-password and re-runs the stored policy - adopts the
result by versioning the encrypted original in place, and reports the row
resolved. A policy re-run is registered with the run store so it polls to
terminal, honours outputMode, and rejoins the rest of the upload chain;
an upload's chain now also holds back until its unlock prompt is
answered. hasLocalFile moves into the retry stash module, which replaces
localFilePresence.
2026-09-01 18:59:28 +01:00
EthanHealy01 f6661a8f87 Failure action slots, resolve transition, and the bell that renders them (Review Flow PR 5a) (#7761)
Review Flow PR 5a — the first half of #7479, which stays open for
reference until both halves land. This PR is the ranking and the
bookkeeping; #7762 adds the retry handlers. Merging both reproduces
#7479's diff byte-for-byte.

## What's added

**The action slot model (backend).** `FailureActionSlot` ranks each of a
kind's offers as its `RESOLUTION`, `SECONDARY` or `OVERFLOW`.
`FailureKind` now declares placement per offer — the password-protected
kind names `DECRYPT_AND_RETRY` as its resolution, `UNKNOWN` leads with a
plain `RETRY` — and `FailureActionId` gains those two ids. The
declarations are data; their client handlers arrive in the follow-up, so
this build withholds them with a reason rather than rendering unwired
buttons (the same forward-compatibility #7478 relied on).

**A resolve transition.** `POST /api/v1/notifications/{id}/resolved`
lets a client report a failure fixed. `NotificationSource.parse` turns a
qualified notification id back into the source that owns it, and
`FileRunEventService` folds the resolution into the incident rather than
deleting it.

**`viewerReviewsTeam` on the list response.** A member sees only rows
whose document this browser holds — they can neither open nor fix
anything else — while a team reviewer keeps every row.

**The bell renders the ranking** (`promoteActions`): one primary button,
at most one secondary, the rest in an overflow menu beside **Copy log**.
The row's body is the kind's own sentence; the raw failure message moves
into the menu.

**Read state is a timestamp, not a row id.** `readThroughAt` replaces
`lastSeenId`: when a resolved or dismissed row leaves the list, the rows
below it stay read instead of re-lighting the badge.

## How to test

Needs a proprietary or SaaS build with login enabled (`task dev:all`,
sign in).

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now**; the upload's policy run fails on it.
2. **Open the bell.** The row reads the kind's sentence, not a stack
trace. Its primary button is **View file** — the server offers Decrypt
and retry as the resolution, but this build withholds it (handler lands
in the follow-up), so the best renderable offer is promoted instead.
3. **Open the row's ⋯ menu.** View in processor and Dismiss sit there,
along with **Copy log**, which copies the raw message.
4. **Check the read marker survives a departure.** With two failures,
open the bell (badge clears), dismiss the newer row, and refresh: the
badge stays dark. On main, the marker held the departed row's id and the
older row re-read as unread.
5. **Member visibility.** As a plain member, a failure recorded from
another browser does not appear in the bell; as a team reviewer it does.
6. **Resolve endpoint.** `POST
/api/v1/notifications/failure-{eventId}/resolved` as the owner removes
the row on the next poll; `NotificationResolveTest` pins refusal for a
non-owner, an unknown id, and a foreign prefix.

## Migration

None.
2026-09-01 13:25:19 +00:00
Anthony StirlingandJames Brunton ceeec53df4 Let a pipeline run on the editor, on upload or export (#7581)
Redesigns the policies system so that the backend has an understanding
of policies running over the Editor. The Editor is not set up as a
source for the backend because the backend can't actively get files from
it, they come in via the frontend sending them to the backend, so
instead pipelines have a specific editor key in them to encode whether
the pipeline is triggered on file upload/export in the editor.

Also make a big effort in the frontend code towards genericising policy
running. Previously, there was specific support in the main policy
executor for each policy that it had to run, which was not going to be
appropriate long-term, especially when users can run any pipeline in the
editor. There's more work needed here for me to really be happy with it
but this PR is plenty large on its own and moves it in the right
direction.

All of the above was required to allow arbitrary user pipelines to run
in the editor. This PR makes it so that the user can select Editor as a
source in the pipeline creator, along with whether it should run on
upload or export.

<img width="1437" height="506" alt="image"
src="https://github.com/user-attachments/assets/b2d176a1-185c-480b-9916-abdd1447d8e1"
/>

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-09-01 13:12:06 +00:00
ConnorYoh 4ef2e3811c ci(preview): give PR previews the Stirling account config they need to link (#7728)
Add CI steps to enable PR deploy servers to link to prod saas. This will
allow pr testing of payment flows, usage of real credits etc
2026-09-01 12:35:57 +00:00
ConnorYoh 31d52d4c32 Connect flow for self-hosted account linking, and the triggers that drive it (#7415)
Replaces the bare account-link login box with a guided Connect flow, and
wires up the triggers that actually put it in front of someone.
## Top bar 
<img width="1580" height="422" alt="image"
src="https://github.com/user-attachments/assets/719e12fc-121a-4caa-bc72-124c5167b011"
/>

## The modal

Three steps on the portal's own `FlowModal` + `StepModalHeader`, the
shells procurement and prepay already wear:

1. **What you unlock** — six benefits as a plain list.
<img width="817" height="503" alt="image"
src="https://github.com/user-attachments/assets/4644ddd2-6181-44e1-9be9-7a961972195d"
/>

2. **Sign in** — the existing `SupabaseLoginForm`, reseated.
<img width="880" height="930" alt="image"
src="https://github.com/user-attachments/assets/fc66cbbb-9f98-40a4-9daa-4f2447713f39"
/>

3. **Connected** — confirms, then deep links into Users, Pipelines and
Policies.
<img width="876" height="752" alt="image"
src="https://github.com/user-attachments/assets/28358e4d-a44f-4118-a8ae-8275984ebd00"
/>


Re-auth stays a single step with no pitch and no success screen.

## The triggers

**`LinkGate` stops being dead code.** It was built as the drop-anywhere
"link to unlock" wrapper and was imported by nothing. It is now a
blocking empty state that replaces the feature it guards, wired into
Pipelines, Policies, Users, Sources and Integrations.

**Scoped to creating and editing, never viewing.** Existing pipelines,
policies, sources and connections keep listing and running, so upgrading
an unlinked instance cannot take away something that already works. The
clicks that would open a builder or a create modal ask for the
connection first, which is the moment an admin has already declared
intent.

## Capability signal

`accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two
facts: whether the instance is linked (`LinkContext`) and whether it
*could* be (this flag). The account-link endpoints 404 when the feature
flag is off, which the client cannot distinguish from "not linked yet" —
so gating on link state alone would lock all five views on every default
install with no way out. `useConnectGate` holds that decision in one
place and shares the app-config query key, so it costs no extra request.

Read from the environment rather than `AccountLinkProperties` because
`:core` cannot depend on `:proprietary`.
2026-09-01 10:39:57 +00:00
440 changed files with 13345 additions and 4853 deletions
+46
View File
@@ -220,6 +220,42 @@ jobs:
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
fi
# The Stirling account previews connect to. Derived from the ref rather than stored as a URL
# so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected
# by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in.
# Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not
# itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable.
- name: Resolve Stirling account config
id: saas
env:
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }}
run: |
# Set, this is the one value both halves use: the browser's portal reads and the backend's
# register/entitlement calls have to land on the same SaaS, and nothing checks that they
# do. Unset, only the backend gets a base, from its own compiled-in default.
API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}"
echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT"
if [ -z "${PROJECT_REF}" ]; then
echo "Not configured for this environment: the preview will build without a Stirling"
echo "account, and the connect dialog will say so. To wire one up, set on the"
echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and"
echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project."
echo "supabase_url=" >> "$GITHUB_OUTPUT"
echo "frontend_base=" >> "$GITHUB_OUTPUT"
else
# Only whether, not which: the ref is a secret here, so Actions masks it out of any
# line it appears in, derived URL included.
echo "Stirling account configured, at ${API_BASE}."
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
# Deliberately the override and not API_BASE: the backend's default is a subpath URL
# nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames,
# so portal reads stay off until someone sets a base they have checked. Empty leaves the
# committed .env default alone, which is the clean "not configured" state.
echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT"
fi
- name: Check if image exists
id: check-image
run: |
@@ -246,6 +282,9 @@ jobs:
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }}
platforms: linux/amd64
- name: Set up SSH
@@ -279,6 +318,13 @@ jobs:
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}"
# Off so preview traffic never accrues against a real wallet or trips its cap. The
# 402 gate is separate and stays on, so gating is still testable here.
STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false"
# Stated rather than inferred from the request: the callback has to come back to the
# preview hostname, not to the container's own :8080 behind this proxy.
SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
+1 -1
View File
@@ -42,7 +42,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.35.0",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
@@ -174,8 +174,7 @@ public class EndpointConfiguration {
&& disabledGroups.contains(group)
&& entry.getValue().contains(endpoint)) {
log.debug(
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no"
+ " alternatives)",
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
original,
group);
return false;
@@ -334,8 +333,7 @@ public class EndpointConfiguration {
String.join(", ", functionallyDisabledEndpoints));
} else if (!disabledToolGroups.isEmpty()) {
log.info(
"No endpoints disabled despite missing tools - fallback implementations"
+ " available");
"No endpoints disabled despite missing tools - fallback implementations available");
}
}
@@ -85,8 +85,7 @@ public class AutoJobAspect {
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution:"
+ " {}",
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
// Rethrow RuntimeException as-is to preserve exception type
@@ -166,8 +165,8 @@ public class AutoJobAspect {
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution"
+ " (attempt {}/{}): {}",
"AutoJobAspect caught exception during job execution (attempt"
+ " {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
@@ -184,8 +183,7 @@ public class AutoJobAspect {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in"
+ " TaskManager",
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API
// access
@@ -43,9 +43,9 @@ public class ClusterConfig {
} else if ("inprocess".equalsIgnoreCase(backplane)) {
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
log.warn(
"cluster.enabled=true with backplane=inprocess - only the local JVM is"
+ " coordinated. Cross-node lookups and the file proxy will fail. Use"
+ " backplane=valkey for real multi-node deployments.");
"cluster.enabled=true with backplane=inprocess - only the local"
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
+ " Use backplane=valkey for real multi-node deployments.");
} else {
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
@@ -230,14 +230,12 @@ public class RuntimePathConfig {
// Check if one path is a parent of the other
if (path1.startsWith(path2)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause"
+ " duplicate processing",
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path1,
path2);
} else if (path2.startsWith(path1)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause"
+ " duplicate processing",
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path2,
path1);
}
@@ -255,24 +253,21 @@ public class RuntimePathConfig {
// Check if watched folder is same as finished folder
if (watchedPath.equals(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' -"
+ " this will cause processing loops!",
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
// Check if watched folder contains finished folder
else if (finishedPath.startsWith(watchedPath)) {
log.warn(
"Finished folder '{}' is nested inside watched folder '{}' - this may"
+ " cause issues",
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
finishedPath,
watchedPath);
}
// Check if finished folder contains watched folder
else if (watchedPath.startsWith(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' -"
+ " this will cause processing loops!",
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
@@ -300,17 +295,15 @@ public class RuntimePathConfig {
// Warn if manual endpoint count doesn't match sessionLimit
if (configured.size() != sessionLimit) {
log.warn(
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit"
+ " ({}). Concurrency will be limited by endpoint count, not"
+ " sessionLimit.",
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
configured.size(),
sessionLimit);
}
return configured;
}
log.warn(
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to"
+ " 127.0.0.1:2003.");
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
@@ -144,8 +144,7 @@ public class ApplicationProperties {
sizeInMB);
} else {
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999),"
+ " ignoring",
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
@@ -24,8 +24,7 @@ public class PDFFile {
@Schema(
description =
"File ID for server-side files (can be used instead of fileInput if job was"
+ " previously done on file in async mode)")
"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")
@@ -209,8 +209,7 @@ public class ResourceMonitor {
return (double) m.invoke(osMXBean);
} catch (Exception e2) {
log.trace(
"Could not get CPU load through reflection, assuming moderate load"
+ " (0.5)");
"Could not get CPU load through reflection, assuming moderate load (0.5)");
return 0.5;
}
}
@@ -167,8 +167,7 @@ public class TempFileCleanupService {
|| unregisteredDeletedCount > 0
|| directoriesDeletedCount > 0) {
log.info(
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered"
+ " files, {} directories",
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
registeredDeletedCount,
unregisteredDeletedCount,
directoriesDeletedCount);
@@ -253,8 +252,7 @@ public class TempFileCleanupService {
dirDeletedCount.incrementAndGet();
if (log.isDebugEnabled()) {
log.debug(
"Deleted temp file during {} cleanup:"
+ " {}",
"Deleted temp file during {} cleanup: {}",
phase,
path);
}
@@ -41,8 +41,7 @@ public class AttachmentUtils {
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments"
+ " pane");
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
log.error("Failed to set catalog viewer preferences for attachments", e);
@@ -342,26 +342,26 @@ public class EmlProcessingUtils {
private String getFallbackStyles() {
return """
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px;
word-wrap: break-word;
}
.email-container { max-width: 100%; }
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
.email-meta { font-size: 12px; color: #666; }
.email-body { line-height: 1.6; }
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
img { max-width: 100%; height: auto; }
""";
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px;
word-wrap: break-word;
}
.email-container { max-width: 100%; }
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
.email-meta { font-size: 12px; color: #666; }
.email-body { line-height: 1.6; }
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
img { max-width: 100%; height: auto; }
""";
}
private void appendAttachmentsSection(
@@ -290,8 +290,7 @@ public class ExceptionUtils {
// Additional safety check: warn about very large images (> 1GB estimated)
if (estimatedBytes > 1024L * 1024 * 1024) {
log.warn(
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This"
+ " may cause memory issues.",
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This may cause memory issues.",
pageNumber,
widthInPixels,
heightInPixels,
@@ -395,8 +394,7 @@ public class ExceptionUtils {
message = getMessage(contextKey, defaultMsg, context);
} else {
message =
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
+ " feature first to fix the file before proceeding with this operation.";
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
}
return new PdfCorruptedException(message, cause, ErrorCode.PDF_CORRUPTED.getCode());
@@ -1121,25 +1119,19 @@ public class ExceptionUtils {
PDF_CORRUPTED(
"E001",
"error.pdfCorrupted",
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
+ " feature first to fix the file before proceeding with this operation."),
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."),
PDF_MULTIPLE_CORRUPTED(
"E002",
"error.multiplePdfCorrupted",
"One or more PDF files appear to be corrupted or damaged. Please try using the"
+ " 'Repair PDF' feature on each file first before attempting to merge them."),
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."),
PDF_ENCRYPTION(
"E003",
"error.pdfEncryption",
"The PDF appears to have corrupted encryption data. This can happen when the PDF"
+ " was created with incompatible encryption methods. Please try using the"
+ " 'Repair PDF' feature first, or contact the document creator for a new"
+ " copy."),
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."),
PDF_PASSWORD(
"E004",
"error.pdfPassword",
"The PDF Document is passworded and either the password was not provided or was"
+ " incorrect"),
"The PDF Document is passworded and either the password was not provided or was incorrect"),
PDF_NO_PAGES("E005", "error.pdfNoPages", "PDF file contains no pages"),
PDF_NOT_PDF("E006", "error.notPdfFile", "File must be in PDF format"),
@@ -1147,25 +1139,20 @@ public class ExceptionUtils {
CBR_INVALID_FORMAT(
"E010",
"error.cbrInvalidFormat",
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an"
+ " unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR"
+ " archive."),
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive."),
CBR_NO_IMAGES(
"E012",
"error.cbrNoImages",
"No valid images found in the CBR file. The archive may be empty, or all images may"
+ " be corrupted or in unsupported formats."),
"No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
CBR_NOT_CBR("E014", "error.notCbrFile", "File must be a CBR or RAR archive"),
CBZ_INVALID_FORMAT(
"E015",
"error.cbzInvalidFormat",
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not"
+ " be a valid ZIP archive."),
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive."),
CBZ_NO_IMAGES(
"E016",
"error.cbzNoImages",
"No valid images found in the CBZ file. The archive may be empty, or all images may"
+ " be corrupted or in unsupported formats."),
"No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
CBZ_NOT_CBZ("E018", "error.notCbzFile", "File must be a CBZ or ZIP archive"),
// EML errors
@@ -1218,8 +1205,7 @@ public class ExceptionUtils {
FFMPEG_REQUIRED(
"E063",
"error.ffmpegRequired",
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is"
+ " available on the system PATH."),
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH."),
// Validation errors
INVALID_ARGUMENT("E070", "error.invalidArgument", "Invalid argument ''{0}'': {1}"),
@@ -1235,10 +1221,7 @@ public class ExceptionUtils {
OUT_OF_MEMORY_DPI(
"E081",
"error.outOfMemoryDpi",
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI."
+ " This can occur when the resulting image exceeds Java's array/memory limits"
+ " (e.g., NegativeArraySizeException). Please use a lower DPI value"
+ " (recommended: 150 or less) or process the document in smaller chunks.");
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.");
private final String code;
private final String messageKey;
@@ -456,8 +456,7 @@ public class FormUtils {
|| !Float.isFinite(finalW)
|| !Float.isFinite(finalH)) {
log.warn(
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={},"
+ " h={}",
"Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}",
field.getFullyQualifiedName(),
pageIndex,
finalX,
@@ -392,9 +392,9 @@ public class PdfUtils {
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting"
+ " image would exceed Java's maximum array size. Please use a"
+ " lower DPI value for PDF-to-image conversion.",
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
}
throw e;
@@ -253,8 +253,7 @@ public class ProcessExecutor {
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to"
+ " timeout.");
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
@@ -279,8 +278,7 @@ public class ProcessExecutor {
}
} catch (InterruptedIOException e) {
log.warn(
"Error reader thread was interrupted due to"
+ " timeout.");
"Error reader thread was interrupted due to timeout.");
} catch (IOException e) {
log.error("exception", e);
}
@@ -237,7 +237,6 @@ class ApplicationPropertiesLogicTest {
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn"
+ " Element leer/blank ist");
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
}
}
@@ -130,8 +130,7 @@ class PdfMarkdownConverterTest {
if (similarity < THRESHOLD) {
fail(
String.format(
"Markdown output differs from golden file '%s' by %.1f%% (threshold"
+ " %.0f%%):%n%s",
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
mdName,
(1.0 - similarity) * 100,
(1.0 - THRESHOLD) * 100,
@@ -60,10 +60,10 @@ class CustomHtmlSanitizerTest {
new String[] {"<p>", "<strong>", "<em>"}),
Arguments.of(
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
+ " <em>emphasis</em>, <strong>strong</strong>,"
+ " <strike>strikethrough</strike>, <s>strike</s>,"
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
+ " <em>emphasis</em>, <strong>strong</strong>,"
+ " <strike>strikethrough</strike>, <s>strike</s>,"
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
new String[] {
"<b>bold</b>",
"<i>italic</i>",
@@ -271,8 +271,8 @@ class CustomHtmlSanitizerTest {
// Arrange
String htmlWithObjects =
"<p>Safe content</p><object data=\"data.swf\""
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
+ " type=\"application/x-shockwave-flash\">";
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
+ " type=\"application/x-shockwave-flash\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
@@ -309,11 +309,11 @@ class CustomHtmlSanitizerTest {
// Arrange
String complexHtml =
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
+ " image\"> <script>alert('XSS');</script> <iframe"
+ " src=\"https://evil.com\"></iframe></div>";
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
+ " image\"> <script>alert('XSS');</script> <iframe"
+ " src=\"https://evil.com\"></iframe></div>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
@@ -120,10 +120,10 @@ class EmlToPdfTest {
void parseHtmlEmailWithStyling() throws IOException {
String htmlBody =
"<html><head><style>.header{color:blue;font-weight:bold;}"
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head><body><div"
+ " class=\"header\">Important Notice</div><div class=\"content\">This is"
+ " <strong>HTML content</strong> with styling.</div><div"
+ " class=\"footer\">Best regards</div></body></html>";
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head>"
+ "<body><div class=\"header\">Important Notice</div>"
+ "<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>"
+ "<div class=\"footer\">Best regards</div></body></html>";
String emlContent =
createHtmlEmail(
@@ -286,13 +286,11 @@ class EmlToPdfTest {
@DisplayName("Should handle complex nested HTML structures")
void handleComplexNestedHtml() throws IOException {
String complexHtml =
"<html><head><title>Complex Email</title></head><body><div"
+ " class=\"container\"><header><h1>Email"
+ " Header</h1></header><main><section><p>Paragraph with <a"
+ " href=\"https://example.com\">link</a></p><ul><li>List item"
+ " 1</li><li>List item 2 with"
+ " <em>emphasis</em></li></ul><table><tr><td>Cell 1</td><td>Cell"
+ " 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
"<html><head><title>Complex Email</title></head><body>"
+ "<div class=\"container\"><header><h1>Email Header</h1></header><main><section>"
+ "<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>"
+ "<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>"
+ "<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
+ "</table></section></main></div></body></html>";
String emlContent =
@@ -348,8 +346,7 @@ class EmlToPdfTest {
This line breaks header format
Content-Type: text/plain
Body content\
""";
Body content""";
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
EmlToPdfRequest request = createBasicRequest();
@@ -784,13 +781,7 @@ class EmlToPdfTest {
String from, String to, String subject, String body, String charset) {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "Content-Type: text/plain; charset=%s\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
@@ -802,11 +793,7 @@ class EmlToPdfTest {
private String createEmailWithCustomHeaders() {
return String.format(
Locale.ROOT,
"From: sender@example.com\n"
+ "Date: %s\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
getTimestamp(),
"This is an email body with some headers missing.");
}
@@ -814,13 +801,7 @@ class EmlToPdfTest {
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "Content-Type: text/html; charset=UTF-8\n"
+ "Content-Transfer-Encoding: 8bit\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
@@ -842,27 +823,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
from,
to,
subject,
@@ -883,27 +863,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: message/rfc822; name="%s"
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: message/rfc822; name="%s"
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
"outer@example.com",
"outer_recipient@example.com",
"Fwd: Inner Email Subject",
@@ -923,27 +902,26 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
%s
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Multipart/Alternative Test",
@@ -959,14 +937,7 @@ class EmlToPdfTest {
private String createQuotedPrintableEmail() {
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: quoted-printable\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
"sender@example.com",
"recipient@example.com",
"Quoted-Printable Test",
@@ -979,14 +950,7 @@ class EmlToPdfTest {
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"From: %s\n"
+ "To: %s\n"
+ "Subject: %s\n"
+ "Date: %s\n"
+ "MIME-Version: 1.0\n"
+ "Content-Type: text/plain; charset=UTF-8\n"
+ "Content-Transfer-Encoding: base64\n\n"
+ "%s",
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
"sender@example.com",
"recipient@example.com",
"Base64 Test",
@@ -999,28 +963,27 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/related; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/related; boundary="%s"
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
--%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Inline Image Test",
@@ -1045,40 +1008,39 @@ class EmlToPdfTest {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
From: %s
To: %s
Subject: %s
Date: %s
Content-Type: multipart/mixed; boundary="%s"
--%s
Content-Type: multipart/related; boundary="related-%s"
--%s
Content-Type: multipart/related; boundary="related-%s"
--related-%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
--related-%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
%s
%s
--related-%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
--related-%s
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <%s>
Content-Disposition: inline; filename="image.png"
%s
%s
--related-%s--
--related-%s--
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
--%s
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename="%s"
Content-Transfer-Encoding: base64
%s
%s
--%s--\
""",
--%s--""",
"sender@example.com",
"receiver@example.com",
"Mixed Attachments Test",
@@ -31,22 +31,21 @@ class OfficeDocumentSanitizerTest {
private static final String INTERNAL_TARGET = "media/image1.png";
private static final String DOCX_RELS =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/><Relationship Id=\"rId2\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ "\" TargetMode=\"External\"/>"
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ INTERNAL_TARGET
+ "\"/>"
+ "</Relationships>";
private static final String DOCX_DOCUMENT =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><w:document"
+ " xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
+ "<w:body><w:p/></w:body></w:document>";
private static final String ODF_CONTENT_EXTERNAL =
@@ -58,8 +57,8 @@ class OfficeDocumentSanitizerTest {
+ "<office:body><office:text>"
+ "<draw:frame><draw:image xlink:href=\""
+ EXTERNAL_URL
+ "\" xlink:type=\"simple\"/></draw:frame><draw:frame><draw:image"
+ " xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
+ "\" xlink:type=\"simple\"/></draw:frame>"
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
+ "</office:text></office:body></office:document-content>";
private SsrfProtectionService ssrfProtectionService;
@@ -114,11 +113,10 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_pptxExternalImageRelStripped() throws IOException {
String pptxRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/>"
+ "</Relationships>";
@@ -137,11 +135,10 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_xlsxExternalImageRelStripped() throws IOException {
String xlsxRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/>"
+ "</Relationships>";
@@ -165,7 +162,7 @@ class OfficeDocumentSanitizerTest {
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
String manifestXml =
"<?xml version=\"1.0\"?><manifest:manifest"
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
byte[] odt = zip(entries);
@@ -297,11 +294,11 @@ class OfficeDocumentSanitizerTest {
@Test
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
String internalOnlyRels =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
+ " Id=\"rId1\""
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\"media/image1.png\"/></Relationships>";
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
+ " Target=\"media/image1.png\"/>"
+ "</Relationships>";
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put(
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
@@ -249,8 +249,7 @@ class ProcessExecutorGapTest {
@Test
@DisplayName(
"injects --host/--port after the executable, defaults omit host-location and"
+ " protocol")
"injects --host/--port after the executable, defaults omit host-location and protocol")
void injectsHostAndPortWithDefaults() throws Exception {
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
@@ -38,8 +38,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesScriptElement() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle"
+ " r=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle r=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("script"));
@@ -49,8 +48,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesEventHandler() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\""
+ " onclick=\"alert('xss')\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\" onclick=\"alert('xss')\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("onclick"));
@@ -59,8 +57,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesJavascriptUrl() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a"
+ " href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("javascript"));
@@ -89,8 +86,7 @@ class SvgSanitizerTest {
@Test
void testSanitize_removesForeignObject() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect"
+ " width=\"10\" height=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.toLowerCase().contains("foreignobject"));
@@ -117,8 +113,8 @@ class SvgSanitizerTest {
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"../../assets/image.png\""
+ " width=\"10\" height=\"10\"/></svg>";
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
@@ -36,8 +36,7 @@ public class ReplaceAndInvertColorFactory {
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw new IllegalStateException(
"CMYK color space conversion requires Ghostscript, which is not available on"
+ " this system");
"CMYK color space conversion requires Ghostscript, which is not available on this system");
}
return switch (replaceAndInvertOption) {
@@ -74,8 +74,7 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
private ApiResponse create400Response() {
return new ApiResponse()
.description(
"Bad request - Invalid input parameters, unsupported format, or corrupted"
+ " file")
"Bad request - Invalid input parameters, unsupported format, or corrupted file")
.content(
new Content()
.addMediaType(
@@ -84,14 +83,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
400,
"Invalid input parameters or"
+ " corrupted file",
"Invalid input parameters or corrupted file",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
400,
"Invalid input parameters or"
+ " corrupted file",
"Invalid input parameters or corrupted file",
"/api/v1/example/endpoint"))));
}
@@ -106,14 +103,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
413,
"File size exceeds maximum allowed"
+ " limit",
"File size exceeds maximum allowed limit",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
413,
"File size exceeds maximum allowed"
+ " limit",
"File size exceeds maximum allowed limit",
"/api/v1/example/endpoint"))));
}
@@ -128,14 +123,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
422,
"File is valid but cannot be"
+ " processed",
"File is valid but cannot be processed",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
422,
"File is valid but cannot be"
+ " processed",
"File is valid but cannot be processed",
"/api/v1/example/endpoint"))));
}
@@ -150,14 +143,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
.schema(
createErrorSchema(
500,
"Unexpected error during"
+ " processing",
"Unexpected error during processing",
"/api/v1/example/endpoint"))
.example(
createErrorExample(
500,
"Unexpected error during"
+ " processing",
"Unexpected error during processing",
"/api/v1/example/endpoint"))));
}
@@ -51,8 +51,7 @@ public class LocaleConfiguration implements WebMvcConfigurer {
defaultLocale = tempLocale;
} else {
System.err.println(
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back"
+ " to default en-US.");
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US.");
}
}
}
@@ -46,12 +46,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Processing API")
.description(
"APIs for converting, editing, securing, and"
+ " analysing PDF documents. Use these"
+ " endpoints to automate common PDF tasks"
+ " (like split, merge, convert, OCR) and"
+ " plug them into your own apps and"
+ " backend jobs."));
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
})
.build();
}
@@ -84,9 +79,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Management API")
.description(
"Endpoints for authentication, user management,"
+ " invitations, audit logging, and system"
+ " configuration."));
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
})
.build();
}
@@ -109,8 +102,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - System API")
.description(
"System information, UI metadata, job status,"
+ " and file management endpoints."));
"System information, UI metadata, job status, and file management endpoints."));
})
.build();
}
@@ -45,8 +45,7 @@ public class TauriProcessMonitor {
startMonitoring();
} else {
logger.warn(
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring"
+ " disabled.");
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
}
}
@@ -75,8 +74,7 @@ public class TauriProcessMonitor {
try {
if (!isProcessAlive(parentProcessId)) {
logger.warn(
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful"
+ " shutdown...",
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
parentProcessId);
initiateGracefulShutdown();
}
@@ -120,8 +118,7 @@ public class TauriProcessMonitor {
} else {
// Fallback to system exit
logger.warn(
"Unable to shutdown Spring context gracefully, using"
+ " System.exit");
"Unable to shutdown Spring context gracefully, using System.exit");
System.exit(0);
}
} catch (Exception e) {
@@ -29,8 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"CSV file containing extracted table"
+ " data")),
"CSV file containing extracted table data")),
@Content(
mediaType = "application/zip",
schema =
@@ -38,9 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"ZIP archive containing multiple CSV files"
+ " when multiple tables are"
+ " extracted"))
"ZIP archive containing multiple CSV files when multiple tables are extracted"))
}),
@ApiResponse(
responseCode = "400",
@@ -51,8 +51,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be analyzed for"
+ " filtering",
"Unprocessable entity - PDF is valid but cannot be analyzed for filtering",
content =
@Content(
mediaType = "application/json",
@@ -28,8 +28,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@Schema(
type = "object",
description =
"JSON object containing the requested"
+ " data or analysis results"))),
"JSON object containing the requested data or analysis results"))),
@ApiResponse(
responseCode = "400",
description = "Invalid PDF file or request parameters",
@@ -21,8 +21,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "200",
description =
"Files processed successfully. Returns single file or ZIP archive"
+ " containing multiple files.",
"Files processed successfully. Returns single file or ZIP archive containing multiple files.",
content = {
@Content(
mediaType = "application/pdf",
@@ -38,8 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"ZIP archive containing multiple output"
+ " files")),
"ZIP archive containing multiple output files")),
@Content(
mediaType = "image/png",
schema =
@@ -30,13 +30,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
type = "string",
format = "binary",
description =
"Microsoft PowerPoint presentation"
+ " (PPTX)"))),
"Microsoft PowerPoint presentation (PPTX)"))),
@ApiResponse(
responseCode = "400",
description =
"Bad request - Invalid input parameters, unsupported format, or"
+ " corrupted PDF",
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
content =
@Content(
mediaType = "application/json",
@@ -51,8 +49,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be converted to"
+ " PowerPoint format",
"Unprocessable entity - PDF is valid but cannot be converted to PowerPoint format",
content =
@Content(
mediaType = "application/json",
@@ -41,8 +41,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "400",
description =
"Bad request - Invalid input parameters, unsupported format, or"
+ " corrupted PDF",
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
content =
@Content(
mediaType = "application/json",
@@ -57,8 +56,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ApiResponse(
responseCode = "422",
description =
"Unprocessable entity - PDF is valid but cannot be converted to Word"
+ " format",
"Unprocessable entity - PDF is valid but cannot be converted to Word format",
content =
@Content(
mediaType = "application/json",
@@ -39,18 +39,18 @@ public class AdditionalLanguageJsController {
// Generiere die `getDetailedLanguageCode`-Funktion
writer.println(
"""
function getDetailedLanguageCode() {
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
for (let lang of userLanguages) {
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
if (matchedLang) {
return matchedLang;
function getDetailedLanguageCode() {
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
for (let lang of userLanguages) {
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
if (matchedLang) {
return matchedLang;
}
}
// Fallback
return "en_US";
}
}
// Fallback
return "en_US";
}
""");
""");
writer.flush();
}
@@ -54,9 +54,8 @@ public class BookletImpositionController {
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.")
+ " layout. It rearranges pages in the correct order for booklet printing and"
+ " places multiple pages on each sheet for proper folding and binding.")
public ResponseEntity<Resource> createBookletImposition(
@ModelAttribute BookletImpositionRequest request) throws IOException {
@@ -74,8 +73,7 @@ public class BookletImpositionController {
// 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.");
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
@@ -150,8 +150,7 @@ public class CropController {
|| request.getWidth() == null
|| request.getHeight() == null) {
throw new IllegalArgumentException(
"Crop coordinates (x, y, width, height) are required when auto-crop is not"
+ " enabled");
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
@@ -90,14 +90,13 @@ public class EditTextController {
summary = "Edit text in a PDF via find and replace",
description =
"Applies an ordered list of find/replace operations to the text in a PDF and"
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
+ " updating a company name throughout a document), and copy editing where"
+ " the AI agent has identified specific replacements. Matching is"
+ " performed against the joined text of each page, so find strings can"
+ " span multiple visual runs (titles split per word, kerning-broken"
+ " phrases). Cross-element matches are written as a single replacement run"
+ " anchored at the leftmost matched position; centered or tracked text may"
+ " shift left when its content changes.")
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
+ " updating a company name throughout a document), and copy editing where the AI"
+ " agent has identified specific replacements. Matching is performed against the"
+ " joined text of each page, so find strings can span multiple visual runs"
+ " (titles split per word, kerning-broken phrases). Cross-element matches are"
+ " written as a single replacement run anchored at the leftmost matched position;"
+ " centered or tracked text may shift left when its content changes.")
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -246,8 +246,8 @@ public class MergeController {
summary = "Merge multiple PDF files into one",
description =
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided.")
+ " file will contain all pages from the input files in the order they were"
+ " provided.")
public ResponseEntity<Resource> mergePdfs(
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
@@ -220,9 +220,8 @@ public class MultiPageLayoutController {
"error.invalidFormat",
"Invalid {0} format: {1}",
"margin/layout configuration",
"Invalid margin or layout configuration: resulting cell size is"
+ " non-positive. Please reduce outer margins or adjust"
+ " rows/columns.");
"Invalid margin or layout configuration: resulting cell size is non-positive. "
+ "Please reduce outer margins or adjust rows/columns.");
}
float innerWidth = cellWidth - 2 * innerMargin;
@@ -57,8 +57,8 @@ public class PosterPdfController {
summary = "Split large PDF pages into smaller printable chunks",
description =
"This endpoint splits large or oddly-sized PDF pages into smaller chunks"
+ " suitable for printing on standard paper sizes (e.g., A4, Letter)."
+ " Divides each page into a grid of smaller pages using Apache PDFBox.")
+ " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each"
+ " page into a grid of smaller pages using Apache PDFBox.")
public ResponseEntity<Resource> posterPdf(@ModelAttribute PosterPdfRequest request)
throws Exception {
@@ -214,8 +214,7 @@ public class PosterPdfController {
}
log.trace(
"Created output page for grid cell [{},{}] of page {}:"
+ " cropX={}, cropY={}, translate=({}, {})",
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
row,
actualCol,
pageIndex,
@@ -241,8 +241,8 @@ public class RearrangePagesPDFController {
summary = "Rearrange pages in a PDF file",
description =
"This endpoint rearranges pages in a given PDF file based on the specified page"
+ " order or custom mode. Users can provide a page order as a"
+ " comma-separated list of page numbers or page ranges, or a custom mode.")
+ " order or custom mode. Users can provide a page order as a comma-separated list"
+ " of page numbers or page ranges, or a custom mode.")
public ResponseEntity<Resource> rearrangePages(@ModelAttribute RearrangePagesRequest request)
throws IOException {
MultipartFile pdfFile = request.getFileInput();
@@ -60,8 +60,8 @@ public class SplitPDFController {
summary = "Split a PDF file into separate documents",
description =
"This endpoint splits a given PDF file into separate documents based on the"
+ " specified page numbers or ranges. Users can specify pages using"
+ " individual numbers, ranges, or 'all' for every page.")
+ " specified page numbers or ranges. Users can specify pages using individual"
+ " numbers, ranges, or 'all' for every page.")
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPagesRequest request)
throws IOException {
@@ -62,8 +62,8 @@ public class SplitPdfBySectionsController {
summary = "Split PDF pages into smaller sections",
description =
"Split each page of a PDF into smaller sections based on the user's choice"
+ " which page to split, and how to split ( halves, thirds, quarters,"
+ " etc.), both vertically and horizontally.")
+ " which page to split, and how to split ( halves, thirds, quarters, etc.), both"
+ " vertically and horizontally.")
public ResponseEntity<Resource> splitPdf(
@Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
MultipartFile file = request.getFileInput();
@@ -60,9 +60,9 @@ public class SplitPdfBySizeController {
summary = "Auto split PDF pages into separate documents based on size or count",
description =
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
+ " and split into 5, it does 5 documents each 4 pages\r\n"
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
+ " (rounded so that it accepts 1.9MB but not 2.1MB)")
+ " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page"
+ " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts"
+ " 1.9MB but not 2.1MB)")
public ResponseEntity<Resource> autoSplitPdf(
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
@@ -46,8 +46,8 @@ public class ToSinglePageController {
summary = "Convert a multi-page PDF into a single long page PDF",
description =
"This endpoint converts a multi-page PDF document into a single paged PDF"
+ " document. The width of the single page will be same as the input's"
+ " width, but the height will be the sum of all the pages' heights.")
+ " document. The width of the single page will be same as the input's width, but"
+ " the height will be the sum of all the pages' heights.")
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
throws IOException {
@@ -56,9 +56,9 @@ public class ConvertEmlToPDF {
summary = "Convert EML/MSG to PDF",
description =
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format with"
+ " extensive customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output."
+ " or MSG file, or HTML file.")
+ " extensive customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output. or MSG"
+ " file, or HTML file.")
public ResponseEntity<Resource> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
@@ -48,8 +48,7 @@ public class ConvertHtmlToPDF {
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
"This endpoint takes an HTML or ZIP file input and converts it to a PDF"
+ " format.")
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format.")
public ResponseEntity<Resource> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
@@ -95,8 +95,8 @@ public class ConvertImgPDFController {
summary = "Convert PDF to image(s)",
description =
"This endpoint converts a PDF file to image(s) with the specified image format,"
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images.")
+ " color type, and DPI. Users can choose to get a single image or multiple"
+ " images.")
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
@@ -97,8 +97,8 @@ public class ConvertPDFToEpubController {
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
throw new IllegalStateException(
"Calibre support is disabled. Enable the Calibre group or install Calibre to"
+ " use this feature.");
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
+ " this feature.");
}
MultipartFile inputFile = request.getFileInput();
@@ -453,32 +453,32 @@ public class ConvertPDFToPDFA {
String pdfaDefContent =
String.format(
"""
%% This is a sample prefix file for creating a PDF/A document.
%% Feel free to modify entries marked with "Customize".
%% This is a sample prefix file for creating a PDF/A document.
%% Feel free to modify entries marked with "Customize".
%% Define entries in the document Info dictionary.
[/Title (%s)
/DOCINFO pdfmark
%% Define entries in the document Info dictionary.
[/Title (%s)
/DOCINFO pdfmark
%% Define an ICC profile.
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
[{icc_PDFA} <<
/N 3
>> /PUT pdfmark
[{icc_PDFA} (%s) (r) file /PUT pdfmark
%% Define an ICC profile.
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
[{icc_PDFA} <<
/N 3
>> /PUT pdfmark
[{icc_PDFA} (%s) (r) file /PUT pdfmark
%% Define the output intent dictionary.
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
[{OutputIntent_PDFA} <<
/Type /OutputIntent
/S /GTS_PDFA1
/DestOutputProfile {icc_PDFA}
/OutputConditionIdentifier (sRGB IEC61966-2.1)
/Info (sRGB IEC61966-2.1)
/RegistryName (http://www.color.org)
>> /PUT pdfmark
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
""",
%% Define the output intent dictionary.
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
[{OutputIntent_PDFA} <<
/Type /OutputIntent
/S /GTS_PDFA1
/DestOutputProfile {icc_PDFA}
/OutputConditionIdentifier (sRGB IEC61966-2.1)
/Info (sRGB IEC61966-2.1)
/RegistryName (http://www.color.org)
>> /PUT pdfmark
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
""",
title, rgbProfilePath);
Files.writeString(pdfaDefFile, pdfaDefContent);
@@ -598,9 +598,8 @@ public class ConvertPDFToPDFA {
summary = "Convert a PDF to a PDF/A or PDF/X",
description =
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript"
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format"
+ " designed for long-term archiving, while PDF/X is optimized for print"
+ " production.")
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for"
+ " long-term archiving, while PDF/X is optimized for print production.")
public ResponseEntity<Resource> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -662,8 +661,7 @@ public class ConvertPDFToPDFA {
if (!isGhostscriptAvailable()) {
log.error("Ghostscript is required for PDF/X conversion");
throw new IOException(
"Ghostscript is required for PDF/X conversion but is not available on the"
+ " system");
"Ghostscript is required for PDF/X conversion but is not available on the system");
}
log.info("Using Ghostscript for PDF/X conversion to {}", profile.getDisplayName());
@@ -745,8 +743,7 @@ public class ConvertPDFToPDFA {
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
descDict.removeItem(COSName.CHAR_SET);
log.debug(
"Removed potentially invalid CharSet from subsetted Type1"
+ " font: {}",
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
fontNameStr);
} else if (!hasFontFile && fontEmbedded) {
// Font is embedded but we can't verify CharSet, remove it
@@ -764,8 +761,7 @@ public class ConvertPDFToPDFA {
if (!glyphSet.isEmpty()) {
descDict.setString(COSName.CHAR_SET, glyphSet);
log.debug(
"Added missing CharSet for Type1 font {} with {}"
+ " glyphs",
"Added missing CharSet for Type1 font {} with {} glyphs",
fontNameStr,
countGlyphs(glyphSet));
}
@@ -1939,8 +1935,7 @@ public class ConvertPDFToPDFA {
return WebResponseUtils.pdfFileToWebResponse(tempOut, outputFilename);
} catch (IOException | InterruptedException e) {
log.warn(
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
+ " method",
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
e);
}
} else {
@@ -2541,8 +2536,7 @@ public class ConvertPDFToPDFA {
return converted;
} catch (IOException | InterruptedException e) {
log.warn(
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
+ " method",
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
e);
}
} else {
@@ -62,8 +62,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the"
+ " text editor tool.")
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.")
public ResponseEntity<Resource> convertPdfToJson(
@ModelAttribute PDFFile request,
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
@@ -105,8 +104,7 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Convert Text Editor Format to PDF",
description =
"Rebuilds a PDF from the editable JSON structure generated by the text editor"
+ " tool.")
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool.")
public ResponseEntity<Resource> convertJsonToPdf(@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile jsonFile = request.getFileInput();
@@ -139,9 +137,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
"Extracts document metadata, fonts, and page dimensions for the text editor"
+ " tool. Caches the document for subsequent page requests. Returns a"
+ " server-generated jobId scoped to the authenticated user.")
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -183,10 +181,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Apply incremental edits from text editor to a cached PDF",
description =
"Applies edits for the specified pages of a cached PDF and returns an updated"
+ " PDF. Requires the PDF to have been previously cached via the text"
+ " editor metadata endpoint. The jobId must be obtained from the metadata"
+ " extraction endpoint.")
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
+ " The jobId must be obtained from the metadata extraction endpoint.")
public ResponseEntity<Resource> exportPartialPdf(
@PathVariable String jobId,
@RequestBody PdfJsonDocument document,
@@ -227,9 +224,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract single page from cached PDF for text editor",
description =
"Retrieves a single page's content from a previously cached PDF document for"
+ " the text editor tool. Requires prior call to /pdf/text-editor/metadata."
+ " The jobId must belong to the authenticated user.")
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -256,9 +253,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF"
+ " document. Requires prior call to /pdf/text-editor/metadata. The jobId"
+ " must belong to the authenticated user.")
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Resource> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
@@ -288,9 +285,9 @@ public class ConvertPdfJsonController {
@Operation(
summary = "Clear cached PDF document for text editor",
description =
"Manually clears a cached PDF document used by the text editor to free up"
+ " server resources. Called automatically after 30 minutes. The jobId must"
+ " belong to the authenticated user.")
"Manually clears a cached PDF document used by the text editor to free up server resources."
+ " Called automatically after 30 minutes. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
validateJobAccess(jobId);
@@ -68,11 +68,10 @@ public class ConvertSvgToPDF {
summary = "Convert SVG to PDF",
description =
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF"
+ " format. Each SVG is converted to a separate PDF file. The conversion"
+ " preserves vector graphics for crisp output at any resolution - no"
+ " rasterization occurs. SVG dimensions (width/height) determine the PDF"
+ " page size; defaults to A4 if not specified. SVG content is sanitized to"
+ " prevent XSS attacks.")
+ " format. Each SVG is converted to a separate PDF file. The conversion preserves"
+ " vector graphics for crisp output at any resolution - no rasterization occurs."
+ " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if"
+ " not specified. SVG content is sanitized to prevent XSS attacks.")
public ResponseEntity<Resource> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
MultipartFile[] inputFiles = request.getFileInput();
@@ -221,8 +221,7 @@ public class PdfVectorExportController {
if (result.getRc() != 0) {
log.error(
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command:"
+ " {}",
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
outputFormat.toUpperCase(),
result.getRc(),
result.getMessages(),
@@ -262,8 +261,7 @@ public class PdfVectorExportController {
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command:"
+ " {}",
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
@@ -271,8 +269,7 @@ public class PdfVectorExportController {
if (result.getRc() != 0) {
log.error(
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}."
+ " Command: {}",
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
result.getRc(),
result.getMessages(),
String.join(" ", command));
@@ -295,8 +295,7 @@ public class FormFillController {
@Operation(
summary = "Extract form fields as XLSX",
description =
"Returns an Excel (XLSX) file containing all form field names and their current"
+ " values")
"Returns an Excel (XLSX) file containing all form field names and their current values")
public ResponseEntity<byte[]> extractXlsx(
@Parameter(
description = "The input PDF file",
@@ -428,8 +427,8 @@ public class FormFillController {
@Parameter(
description =
"Return a ZIP holding the updated PDF plus the field list it"
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
@@ -79,10 +79,9 @@ public class AddCommentsController {
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
description =
"Attaches PDF Text (sticky-note) annotations to the document. Each CommentSpec"
+ " can either supply absolute coordinates or an `anchorText` hint; when"
+ " provided, the tool locates the first matching line on the target page"
+ " and anchors the icon there (falling back to the coordinates if no"
+ " match).")
+ " can either supply absolute coordinates or an `anchorText` hint; when provided,"
+ " the tool locates the first matching line on the target page and anchors the"
+ " icon there (falling back to the coordinates if no match).")
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
throws IOException {
@@ -149,8 +149,7 @@ public class AttachmentController {
@Operation(
summary = "Extract attachments from PDF",
description =
"This endpoint extracts all embedded attachments from a PDF into a ZIP"
+ " archive.")
"This endpoint extracts all embedded attachments from a PDF into a ZIP archive.")
public ResponseEntity<Resource> extractAttachments(
@ModelAttribute ExtractAttachmentsRequest request) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
@@ -282,8 +282,8 @@ public class AutoSplitPdfController {
summary = "Auto split PDF pages into separate documents",
description =
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document.")
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document.")
public ResponseEntity<Resource> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -94,7 +94,7 @@ public class BlankPageController {
summary = "Remove blank pages from a PDF file",
description =
"This endpoint removes blank pages from a given PDF file. Users can specify the"
+ " threshold and white percentage to tune the detection of blank pages.")
+ " threshold and white percentage to tune the detection of blank pages.")
public ResponseEntity<Resource> removeBlankPages(
@ModelAttribute RemoveBlankPagesRequest request)
throws IOException, InterruptedException {
@@ -338,6 +338,19 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
// before it can prompt anyone to link. Read from the environment rather than
// AccountLinkProperties because :core must not depend on :proprietary.
configData.put(
"accountLinkAvailable",
applicationContext
.getEnvironment()
.getProperty(
"stirling.billing.account-link.enabled", Boolean.class, false));
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
@@ -68,8 +68,8 @@ public class ExtractImageScansController {
summary = "Extract image scans from an input file",
description =
"This endpoint extracts image scans from a given file based on certain"
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
+ " minimum contour area, and border size.")
+ " parameters. Users can specify angle threshold, tolerance, minimum area,"
+ " minimum contour area, and border size.")
public ResponseEntity<Resource> extractImageScans(
@ModelAttribute ExtractImageScansRequest request)
throws IOException, InterruptedException {
@@ -45,8 +45,8 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. Files are"
+ " temporarily stored and automatically cleaned up after 10 minutes.")
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
@@ -271,8 +271,7 @@ public class MobileScannerController {
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted"
+ " after download.")
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
@ApiResponse(responseCode = "404", description = "File or session not found")
@@ -104,9 +104,9 @@ public class OCRController {
summary = "Process a PDF file with OCR",
description =
"This endpoint processes a PDF file using OCR (Optical Character Recognition)."
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal,"
+ " ocrType, ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if"
+ " available, falls back to Tesseract.")
+ " Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType,"
+ " ocrRenderType, and removeImagesAfter options. Uses OCRmyPDF if available,"
+ " falls back to Tesseract.")
public ResponseEntity<Resource> processPdfWithOCR(
@ModelAttribute ProcessPdfWithOcrRequest request)
throws IOException, InterruptedException {
@@ -442,8 +442,7 @@ public class OCRController {
// Verify the OCR'd PDF was created
if (!pageOutputPath.exists()) {
log.warn(
"Tesseract did not create expected output file: {}. Page may be"
+ " blank or unreadable.",
"Tesseract did not create expected output file: {}. Page may be blank or unreadable.",
pageOutputPath.getAbsolutePath());
// Save original page without OCR as fallback
try (PDDocument pageDoc = new PDDocument()) {
@@ -50,10 +50,9 @@ public class OverlayImageController {
summary = "Overlay image onto a PDF file",
description =
"This endpoint overlays an image onto a PDF file at the specified coordinates."
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG)."
+ " SVG files are rendered as vector graphics for crisp output at any"
+ " resolution. The image can be overlaid on every page of the PDF if"
+ " specified.")
+ " Supports both raster formats (PNG, JPEG, etc.) and vector format (SVG). SVG"
+ " files are rendered as vector graphics for crisp output at any resolution. The"
+ " image can be overlaid on every page of the PDF if specified.")
public ResponseEntity<Resource> overlayImage(@ModelAttribute OverlayImageRequest request) {
MultipartFile pdfFile = request.getFileInput();
MultipartFile imageFile = request.getImageFile();
@@ -59,9 +59,8 @@ public class RepairController {
summary = "Repair a PDF file",
description =
"This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf"
+ " (fallback), or PDFBox (if no external tools available). The PDF is"
+ " first saved to a temporary location, repaired, read back, and then"
+ " returned as a response.")
+ " (fallback), or PDFBox (if no external tools available). The PDF is first saved"
+ " to a temporary location, repaired, read back, and then returned as a response.")
public ResponseEntity<Resource> repairPdf(@ModelAttribute PDFFile file)
throws IOException, InterruptedException {
MultipartFile inputFile = file.getFileInput();
@@ -43,8 +43,8 @@ public class ReplaceAndInvertColorController {
summary = "Replace-Invert Color PDF",
description =
"This endpoint accepts a PDF file and provides options to invert all colors,"
+ " replace text and background colors, or convert to CMYK color space for"
+ " printing.")
+ " replace text and background colors, or convert to CMYK color space for"
+ " printing.")
public ResponseEntity<Resource> replaceAndInvertColor(
@ModelAttribute ReplaceAndInvertColorRequest request) throws IOException {
@@ -98,8 +98,7 @@ public class StampController {
summary = "Add stamp to a PDF file",
description =
"This endpoint adds a stamp to a given PDF file. Users can specify the stamp"
+ " type (text or image), rotation, opacity, width spacer, and height"
+ " spacer.")
+ " type (text or image), rotation, opacity, width spacer, and height spacer.")
public ResponseEntity<Resource> addStamp(@ModelAttribute AddStampRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
@@ -58,9 +58,8 @@ public class PipelineController {
@Operation(
summary = "Execute automated PDF processing pipeline",
description =
"This endpoint processes multiple PDF files through a configurable pipeline of"
+ " operations. Users provide files and a JSON configuration defining the"
+ " sequence of operations to perform.")
"This endpoint processes multiple PDF files through a configurable pipeline of operations. "
+ "Users provide files and a JSON configuration defining the sequence of operations to perform.")
public ResponseEntity<Resource> handleData(@ModelAttribute HandleDataRequest request)
throws DatabindException, JacksonException {
MultipartFile[] files = request.getFileInput();
@@ -177,8 +177,8 @@ public class CertSignController {
summary = "Sign PDF with a Digital Certificate",
description =
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file.")
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file.")
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
throws Exception {
@@ -99,8 +99,8 @@ public class RedactController {
summary = "Redacts areas and pages in a PDF document",
description =
"This endpoint redacts content from a PDF file based on manually specified"
+ " areas. Users can specify areas to redact and optionally convert the PDF"
+ " to an image.")
+ " areas. Users can specify areas to redact and optionally convert the PDF to an"
+ " image.")
public ResponseEntity<Resource> redactPDF(@ModelAttribute ManualRedactPdfRequest request)
throws IOException {
@@ -146,8 +146,8 @@ public class RedactController {
operationId = "redactPdfAuto",
description =
"This endpoint automatically redacts text from a PDF file based on specified"
+ " patterns. Users can provide text patterns to redact, with options for"
+ " regex and whole word matching.")
+ " patterns. Users can provide text patterns to redact, with options for regex"
+ " and whole word matching.")
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
if (request.getFileInput() == null || request.getFileInput().isEmpty()) {
log.error("File input is null or empty");
@@ -299,7 +299,7 @@ public class RedactController {
summary = "Execute a unified redaction plan on a PDF",
description =
"Unified redaction endpoint that accepts exact strings, regex patterns, and"
+ " page numbers in a single request. Supports execution strategy hints.")
+ " page numbers in a single request. Supports execution strategy hints.")
public ResponseEntity<Resource> executeRedaction(@ModelAttribute RedactExecuteRequest request)
throws IOException {
@@ -73,8 +73,7 @@ class RedactExecuteService {
boolean hasTextOps = !textValues.isEmpty() || !regexPatterns.isEmpty();
log.info(
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={}"
+ " imageBoxes={} imagePages={}",
"[redact/execute] strategy={} textValues={} regexPatterns={} wipePages={} ranges={} imageBoxes={} imagePages={}",
style.getStrategy(),
textValues.size(),
regexPatterns.size(),
@@ -108,8 +107,7 @@ class RedactExecuteService {
needsOverlayOnly = applyTextRemoval(document, request);
} else if (overlayOnly) {
log.info(
"[redact/execute] overlay-only mode requested — skipping content-stream"
+ " rewriting");
"[redact/execute] overlay-only mode requested — skipping content-stream rewriting");
}
// Reload fresh document on fallback so we overlay onto clean content.
@@ -460,8 +458,7 @@ class RedactExecuteService {
}
if (end == null) {
log.warn(
"[redact/execute] no end anchor after start at (page={}, col={}, y={})"
+ " — skipping",
"[redact/execute] no end anchor after start at (page={}, col={}, y={}) — skipping",
start.page + 1,
start.col,
start.y);
@@ -129,8 +129,7 @@ class TextRedactionService {
result != null ? result.totalMatches() : -1);
if (result == null) {
log.warn(
"JPDFium PdfRedactor.redact returned null result, falling back to box-only"
+ " redaction mode");
"JPDFium PdfRedactor.redact returned null result, falling back to box-only redaction mode");
return true;
}
@@ -154,8 +153,7 @@ class TextRedactionService {
return false;
} catch (Exception e) {
log.warn(
"JPDFium native text replacement failed, falling back to box-only redaction"
+ " mode: {}",
"JPDFium native text replacement failed, falling back to box-only redaction mode: {}",
e.getMessage());
return true;
} finally {
@@ -91,8 +91,8 @@ public class TimestampController {
summary = "Add RFC 3161 document timestamp to a PDF",
description =
"Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161"
+ " document timestamp into the PDF. Only a SHA-256 hash of the document is"
+ " sent to the TSA - the PDF itself never leaves the server.")
+ " document timestamp into the PDF. Only a SHA-256 hash of the document is sent"
+ " to the TSA - the PDF itself never leaves the server.")
public ResponseEntity<Resource> timestampPdf(@ModelAttribute TimestampPdfRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
@@ -38,8 +38,8 @@ public class VerifyPDFController {
summary = "Verify PDF Standards Compliance",
description =
"Validates PDF files against the standards declared in their metadata."
+ " Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards"
+ " from the document's XMP metadata and validates compliance.")
+ " Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards from the"
+ " document's XMP metadata and validates compliance.")
@AutoJobPostMapping(
value = "/verify-pdf",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -81,8 +81,8 @@ public class WatermarkController {
summary = "Add watermark to a PDF file",
description =
"This endpoint adds a watermark to a given PDF file. Users can specify the"
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer.")
+ " watermark type (text or image), rotation, opacity, width spacer, and height"
+ " spacer.")
public ResponseEntity<Resource> addWatermark(@Valid @ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
@@ -58,8 +58,7 @@ public class MetricsController {
@Operation(
summary = "Application health check",
description =
"This endpoint returns the health status of the application and its version"
+ " number. Mirrors /api/v1/info/status.")
"This endpoint returns the health status of the application and its version number. Mirrors /api/v1/info/status.")
public ResponseEntity<?> getHealth() {
return getApplicationStatus();
}
@@ -92,8 +91,7 @@ public class MetricsController {
@Operation(
summary = "GET request count",
description =
"This endpoint returns the total count of GET requests for a specific endpoint"
+ " or all endpoints.")
"This endpoint returns the total count of GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getPageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -112,8 +110,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for GET requests",
description =
"This endpoint returns the count of unique users for GET requests for a"
+ " specific endpoint or all endpoints.")
"This endpoint returns the count of unique users for GET requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniquePageLoads(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -148,8 +145,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for GET requests for all endpoints",
description =
"This endpoint returns the count of unique users for GET requests for each"
+ " endpoint.")
"This endpoint returns the count of unique users for GET requests for each endpoint.")
public ResponseEntity<?> getAllUniqueEndpointLoads() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
@@ -166,8 +162,7 @@ public class MetricsController {
@Operation(
summary = "POST request count",
description =
"This endpoint returns the total count of POST requests for a specific endpoint"
+ " or all endpoints.")
"This endpoint returns the total count of POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -186,8 +181,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for POST requests",
description =
"This endpoint returns the count of unique users for POST requests for a"
+ " specific endpoint or all endpoints.")
"This endpoint returns the count of unique users for POST requests for a specific endpoint or all endpoints.")
public ResponseEntity<?> getUniqueTotalRequests(
@RequestParam(required = false, name = "endpoint") @Parameter(description = "endpoint")
Optional<String> endpoint) {
@@ -222,8 +216,7 @@ public class MetricsController {
@Operation(
summary = "Unique users count for POST requests for all endpoints",
description =
"This endpoint returns the count of unique users for POST requests for each"
+ " endpoint.")
"This endpoint returns the count of unique users for POST requests for each endpoint.")
public ResponseEntity<?> getAllUniquePostRequests() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
@@ -404,8 +397,7 @@ public class MetricsController {
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
"WAU tracking is only available when security is disabled (no-login"
+ " mode)");
"WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
@@ -138,8 +138,7 @@ public class ReactRoutingController {
this.useExternalIndexHtml = false;
this.loggedMissingIndex = true;
log.warn(
"index.html not found in classpath or custom path; using lightweight fallback"
+ " page");
"index.html not found in classpath or custom path; using lightweight fallback page");
}
private String processIndexHtml() {
@@ -372,51 +371,51 @@ public class ReactRoutingController {
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const serverUrl = %s;
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const serverUrl = %s;
if (token) {
// Extract nonce from URL to send back to desktop app for validation
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
if (token) {
// Extract nonce from URL to send back to desktop app for validation
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
}
@@ -431,238 +430,238 @@ public class ReactRoutingController {
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
text-align: center;
padding: 50px 20px;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
color: #2e7d32;
}
.icon.error {
color: #d32f2f;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.error-details {
background: #ffebee;
border: 1px solid #ffcdd2;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
font-size: 14px;
color: #c62828;
word-break: break-word;
text-align: left;
line-height: 1.5;
display: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.container {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: #374151;
color: #e5e7eb;
}
.icon {
color: #66bb6a;
}
.icon.error {
color: #ef5350;
}
h1 {
color: #f5f5f5;
}
p {
color: #b0b0b0;
}
.error-details {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
body {
padding: 20px 16px;
}
.container {
padding: 32px 24px;
}
h1 {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
</style>
<script>
(function() {
const run = () => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const errorCode = searchParams.get('errorOAuth')
|| searchParams.get('error')
|| hashParams.get('error')
|| searchParams.get('error_description')
|| hashParams.get('error_description');
const serverUrl = %s;
const iconEl = document.getElementById('auth-icon');
const titleEl = document.getElementById('auth-title');
const messageEl = document.getElementById('auth-message');
const detailsEl = document.getElementById('auth-error-details');
const sendDeepLink = (type, value, key) => {
try {
const encodedValue = encodeURIComponent(value || '');
const encodedServer = encodeURIComponent(serverUrl);
const hashKey = key || 'access_token';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
window.location.href = deepLink;
} catch (_) {
// ignore deep link errors
}
};
const showError = (message, details) => {
if (iconEl) {
iconEl.textContent = '✗';
iconEl.classList.add('error');
}
if (titleEl) {
titleEl.textContent = 'Authentication failed';
}
if (messageEl) {
messageEl.textContent = message;
}
if (detailsEl && details) {
detailsEl.textContent = details;
detailsEl.style.display = 'block';
}
};
if (token) {
// Extract nonce from URL to send back to desktop app for validation
// (System browser doesn't have access to desktop app's sessionStorage)
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
setTimeout(() => {
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
} catch (err) {
console.error('[Auth Callback] Failed to trigger deep link:', err);
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
}, 200);
return;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
text-align: center;
padding: 50px 20px;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
if (errorCode) {
const isCancelled = errorCode === 'access_denied';
sendDeepLink('sso-error', errorCode, 'error');
showError(
isCancelled
? 'Authentication was cancelled. You can close this window and return to the app.'
: 'Authentication was not successful. You can close this window and return to the app.',
errorCode
);
return;
}
.container {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
showError(
'Authentication did not complete. You can close this window and try again.',
'missing_token'
);
};
.icon {
font-size: 48px;
margin-bottom: 16px;
color: #2e7d32;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
</script>
</head>
<body>
<div class="container">
<div class="icon" id="auth-icon">&#10003;</div>
<h1 id="auth-title">Authentication complete</h1>
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
<div class="error-details" id="auth-error-details"></div>
</div>
</body>
</html>
"""
.icon.error {
color: #d32f2f;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.error-details {
background: #ffebee;
border: 1px solid #ffcdd2;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
font-size: 14px;
color: #c62828;
word-break: break-word;
text-align: left;
line-height: 1.5;
display: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.container {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: #374151;
color: #e5e7eb;
}
.icon {
color: #66bb6a;
}
.icon.error {
color: #ef5350;
}
h1 {
color: #f5f5f5;
}
p {
color: #b0b0b0;
}
.error-details {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
body {
padding: 20px 16px;
}
.container {
padding: 32px 24px;
}
h1 {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
</style>
<script>
(function() {
const run = () => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const errorCode = searchParams.get('errorOAuth')
|| searchParams.get('error')
|| hashParams.get('error')
|| searchParams.get('error_description')
|| hashParams.get('error_description');
const serverUrl = %s;
const iconEl = document.getElementById('auth-icon');
const titleEl = document.getElementById('auth-title');
const messageEl = document.getElementById('auth-message');
const detailsEl = document.getElementById('auth-error-details');
const sendDeepLink = (type, value, key) => {
try {
const encodedValue = encodeURIComponent(value || '');
const encodedServer = encodeURIComponent(serverUrl);
const hashKey = key || 'access_token';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
window.location.href = deepLink;
} catch (_) {
// ignore deep link errors
}
};
const showError = (message, details) => {
if (iconEl) {
iconEl.textContent = '✗';
iconEl.classList.add('error');
}
if (titleEl) {
titleEl.textContent = 'Authentication failed';
}
if (messageEl) {
messageEl.textContent = message;
}
if (detailsEl && details) {
detailsEl.textContent = details;
detailsEl.style.display = 'block';
}
};
if (token) {
// Extract nonce from URL to send back to desktop app for validation
// (System browser doesn't have access to desktop app's sessionStorage)
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
setTimeout(() => {
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
} catch (err) {
console.error('[Auth Callback] Failed to trigger deep link:', err);
}
}, 200);
return;
}
if (errorCode) {
const isCancelled = errorCode === 'access_denied';
sendDeepLink('sso-error', errorCode, 'error');
showError(
isCancelled
? 'Authentication was cancelled. You can close this window and return to the app.'
: 'Authentication was not successful. You can close this window and return to the app.',
errorCode
);
return;
}
showError(
'Authentication did not complete. You can close this window and try again.',
'missing_token'
);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
</script>
</head>
<body>
<div class="container">
<div class="icon" id="auth-icon">&#10003;</div>
<h1 id="auth-title">Authentication complete</h1>
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
<div class="error-details" id="auth-error-details"></div>
</div>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, serverUrl);
}
}
@@ -755,8 +755,7 @@ public class GlobalExceptionHandler {
getLocalizedMessage(
"error.methodNotAllowed.detail",
String.format(
"HTTP method '%s' is not supported for this endpoint. Supported"
+ " methods: %s",
"HTTP method '%s' is not supported for this endpoint. Supported methods: %s",
ex.getMethod(), String.join(", ", ex.getSupportedMethods())),
ex.getMethod(),
String.join(", ", ex.getSupportedMethods()));
@@ -880,15 +879,13 @@ public class GlobalExceptionHandler {
errorMap.put("status", 406);
errorMap.put(
"detail",
"The requested resource could not be returned in an acceptable format. Error"
+ " responses are returned as JSON.");
"The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.");
errorMap.put("instance", request.getRequestURI());
errorMap.put("timestamp", Instant.now().toString());
errorMap.put(
"hints",
java.util.Arrays.asList(
"Error responses are always returned as application/json or"
+ " application/problem+json",
"Error responses are always returned as application/json or application/problem+json",
"Set Accept header to include application/json for proper error handling"));
String errorJson = mapper.writeValueAsString(errorMap);
@@ -1253,8 +1250,7 @@ public class GlobalExceptionHandler {
String message =
getLocalizedMessage(
"error.tempFileNotFound.detail",
"The temporary file was not found. This may indicate a processing error"
+ " or cleanup issue. Please try again.");
"The temporary file was not found. This may indicate a processing error or cleanup issue. Please try again.");
String title =
getLocalizedMessage("error.tempFileNotFound.title", "Temporary File Not Found");
@@ -1266,8 +1262,7 @@ public class GlobalExceptionHandler {
problemDetail.setProperty("errorCode", "E999");
problemDetail.setProperty(
"hint.1",
"This error usually occurs when temporary files are cleaned up before"
+ " processing completes.");
"This error usually occurs when temporary files are cleaned up before processing completes.");
problemDetail.setProperty("hint.2", "Try submitting your request again.");
return new ResponseEntity<>(problemDetail, HttpStatus.INTERNAL_SERVER_ERROR);
}
@@ -15,9 +15,7 @@ public class EditTableOfContentsRequest extends PDFFile {
description = "Bookmark structure in JSON format",
type = "string",
example =
"[{\\\"title\\\":\\\"Chapter"
+ " 1\\\",\\\"pageNumber\\\":1,\\\"children\\\":[{\\\"title\\\":\\\"Section"
+ " 1.1\\\",\\\"pageNumber\\\":2}]}]")
"[{\\\"title\\\":\\\"Chapter 1\\\",\\\"pageNumber\\\":1,\\\"children\\\":[{\\\"title\\\":\\\"Section 1.1\\\",\\\"pageNumber\\\":2}]}]")
private String bookmarkData;
@Schema(
@@ -20,9 +20,9 @@ public class PDFWithPageNums extends PDFFile {
@Schema(
description =
"The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions"
+ " in the format 'an+b' where 'a' is the multiplier of the page number"
+ " 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')",
"The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the"
+ " format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a"
+ " constant (e.g., '2n+1', '3n', '6n-5')",
defaultValue = "all",
requiredMode = RequiredMode.REQUIRED)
private String pageNumbers;
@@ -24,8 +24,7 @@ public class SplitPdfBySectionsRequest extends PDFFile {
implementation = SplitTypes.class,
description =
"Modes for page split. Valid values are:\n"
+ "SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the"
+ " last pages.\n"
+ "SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the last pages.\n"
+ "SPLIT_ALL_EXCEPT_FIRST: Splits all except the first page.\n"
+ "SPLIT_ALL_EXCEPT_LAST: Splits all except the last page.\n"
+ "SPLIT_ALL: Splits all pages.\n"
@@ -17,8 +17,8 @@ public class ConvertEbookToPdfRequest {
+ " TXT, DOCX)",
contentMediaType =
"application/epub+zip, application/x-mobipocket-ebook, application/x-azw3,"
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@@ -13,17 +13,16 @@ public class SvgToPdfRequest {
@Schema(
description =
"The SVG file(s) to be converted to PDF. SVGs are scalable and have inherent"
+ " dimensions - the conversion uses these dimensions to determine the PDF"
+ " page size. If dimensions are not specified in the SVG, A4 size is"
+ " used.",
"The SVG file(s) to be converted to PDF. "
+ "SVGs are scalable and have inherent dimensions - the conversion uses these dimensions "
+ "to determine the PDF page size. If dimensions are not specified in the SVG, A4 size is used.",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] fileInput;
@Schema(
description =
"Whether to combine all SVG files into a single PDF (each SVG as a separate"
+ " page) or create separate PDF files for each SVG.",
"Whether to combine all SVG files into a single PDF (each SVG as a separate page) "
+ "or create separate PDF files for each SVG.",
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean combineIntoSinglePdf;
@@ -13,8 +13,7 @@ public class BookletImpositionRequest extends PDFFile {
@Schema(
description =
"The number of pages per side for booklet printing (always 2 for proper"
+ " booklet).",
"The number of pages per side for booklet printing (always 2 for proper booklet).",
type = "number",
defaultValue = "2",
requiredMode = Schema.RequiredMode.REQUIRED,
@@ -28,8 +28,7 @@ public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while"
+ " BY_COLUMNS fills pages column by column.",
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.",
type = "string",
defaultValue = "BY_ROWS",
allowableValues = {"BY_ROWS", "BY_COLUMNS"})
@@ -37,8 +36,7 @@ public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or"
+ " RTL (right-to-left).",
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).",
type = "string",
defaultValue = "LTR",
allowableValues = {"LTR", "RTL"})
@@ -35,17 +35,14 @@ public class MergePdfsRequest extends MultiplePDFFiles {
@Schema(
description =
"Flag indicating whether to generate a table of contents for the merged PDF. If"
+ " true, a table of contents will be created using the input filenames as"
+ " chapter names.",
"Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as"
+ " fileInput)",
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String clientFileIds;
}
@@ -23,8 +23,8 @@ public class OverlayPdfsRequest extends PDFFile {
@Schema(
description =
"The mode of overlaying: 'SequentialOverlay' for sequential application,"
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts",
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts",
allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String overlayMode;
@@ -32,8 +32,8 @@ public class OverlayPdfsRequest extends PDFFile {
@Schema(
description =
"An array of integers specifying the number of times each corresponding overlay"
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.",
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private int[] counts;
@@ -16,16 +16,14 @@ public class RearrangePagesRequest extends PDFWithPageNums {
implementation = SortTypes.class,
description =
"The custom mode for page rearrangement. Valid values are:\n"
+ "CUSTOM: Uses order defined in PageNums DUPLICATE: Duplicate pages n"
+ " times (if Page order defined as 4, then duplicates each page 4"
+ " times)REVERSE_ORDER: Reverses the order of all pages.\n"
+ "DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in"
+ " reverse (1, n, 2, n-1, ...). BOOKLET_SORT: Arranges pages for booklet"
+ " printing (last, first, second, second last, ...).\n"
+ "ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered"
+ " pages.\n"
+ "REMOVE_FIRST: Removes the first page.\n"
+ "REMOVE_LAST: Removes the last page.\n"
+ "REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.\n")
+ "CUSTOM: Uses order defined in PageNums "
+ "DUPLICATE: Duplicate pages n times (if Page order defined as 4, then duplicates each page 4 times)"
+ "REVERSE_ORDER: Reverses the order of all pages.\n"
+ "DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). "
+ "BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...).\n"
+ "ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages.\n"
+ "REMOVE_FIRST: Removes the first page.\n"
+ "REMOVE_LAST: Removes the last page.\n"
+ "REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.\n")
private String customMode;
}
@@ -13,8 +13,7 @@ public class RotatePDFRequest extends PDFFile {
@Schema(
description =
"The clockwise angle by which to rotate all pages in the PDF file. Must be a"
+ " multiple of 90.",
"The clockwise angle by which to rotate all pages in the PDF file. Must be a multiple of 90.",
type = "integer",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"0", "90", "180", "270"})
@@ -13,8 +13,7 @@ public class SplitPdfBySizeOrCountRequest extends PDFFile {
@Schema(
description =
"Determines the type of split: 0 for size, 1 for page count, 2 for document"
+ " count",
"Determines the type of split: 0 for size, 1 for page count, 2 for document count",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "0")
private int splitType;
@@ -22,8 +22,8 @@ public class AddCommentsRequest extends PDFFile {
@Schema(
description =
"JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ " origin at the page's bottom-left.",
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
+ " origin at the page's bottom-left.",
example =
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
@@ -41,8 +41,7 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
@Schema(
description =
"Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable"
+ " padding",
"Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding",
minimum = "0",
defaultValue = "0",
requiredMode = RequiredMode.NOT_REQUIRED)
@@ -51,9 +51,9 @@ public class AddStampRequest extends PDFWithPageNums {
@Schema(
description =
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2:"
+ " bottom-center, 3: bottom-right, 4: middle-left, 5: middle-center, 6:"
+ " middle-right, 7: top-left, 8: top-center, 9: top-right)",
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center,"
+ " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right,"
+ " 7: top-left, 8: top-center, 9: top-right)",
allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
defaultValue = "8",
requiredMode = Schema.RequiredMode.REQUIRED)
@@ -13,8 +13,7 @@ public class AutoSplitPdfRequest extends PDFFile {
@Schema(
description =
"Flag indicating if the duplex mode is active, where the page after the divider"
+ " also gets removed.",
"Flag indicating if the duplex mode is active, where the page after the divider also gets removed.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean duplexMode;
@@ -13,8 +13,7 @@ public class ExtractHeaderRequest extends PDFFile {
@Schema(
description =
"Flag indicating whether to use the first text as a fallback if no suitable"
+ " title is found. Defaults to false.",
"Flag indicating whether to use the first text as a fallback if no suitable title is found. Defaults to false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean useFirstTextAsFallback;
@@ -48,8 +48,7 @@ public class OptimizePdfRequest extends PDFFile {
@Schema(
description =
"Whether to convert images to high-contrast line art using ImageMagick. Default"
+ " is false.",
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean lineArt = false;
@@ -15,9 +15,9 @@ public class OverlayImageRequest extends PDFFile {
@Schema(
description =
"The image file to be overlaid onto the PDF. Supports raster formats (PNG,"
+ " JPEG, etc.) and vector format (SVG). SVG files are rendered as vector"
+ " graphics for crisp output at any resolution.",
"The image file to be overlaid onto the PDF. "
+ "Supports raster formats (PNG, JPEG, etc.) and vector format (SVG). "
+ "SVG files are rendered as vector graphics for crisp output at any resolution.",
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private MultipartFile imageFile;
@@ -27,8 +27,7 @@ public class ReplaceAndInvertColorRequest extends PDFFile {
@Schema(
description =
"If HIGH_CONTRAST_COLOR option selected, then pick the default color option for"
+ " text and background.",
"If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.",
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "WHITE_TEXT_ON_BLACK",
allowableValues = {
@@ -16,24 +16,22 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"Exact strings to find and black out. One entry per phrase to redact. Best for"
+ " known names, identifiers, and specific text found in the document.")
"Exact strings to find and black out. One entry per phrase to redact."
+ " Best for known names, identifiers, and specific text found in the document.")
private List<String> textValues = new ArrayList<>();
@Schema(
description =
"Regex patterns to match and redact. Each match anywhere in the document is"
+ " blacked out. Uses Java/PCRE regex syntax. Well-suited for strings that"
+ " follow known patterns, like phone numbers, email addresses, national ID"
+ " numbers, or dates (which can appear with different separators, optional"
+ " country codes, etc.). For fixed known strings such as names, use"
+ " textValues instead.")
"Regex patterns to match and redact. Each match anywhere in the document is blacked out."
+ " Uses Java/PCRE regex syntax. Well-suited for strings that follow known patterns, like"
+ " phone numbers, email addresses, national ID numbers, or"
+ " dates (which can appear with different separators, optional country codes,"
+ " etc.). For fixed known strings such as names, use textValues instead.")
private List<String> regexPatterns = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to wipe entirely (all content removed from those"
+ " pages).")
"1-indexed page numbers to wipe entirely (all content removed from those pages).")
private List<Integer> wipePages = new ArrayList<>();
@Schema(
@@ -46,15 +44,12 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"Rectangular areas to black out, each defined by a page number and bounding box"
+ " coordinates.")
"Rectangular areas to black out, each defined by a page number and bounding box coordinates.")
private List<ImageBox> imageBoxes = new ArrayList<>();
@Schema(
description =
"1-indexed page numbers to redact all detected images from. Pass an empty list"
+ " to redact images from every page. Omit or pass null to skip image"
+ " redaction entirely.")
"1-indexed page numbers to redact all detected images from. Pass an empty list to redact images from every page. Omit or pass null to skip image redaction entirely.")
private List<Integer> redactImagePages;
@Schema(description = "Redaction style options")
@@ -64,17 +59,17 @@ public class RedactExecuteRequest extends PDFFile {
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction begins (inclusive). Must appear verbatim in"
+ " the document — e.g. a section heading or a unique"
+ " sentence fragment.",
+ " redaction begins (inclusive). Must appear verbatim in"
+ " the document — e.g. a section heading or a unique"
+ " sentence fragment.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String startString,
@Schema(
description =
"A short, distinctive phrase (515 words) that marks where"
+ " redaction ends (inclusive). Must appear verbatim in the"
+ " document. Shorter phrases match more reliably.",
+ " redaction ends (inclusive). Must appear verbatim in the"
+ " document. Shorter phrases match more reliably.",
requiredMode = Schema.RequiredMode.REQUIRED,
minLength = 1)
String endString) {
@@ -90,26 +85,22 @@ public class RedactExecuteRequest extends PDFFile {
int pageIndex,
@Schema(
description =
"Left x coordinate of the redaction rectangle in PDF user-space"
+ " points.",
"Left x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x1,
@Schema(
description =
"Top y coordinate of the redaction rectangle in PDF user-space"
+ " points.",
"Top y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y1,
@Schema(
description =
"Right x coordinate of the redaction rectangle in PDF"
+ " user-space points.",
"Right x coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float x2,
@Schema(
description =
"Bottom y coordinate of the redaction rectangle in PDF"
+ " user-space points.",
"Bottom y coordinate of the redaction rectangle in PDF user-space points.",
requiredMode = Schema.RequiredMode.REQUIRED)
float y2) {}
@@ -173,8 +173,7 @@ public class AttachmentService implements AttachmentServiceInterface {
Optional<byte[]> attachmentData = readAttachmentData(embeddedFile);
if (attachmentData.isEmpty()) {
log.warn(
"Skipping attachment '{}' because it exceeds the size limit of {}"
+ " bytes",
"Skipping attachment '{}' because it exceeds the size limit of {} bytes",
sanitizedFilename,
maxAttachmentSizeBytes);
continue;

Some files were not shown because too many files have changed in this diff Show More