mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ada0cdf5d | ||
|
|
eb2c18325b | ||
|
|
2f1fe2c80c | ||
|
|
1051f28c52 | ||
|
|
29ccbf7ae6 | ||
|
|
007c8e17de | ||
|
|
81dbb29be3 | ||
|
|
caef0477a9 | ||
|
|
7682a0dd54 | ||
|
|
9d081d1792 | ||
|
|
f90ed4657a | ||
|
|
a6c7a68242 | ||
|
|
a184b394d6 | ||
|
|
302d04c201 | ||
|
|
68ae9d52fb | ||
|
|
089de247b4 | ||
|
|
6552ba905c | ||
|
|
ee36b6f616 | ||
|
|
627091f8df | ||
|
|
67a37d3291 | ||
|
|
0c3a512b21 | ||
|
|
707cfa3140 | ||
|
|
ea91a35518 | ||
|
|
5a5aada2d8 | ||
|
|
a4ef70eaf8 | ||
|
|
d76d25654a | ||
|
|
31b00b7d06 | ||
|
|
f00c12ad8a | ||
|
|
1f5c046362 | ||
|
|
1787443102 | ||
|
|
8fb281525b | ||
|
|
4c3be4992f | ||
|
|
2790467241 | ||
|
|
dd770dfa76 | ||
|
|
b146d9994d | ||
|
|
4b6d4885f4 | ||
|
|
2a151b65f7 | ||
|
|
9458fcd0e2 | ||
|
|
1e8c41425b | ||
|
|
2399da6893 | ||
|
|
2c15044dfa | ||
|
|
b3277a18c8 | ||
|
|
5ca1586976 | ||
|
|
ce47a4e3af | ||
|
|
31f6ea4b25 | ||
|
|
859a2d97c2 | ||
|
|
f9fbc37800 | ||
|
|
a422deecdb | ||
|
|
7f8f09c899 | ||
|
|
96920b1186 | ||
|
|
3626319685 | ||
|
|
755f270a31 | ||
|
|
6e1a7454ca | ||
|
|
6b9567cf38 | ||
|
|
c731d5fd5d | ||
|
|
beb99e273b | ||
|
|
ece1bb6865 | ||
|
|
5b9ef852ab | ||
|
|
672e81d286 | ||
|
|
8abe734f0b | ||
|
|
8a59c10f42 | ||
|
|
d62f2ad3ed | ||
|
|
f60a075443 | ||
|
|
c059e13423 | ||
|
|
575684ee4b | ||
|
|
294b616a63 | ||
|
|
f61121953e | ||
|
|
8645280ee5 | ||
|
|
bd50e2e59d | ||
|
|
77abaf5de5 | ||
|
|
503a81be50 | ||
|
|
67fc5c70b9 | ||
|
|
1e6514a471 | ||
|
|
c4874deb5d | ||
|
|
4ee47fdddd | ||
|
|
270af912d4 | ||
|
|
efd2ed754c | ||
|
|
6730ad7cbb | ||
|
|
8fb9fced35 | ||
|
|
ed56490f93 | ||
|
|
c892b985e2 | ||
|
|
95c39b4648 | ||
|
|
44f2d0d679 | ||
|
|
1a63aa045e | ||
|
|
1ae24e18b5 | ||
|
|
4764f1931b | ||
|
|
7f00441aab | ||
|
|
561d07b8b1 | ||
|
|
f39db01919 | ||
|
|
4ab7d3b3ae | ||
|
|
d8519a6517 | ||
|
|
34c9e9bdc5 | ||
|
|
4663a9a194 | ||
|
|
f89f7d99ba | ||
|
|
84e30cd008 | ||
|
|
69236a89c8 | ||
|
|
b66e12d15d | ||
|
|
3fe8adc5cb | ||
|
|
51f5345151 | ||
|
|
5541dd666c | ||
|
|
5605062153 | ||
|
|
86774d556e |
@@ -22,7 +22,7 @@ runs:
|
||||
steps:
|
||||
- name: Generate a GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ inputs.app-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.10.0
|
||||
pkgver=2.11.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.10.0
|
||||
pkgver=2.11.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -48,6 +48,81 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
embedpdf:
|
||||
patterns:
|
||||
- "@embedpdf/*"
|
||||
mantine:
|
||||
patterns:
|
||||
- "@mantine/*"
|
||||
- "postcss-preset-mantine"
|
||||
mui:
|
||||
patterns:
|
||||
- "@mui/*"
|
||||
tauri-js:
|
||||
patterns:
|
||||
- "@tauri-apps/*"
|
||||
emotion:
|
||||
patterns:
|
||||
- "@emotion/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
- "vite-*"
|
||||
- "@vitejs/*"
|
||||
vitest:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
testing-library:
|
||||
patterns:
|
||||
- "@testing-library/*"
|
||||
i18next:
|
||||
patterns:
|
||||
- "i18next"
|
||||
- "i18next-*"
|
||||
- "react-i18next"
|
||||
iconify:
|
||||
patterns:
|
||||
- "@iconify/*"
|
||||
- "@iconify-json/*"
|
||||
stripe:
|
||||
patterns:
|
||||
- "@stripe/*"
|
||||
posthog:
|
||||
patterns:
|
||||
- "@posthog/*"
|
||||
- "posthog-js"
|
||||
supabase:
|
||||
patterns:
|
||||
- "@supabase/*"
|
||||
dnd-kit:
|
||||
patterns:
|
||||
- "@dnd-kit/*"
|
||||
tailwind:
|
||||
patterns:
|
||||
- "tailwindcss"
|
||||
- "@tailwindcss/*"
|
||||
postcss:
|
||||
patterns:
|
||||
- "postcss"
|
||||
- "postcss-*"
|
||||
exclude-patterns:
|
||||
- "postcss-preset-mantine"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directories:
|
||||
@@ -59,6 +134,24 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
tauri:
|
||||
patterns:
|
||||
- "tauri"
|
||||
- "tauri-build"
|
||||
- "tauri-plugin-*"
|
||||
serde:
|
||||
patterns:
|
||||
- "serde"
|
||||
- "serde_*"
|
||||
tracing:
|
||||
patterns:
|
||||
- "tracing"
|
||||
- "tracing-*"
|
||||
tokio:
|
||||
patterns:
|
||||
- "tokio"
|
||||
- "tokio-*"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
|
||||
@@ -35,13 +35,13 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Add 'in_progress' reaction to comment
|
||||
if: github.event_name == 'issue_comment'
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
|
||||
- name: Add success reaction to comment
|
||||
if: success() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
|
||||
- name: Add failure reaction to comment
|
||||
if: failure() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
|
||||
- name: Post deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Remove 'pr-deployed' label if present
|
||||
id: remove-label-comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Comment on fixer failures
|
||||
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- engine-check -->';
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Publish stirling-pdf-desktop to AUR
|
||||
if: ${{ github.event_name == 'release' || inputs.dry_run == false }}
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
|
||||
with:
|
||||
pkgname: stirling-pdf-desktop
|
||||
pkgbuild: .github/aur/stirling-pdf-desktop/PKGBUILD
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Backend build, format check, and coverage
|
||||
|
||||
# Reusable workflow called from build.yml. Runs the full backend build matrix
|
||||
# (JDK 21/25 × spring-security on/off), Spotless formatting check, JUnit, and
|
||||
# Reusable workflow called from build.yml. Runs the backend build matrix
|
||||
# (JDK 25 × spring-security on/off), Spotless formatting check, JUnit, and
|
||||
# posts Jacoco coverage to PRs.
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -18,11 +18,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [21, 25]
|
||||
jdk-version: [25]
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -63,7 +63,15 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task frontend:test:e2e:install -- chromium
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
# Enterprise tests target :8080 (Spring Boot's bundled frontend), but
|
||||
# playwright's webServer config still launches `vite preview --port 5173`
|
||||
# before any test run, which needs dist/ to exist. VITE_BUILD_FOR_PREVIEW
|
||||
# forces absolute asset paths so vite preview can serve deep SPA routes.
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
|
||||
- name: Resolve kubernetes.docker.internal to localhost
|
||||
# The compose stacks set KC_HOSTNAME=kubernetes.docker.internal so
|
||||
@@ -140,7 +148,7 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
run: task frontend:test:e2e -- --project=enterprise --grep "OAuth"
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
run: |
|
||||
@@ -213,7 +221,7 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
run: task frontend:test:e2e -- --project=enterprise --grep "SAML"
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
run: |
|
||||
@@ -243,7 +251,7 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
run: task frontend:test:e2e -- --project=enterprise --grep "Enterprise license"
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
run: |
|
||||
@@ -257,7 +265,7 @@ jobs:
|
||||
stop_backend
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr-data
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR
|
||||
if: env.SCRIPT_OUTPUT != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
config-file: "./.github/config/dependency-review-config.yml"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: docker-compose-test-reports
|
||||
path: testing/reports/
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -34,62 +34,30 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task frontend:test:e2e:install -- chromium
|
||||
- name: Start Spring Boot backend (background)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
# Suppress the analytics opt-in modal that fires on first admin login when
|
||||
# enableAnalytics is null (see Onboarding.tsx). The modal renders a Mantine
|
||||
# overlay that intercepts pointer events on every tool page until dismissed,
|
||||
# which causes every "click run button" assertion in the live suite to fail.
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
# NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set.
|
||||
# The live-setup project's bootstrap spec performs the real first-login
|
||||
# flow against the backend's default admin/stirling user, exercising the
|
||||
# forced-password-change UI and leaving the DB at admin/adminadmin for
|
||||
# the rest of the live suite. This is both real coverage of the first-
|
||||
# login flow and a stronger seed than env-var-driven user creation.
|
||||
run: |
|
||||
nohup ./gradlew :stirling-pdf:bootRun > /tmp/backend.log 2>&1 &
|
||||
echo $! > /tmp/backend.pid
|
||||
- name: Wait for backend to become ready
|
||||
run: |
|
||||
start=$SECONDS
|
||||
# 300 iterations × 2s = 10 minute ceiling
|
||||
for i in $(seq 1 300); do
|
||||
if curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1; then
|
||||
echo "Backend up after $((SECONDS - start))s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Backend did not become ready in $((SECONDS - start))s"
|
||||
tail -200 /tmp/backend.log || true
|
||||
exit 1
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run live E2E tests (chromium)
|
||||
id: live-tests
|
||||
run: task frontend:test:e2e -- --project=live
|
||||
run: task e2e:live
|
||||
- name: Print backend log on failure
|
||||
if: failure() && steps.live-tests.conclusion == 'failure'
|
||||
run: |
|
||||
echo "::group::Spring Boot backend log (last 500 lines)"
|
||||
tail -500 /tmp/backend.log || echo "no backend log found"
|
||||
tail -500 .test-state/playwright/backend.log || echo "no backend log found"
|
||||
echo "::endgroup::"
|
||||
- name: Stop backend
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/backend.pid ]; then
|
||||
kill "$(cat /tmp/backend.pid)" 2>/dev/null || true
|
||||
fi
|
||||
- name: Upload backend log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-log-live-${{ github.run_id }}
|
||||
path: /tmp/backend.log
|
||||
path: .test-state/playwright/backend.log
|
||||
retention-days: 7
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-live-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -28,12 +28,16 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task frontend:test:e2e:install -- chromium
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
run: task frontend:test:e2e -- --project=stubbed --workers=3
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.frontend-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
@@ -85,8 +85,28 @@ jobs:
|
||||
echo "to see what still needs fixing manually."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
- name: Remove frontend check comment on success
|
||||
if: steps.frontend-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -90,21 +90,21 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,13 +140,13 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -218,7 +218,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -227,20 +243,30 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:prepare
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -321,7 +347,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -342,7 +368,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -557,7 +583,7 @@ jobs:
|
||||
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
@@ -568,7 +594,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: always() && steps.digicert-setup.conclusion != 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -582,7 +608,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -614,7 +640,7 @@ jobs:
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -35,14 +35,14 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task frontend:test:e2e:install
|
||||
run: task e2e:install
|
||||
|
||||
- name: Run E2E tests (all browsers)
|
||||
run: task frontend:test:e2e
|
||||
run: task e2e:cross-browser
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -22,14 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
dmg_arm64_sha256: ${{ steps.hashes.outputs.dmg_arm64_sha256 }}
|
||||
dmg_x86_64_sha256: ${{ steps.hashes.outputs.dmg_x86_64_sha256 }}
|
||||
dmg_sha256: ${{ steps.hashes.outputs.dmg_sha256 }}
|
||||
msi_sha256: ${{ steps.hashes.outputs.msi_sha256 }}
|
||||
deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }}
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -63,17 +62,15 @@ jobs:
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
}
|
||||
|
||||
DMG_ARM64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-aarch64.dmg")
|
||||
DMG_X64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-x86_64.dmg")
|
||||
DMG_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-universal.dmg")
|
||||
MSI_SHA=$(download_sha256 "${BASE}/Stirling-PDF-windows-x86_64.msi")
|
||||
DEB_SHA=$(download_sha256 "${BASE}/Stirling-PDF-linux-x86_64.deb")
|
||||
JAR_SHA=$(download_sha256 "${BASE}/Stirling-PDF-with-login.jar")
|
||||
|
||||
echo "dmg_arm64_sha256=$DMG_ARM64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_x86_64_sha256=$DMG_X64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_sha256=$DMG_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew-and-scoop:
|
||||
needs: get-release-info
|
||||
@@ -82,7 +79,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -93,22 +90,21 @@ jobs:
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: tap
|
||||
|
||||
# Stirling-PDF now ships a single universal DMG, so the Cask should
|
||||
# have one sha256 line rather than separate on_arm/on_intel blocks.
|
||||
# We rewrite every `sha256 "..."` in the Cask to the same value so
|
||||
# the workflow keeps working through the Cask migration: pre-migration
|
||||
# both arch blocks get the universal SHA (still correct, since both
|
||||
# would resolve to the same DMG), post-migration the lone sha256 line
|
||||
# is updated.
|
||||
- name: Update Homebrew cask (Casks/stirling-pdf.rb)
|
||||
env:
|
||||
VERSION: ${{ needs.get-release-info.outputs.version }}
|
||||
ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }}
|
||||
X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }}
|
||||
DMG_SHA: ${{ needs.get-release-info.outputs.dmg_sha256 }}
|
||||
run: |
|
||||
CASK="tap/Casks/stirling-pdf.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK"
|
||||
awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" '
|
||||
/on_arm/ { in_arm=1 }
|
||||
/on_intel/ { in_arm=0; in_intel=1 }
|
||||
/end/ { in_arm=0; in_intel=0 }
|
||||
in_arm && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" arm "\"") }
|
||||
in_intel && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" x64 "\"") }
|
||||
{ print }
|
||||
' "$CASK" > tmp && mv tmp "$CASK"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$CASK"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${DMG_SHA}\"/g" "$CASK"
|
||||
|
||||
- name: Update Homebrew formula (Formula/stirling-pdf-server.rb)
|
||||
env:
|
||||
@@ -116,8 +112,8 @@ jobs:
|
||||
JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }}
|
||||
run: |
|
||||
FORMULA="tap/Formula/stirling-pdf-server.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
|
||||
- name: Update Scoop stirling-pdf.json
|
||||
env:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
|
||||
@@ -2,6 +2,22 @@ name: Push Docker Image with VersionNumber
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_main_app:
|
||||
description: "Build & push the main Stirling-PDF image (latest, fat, ultra-lite)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_unoserver:
|
||||
description: "Build & push the standalone stirling-unoserver image."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force_unoserver_rebuild:
|
||||
description: "Rebuild stirling-unoserver even if its source hash is unchanged."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -31,9 +47,13 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
# On push events these stay 'true'; on workflow_dispatch they follow the inputs.
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +66,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -56,7 +76,7 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -76,13 +96,13 @@ jobs:
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
@@ -108,6 +128,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
if: env.RUN_MAIN_APP == 'true'
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
@@ -122,6 +143,8 @@ jobs:
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
# Empty-tag guard: build-push-action errors when asked to push with no tags.
|
||||
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -140,7 +163,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
@@ -156,7 +179,7 @@ jobs:
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -170,7 +193,7 @@ jobs:
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -186,7 +209,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
@@ -200,7 +223,7 @@ jobs:
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -214,7 +237,7 @@ jobs:
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -230,7 +253,7 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
@@ -240,3 +263,136 @@ jobs:
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
# Standalone unoserver image — versioned independently via
|
||||
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
|
||||
# only when the version is new. main/testMain: republish :alpha only
|
||||
# when the source hash differs from the published image's annotation.
|
||||
- name: Read unoserver image version
|
||||
id: unoserverVersion
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
run: |
|
||||
version=$(tr -d '[:space:]' < docker/unoserver/VERSION)
|
||||
if [ -z "$version" ]; then
|
||||
echo "docker/unoserver/VERSION is empty"; exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "Unoserver image version (from file): ${version}"
|
||||
|
||||
- name: Compute unoserver image source hash
|
||||
id: unoserverHash
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
hash=$(cat \
|
||||
docker/unoserver/Dockerfile \
|
||||
docker/unoserver/entrypoint.sh \
|
||||
docker/unoserver/healthcheck.sh \
|
||||
docker/unoserver/VERSION \
|
||||
| sha256sum | cut -d' ' -f1)
|
||||
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
|
||||
echo "Unoserver source hash: ${hash}"
|
||||
|
||||
- name: Decide whether to publish unoserver image
|
||||
id: unoserverDecision
|
||||
if: env.RUN_UNOSERVER == 'true'
|
||||
env:
|
||||
UNOSERVER_VERSION: ${{ steps.unoserverVersion.outputs.version }}
|
||||
UNOSERVER_HASH: ${{ steps.unoserverHash.outputs.hash }}
|
||||
UNOSERVER_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-unoserver
|
||||
UNOSERVER_HASH_ANNOTATION: org.stirlingpdf.unoserver-source-hash
|
||||
FORCE_REBUILD: ${{ inputs.force_unoserver_rebuild }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
mode="skip"
|
||||
tags=""
|
||||
|
||||
read_published_hash() {
|
||||
local ref="$1"
|
||||
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
|
||||
| jq -r --arg key "$UNOSERVER_HASH_ANNOTATION" \
|
||||
'.annotations[$key] // empty' \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
# Manual dispatch from any branch routes to the :alpha publish path.
|
||||
EFFECTIVE_REF="$GH_REF"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
EFFECTIVE_REF="refs/heads/testMain"
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/master|refs/heads/V2-master)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest"
|
||||
elif docker manifest inspect "${UNOSERVER_IMAGE}:${UNOSERVER_VERSION}" >/dev/null 2>&1; then
|
||||
echo "stirling-unoserver:${UNOSERVER_VERSION} already on GHCR — skipping"
|
||||
else
|
||||
echo "stirling-unoserver:${UNOSERVER_VERSION} is new — will publish"
|
||||
mode="stable"
|
||||
tags="${UNOSERVER_IMAGE}:${UNOSERVER_VERSION},${UNOSERVER_IMAGE}:latest"
|
||||
fi
|
||||
;;
|
||||
refs/heads/main|refs/heads/testMain)
|
||||
published_hash=$(read_published_hash "${UNOSERVER_IMAGE}:alpha")
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_unoserver_rebuild=true — rebuilding :alpha regardless"
|
||||
mode="alpha"
|
||||
tags="${UNOSERVER_IMAGE}:alpha"
|
||||
elif [ -n "$published_hash" ] && [ "$published_hash" = "$UNOSERVER_HASH" ]; then
|
||||
echo "Published :alpha source hash matches (${published_hash}) — skipping"
|
||||
else
|
||||
if [ -z "$published_hash" ]; then
|
||||
echo ":alpha has no source-hash annotation (first publish or pre-tracking image) — will publish"
|
||||
else
|
||||
echo "Source hash changed (was ${published_hash}, now ${UNOSERVER_HASH}) — will publish"
|
||||
fi
|
||||
mode="alpha"
|
||||
tags="${UNOSERVER_IMAGE}:alpha"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Branch ${GH_REF} does not publish unoserver image"
|
||||
;;
|
||||
esac
|
||||
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push unoserver image
|
||||
id: build-push-unoserver
|
||||
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-unoserver
|
||||
cache-to: type=gha,mode=max,scope=stirling-unoserver
|
||||
tags: ${{ steps.unoserverDecision.outputs.tags }}
|
||||
# Manifest annotation read by the decision step above to detect drift.
|
||||
annotations: |
|
||||
index:org.stirlingpdf.unoserver-source-hash=${{ steps.unoserverHash.outputs.hash }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign unoserver image
|
||||
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode == 'stable'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-unoserver.outputs.digest }}
|
||||
TAGS: ${{ steps.unoserverDecision.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
|
||||
fi
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Build Tauri Applications
|
||||
|
||||
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS arm+intel /
|
||||
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS universal /
|
||||
# Linux). Called from build.yml on PRs that touch desktop sources (gated
|
||||
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
|
||||
# on demand via workflow_dispatch with a per-platform selector.
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -47,18 +47,15 @@ jobs:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
|
||||
# Resolve requested platform — populated by either workflow_dispatch
|
||||
# or workflow_call inputs; both paths default to "all".
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -87,7 +84,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -101,7 +98,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -111,7 +108,19 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -120,19 +129,28 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
|
||||
run: task desktop:prepare
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -213,7 +231,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -234,7 +252,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -245,8 +263,12 @@ jobs:
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Sign JPDFium dylibs inside bootJar (macOS only)
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: bash frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh
|
||||
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
@@ -373,7 +395,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/src-tauri/target
|
||||
@@ -398,7 +420,7 @@ jobs:
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
@@ -472,7 +494,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -491,7 +513,7 @@ jobs:
|
||||
echo "❌ No Windows executable found"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
@@ -533,12 +555,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Post/Update PR Comment with Download Links
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
@@ -556,8 +578,7 @@ jobs:
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
|
||||
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
|
||||
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
|
||||
};
|
||||
|
||||
@@ -625,7 +646,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
@@ -160,3 +160,58 @@ jobs:
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
test-build-unoserver-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
cache-from: type=gha,scope=stirling-unoserver
|
||||
cache-to: type=gha,mode=max,scope=stirling-unoserver
|
||||
platforms: linux/amd64
|
||||
tags: stirling-unoserver:pr-test
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Smoke test the built image
|
||||
run: |
|
||||
set -eu
|
||||
docker run -d --name unoserver-smoke \
|
||||
-e UNOSERVER_RECYCLE_INTERVAL_SECONDS=0 \
|
||||
stirling-unoserver:pr-test
|
||||
deadline=$((SECONDS + 60))
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' unoserver-smoke 2>/dev/null || echo "starting")
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "unoserver became healthy"
|
||||
docker logs unoserver-smoke | tail -30
|
||||
docker rm -f unoserver-smoke
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "unoserver did not become healthy in time"
|
||||
docker logs unoserver-smoke || true
|
||||
docker rm -f unoserver-smoke || true
|
||||
exit 1
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -150,14 +150,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ exampleYmlFiles/stirling/
|
||||
/testing/cucumber/junit/
|
||||
/testing/cucumber/report.html
|
||||
/testing/.failed_tests
|
||||
/.test-state/
|
||||
SwaggerDoc.json
|
||||
|
||||
# Runtime storage for uploaded files and user data (not Java source code)
|
||||
@@ -264,5 +265,11 @@ docs/type3/signatures/
|
||||
# Type3 sample PDFs (development only)
|
||||
**/type3/samples/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
*.playwright-mcp.png
|
||||
|
||||
+36
-12
@@ -1,5 +1,20 @@
|
||||
version: '3'
|
||||
|
||||
# Gradle invocation strategy:
|
||||
# - Linux/macOS: `./gradlew` runs the POSIX shell wrapper natively.
|
||||
# - Windows: `cmd /c ".\gradlew.bat ..."` invokes the .bat wrapper through
|
||||
# cmd.exe so it inherits the user's Windows-side `JAVA_HOME`
|
||||
# and PATH. Routing through `bash gradlew` on Windows ends up
|
||||
# under WSL or Git-Bash, neither of which inherits
|
||||
# Adoptium/Temurin's default Windows-only Java env - gradlew
|
||||
# then errors with "JAVA_HOME is not set and no 'java' command
|
||||
# could be found".
|
||||
#
|
||||
# The entire `.\gradlew.bat ...` payload is double-quoted so
|
||||
# the leading `.\` survives mvdan/sh's POSIX backslash
|
||||
# stripping; cmd.exe also requires `.\` (not bare `gradlew.bat`)
|
||||
# because modern Windows excludes cwd from cmd's search path.
|
||||
|
||||
tasks:
|
||||
dev:
|
||||
desc: "Start backend dev server"
|
||||
@@ -10,15 +25,24 @@ tasks:
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}set "AIENGINE_URL={{.AIENGINE_URL}}" && set "AIENGINE_ENABLED=true" && {{end}}cmd /c gradlew.bat :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)"
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- cmd: cmd /c ".\gradlew.bat clean bootRun -PbuildWithFrontend=true"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
|
||||
platforms: [linux, darwin]
|
||||
|
||||
build:
|
||||
desc: "Full backend build"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean build
|
||||
- cmd: cmd /c ".\gradlew.bat clean build"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean build
|
||||
platforms: [linux, darwin]
|
||||
@@ -26,7 +50,7 @@ tasks:
|
||||
build:fast:
|
||||
desc: "Build without tests"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean build -x test
|
||||
- cmd: cmd /c ".\gradlew.bat clean build -x test"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean build -x test
|
||||
platforms: [linux, darwin]
|
||||
@@ -34,7 +58,7 @@ tasks:
|
||||
build:ci:
|
||||
desc: "Build for CI (formatting checked separately)"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat build -PnoSpotless
|
||||
- cmd: cmd /c ".\gradlew.bat build -PnoSpotless"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew build -PnoSpotless
|
||||
platforms: [linux, darwin]
|
||||
@@ -42,7 +66,7 @@ tasks:
|
||||
test:
|
||||
desc: "Run backend tests"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat test
|
||||
- cmd: cmd /c ".\gradlew.bat test"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew test
|
||||
platforms: [linux, darwin]
|
||||
@@ -50,7 +74,7 @@ tasks:
|
||||
format:
|
||||
desc: "Auto-fix code formatting"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat spotlessApply
|
||||
- cmd: cmd /c ".\gradlew.bat spotlessApply"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew spotlessApply
|
||||
platforms: [linux, darwin]
|
||||
@@ -58,7 +82,7 @@ tasks:
|
||||
format:check:
|
||||
desc: "Check code formatting"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat spotlessCheck
|
||||
- cmd: cmd /c ".\gradlew.bat spotlessCheck"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew spotlessCheck
|
||||
platforms: [linux, darwin]
|
||||
@@ -71,7 +95,7 @@ tasks:
|
||||
swagger:
|
||||
desc: "Generate OpenAPI docs"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat :stirling-pdf:copySwaggerDoc
|
||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew :stirling-pdf:copySwaggerDoc
|
||||
platforms: [linux, darwin]
|
||||
@@ -92,7 +116,7 @@ tasks:
|
||||
desc: "Print project version"
|
||||
silent: true
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat printVersion --quiet | tail -1
|
||||
- cmd: cmd /c ".\gradlew.bat printVersion --quiet" | tail -1
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew printVersion --quiet | tail -1
|
||||
platforms: [linux, darwin]
|
||||
@@ -100,7 +124,7 @@ tasks:
|
||||
licenses:check:
|
||||
desc: "Check dependency licenses"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat checkLicense --no-parallel
|
||||
- cmd: cmd /c ".\gradlew.bat checkLicense --no-parallel"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew checkLicense --no-parallel
|
||||
platforms: [linux, darwin]
|
||||
@@ -108,7 +132,7 @@ tasks:
|
||||
licenses:generate:
|
||||
desc: "Check and generate dependency license report"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat checkLicense generateLicenseReport --no-parallel
|
||||
- cmd: cmd /c ".\gradlew.bat checkLicense generateLicenseReport --no-parallel"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew checkLicense generateLicenseReport --no-parallel
|
||||
platforms: [linux, darwin]
|
||||
@@ -116,7 +140,7 @@ tasks:
|
||||
clean:
|
||||
desc: "Clean build artifacts"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat clean
|
||||
- cmd: cmd /c ".\gradlew.bat clean"
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew clean
|
||||
platforms: [linux, darwin]
|
||||
|
||||
+36
-4
@@ -3,6 +3,22 @@ version: '3'
|
||||
vars:
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
|
||||
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
|
||||
JPDFIUM_PLATFORMS:
|
||||
sh: |
|
||||
if [ -n "${JPDFIUM_PLATFORMS:-}" ]; then
|
||||
echo "$JPDFIUM_PLATFORMS"
|
||||
else
|
||||
case "{{OS}}-{{ARCH}}" in
|
||||
darwin-arm64) echo "darwin-arm64";;
|
||||
darwin-amd64) echo "darwin-x64";;
|
||||
linux-amd64) echo "linux-x64";;
|
||||
linux-arm64) echo "linux-arm64";;
|
||||
windows-amd64) echo "windows-x64";;
|
||||
*) echo "all";;
|
||||
esac
|
||||
fi
|
||||
|
||||
tasks:
|
||||
prepare:
|
||||
desc: "Prepare desktop build dependencies"
|
||||
@@ -71,15 +87,16 @@ tasks:
|
||||
deps: [jlink:jar, jlink:runtime]
|
||||
|
||||
jlink:jar:
|
||||
desc: "Build backend JAR for Tauri bundling"
|
||||
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
|
||||
run: once
|
||||
dir: ..
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
cmds:
|
||||
- cmd: cmd /c gradlew.bat bootJar --no-daemon
|
||||
- echo "Building bootJar with JPDFium natives for {{.JPDFIUM_PLATFORMS}}"
|
||||
- cmd: cmd /c gradlew.bat bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
|
||||
platforms: [windows]
|
||||
- cmd: ./gradlew bootJar --no-daemon
|
||||
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
|
||||
platforms: [linux, darwin]
|
||||
- mkdir -p frontend/src-tauri/libs
|
||||
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
|
||||
@@ -96,7 +113,7 @@ tasks:
|
||||
jlink
|
||||
--add-modules {{.JLINK_MODULES}}
|
||||
--strip-debug
|
||||
--compress=2
|
||||
--compress=zip-6
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output src-tauri/runtime/jre
|
||||
@@ -107,3 +124,18 @@ tasks:
|
||||
desc: "Remove JLink runtime and bundled JARs"
|
||||
cmds:
|
||||
- rm -rf src-tauri/libs src-tauri/runtime
|
||||
|
||||
# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
|
||||
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
|
||||
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
|
||||
# the default desktop:build flow. Requires AARCH64_JAVA_HOME and
|
||||
# X64_JAVA_HOME to point at matching JDK installations with jmods/.
|
||||
jlink:universal-mac:
|
||||
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
|
||||
deps: [jlink:jar]
|
||||
platforms: [darwin]
|
||||
env:
|
||||
JLINK_MODULES: "{{.JLINK_MODULES}}"
|
||||
OUTPUT_DIR: src-tauri/runtime/jre
|
||||
cmds:
|
||||
- scripts/build-universal-mac-jre.sh
|
||||
|
||||
@@ -55,3 +55,9 @@ tasks:
|
||||
desc: "Tail docker compose logs"
|
||||
cmds:
|
||||
- docker compose -f {{.COMPOSE_DIR}}/docker-compose.yml logs -f
|
||||
|
||||
test:
|
||||
desc: "Run full Docker integration test suite (builds all variants and tests them)"
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/test.sh {{.CLI_ARGS}}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
install:
|
||||
desc: "Install Playwright browsers"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:install' ]
|
||||
cmds:
|
||||
- npx playwright install {{.CLI_ARGS}} --with-deps
|
||||
|
||||
stubbed:
|
||||
desc: "Run stubbed E2E tests"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
||||
|
||||
live:
|
||||
desc: "Run live E2E tests"
|
||||
summary: |
|
||||
Auto-spawns a Spring Boot backend with isolated state under
|
||||
.test-state/playwright/ (purged on every run) so the suite doesn't
|
||||
touch your local DB, settings.yml, backups or customFiles. The
|
||||
backend is stopped automatically when the runner exits.
|
||||
|
||||
Pass extra Playwright flags via -- :
|
||||
task e2e:live -- --headed --grep merge
|
||||
deps:
|
||||
- live:backend
|
||||
- live:runner
|
||||
|
||||
live:backend:
|
||||
internal: true
|
||||
ignore_error: true
|
||||
vars:
|
||||
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
||||
env:
|
||||
STIRLING_BASE_PATH: '{{.BASE_DIR}}'
|
||||
# Suppress the analytics opt-in modal that fires on first admin login.
|
||||
# It renders a Mantine overlay that intercepts pointer events and blocks
|
||||
# FirstLoginSlide from rendering, so the bootstrap spec times out waiting
|
||||
# for the password-change prompt.
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
# NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set.
|
||||
# The live-setup project's bootstrap spec performs the real first-login
|
||||
# flow against the backend's default admin/stirling user, exercising the
|
||||
# forced-password-change UI and leaving the DB at admin/adminadmin for
|
||||
# the rest of the live suite. This is both real coverage of the first-
|
||||
# login flow and a stronger seed than env-var-driven user creation.
|
||||
cmds:
|
||||
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) doesn't
|
||||
# support `&` + `$!` + `wait` reliably (gradle was never actually
|
||||
# backgrounded, so `$!` recorded a stale PID and the runner saw an
|
||||
# immediate "backend died"). Real bash backgrounds gradle properly and
|
||||
# gives us a stable PID to clean up.
|
||||
- |
|
||||
bash -c '
|
||||
set -e
|
||||
rm -rf "{{.BASE_DIR}}"
|
||||
mkdir -p "{{.BASE_DIR}}"
|
||||
# Background gradle and record its PID so the runner can clean up
|
||||
# the exact process tree (wrapper + forked Spring Boot JVM) without
|
||||
# resorting to fuzzy `pkill -f` patterns. `wait` keeps this script
|
||||
# alive for the lifetime of gradle so Task'"'"'s parallel deps stay
|
||||
# synchronised.
|
||||
bash gradlew :stirling-pdf:bootRun > "{{.BASE_DIR}}/backend.log" 2>&1 &
|
||||
GRADLE_PID=$!
|
||||
echo $GRADLE_PID > "{{.BASE_DIR}}/backend.pid"
|
||||
wait $GRADLE_PID
|
||||
'
|
||||
|
||||
live:runner:
|
||||
internal: true
|
||||
deps: [ ':frontend:prepare' ]
|
||||
dir: frontend
|
||||
vars:
|
||||
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
|
||||
cmds:
|
||||
# Wrapped in `bash -c` because Task's embedded shell (mvdan/sh) only
|
||||
# supports `EXIT`/`ERR` in `trap`, not `INT`/`TERM`. CLI_ARGS are
|
||||
# passed positionally so quoted args like --grep "foo bar" survive.
|
||||
- |
|
||||
bash -c '
|
||||
set +e
|
||||
# bootRun forks a separate Spring Boot JVM as a child of the gradle
|
||||
# wrapper; killing only the wrapper leaves that JVM orphaned holding
|
||||
# :8080. Walk the recorded PID'"'"'s process tree via pgrep so we kill
|
||||
# every descendant, then lsof as a last-resort backstop.
|
||||
kill_tree() {
|
||||
local pid=$1
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
kill_tree "$child"
|
||||
done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
cleanup() {
|
||||
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
||||
echo "Stopping backend..."
|
||||
kill_tree "$(cat "{{.BASE_DIR}}/backend.pid")"
|
||||
sleep 1
|
||||
lsof -ti:8080 2>/dev/null | xargs kill 2>/dev/null || true
|
||||
echo "Backend stopped"
|
||||
fi
|
||||
}
|
||||
# Trap so cleanup runs on every exit path: tests pass, tests fail,
|
||||
# backend never starts, user Ctrl-Cs. Without this the gradle JVM
|
||||
# is left orphaned holding :8080 whenever the wait loop times out.
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Waiting for backend on :8080 (up to 10min; first compile can be slow)..."
|
||||
WAITED=0
|
||||
while [ $WAITED -lt 600 ]; do
|
||||
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q UP; then
|
||||
echo "Backend ready, starting Playwright"
|
||||
break
|
||||
fi
|
||||
# Bail early if the backend already died: no point waiting 10min.
|
||||
if [ -f "{{.BASE_DIR}}/backend.pid" ]; then
|
||||
BACKEND_PID=$(cat "{{.BASE_DIR}}/backend.pid")
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo "Backend process exited before becoming ready; last 100 log lines:"
|
||||
tail -100 "{{.BASE_DIR}}/backend.log" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
done
|
||||
if [ $WAITED -ge 600 ]; then
|
||||
echo "Backend did not become ready within 10min, aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npx playwright test --project=live "$@"
|
||||
' bash {{.CLI_ARGS}}
|
||||
|
||||
enterprise:
|
||||
desc: "Run enterprise E2E tests"
|
||||
summary: |
|
||||
Requires an already-running keycloak compose stack on :8080. Bring one
|
||||
up first with:
|
||||
task e2e:oauth:up
|
||||
task e2e:saml:up
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=enterprise {{.CLI_ARGS}}
|
||||
|
||||
cross-browser:
|
||||
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
|
||||
dir: frontend
|
||||
deps: [ ':frontend:prepare' ]
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
|
||||
|
||||
check:
|
||||
desc: "Run all E2E tests not requiring Docker"
|
||||
cmds:
|
||||
- task: stubbed
|
||||
- task: live
|
||||
|
||||
check:all:
|
||||
desc: "Run all E2E tests"
|
||||
summary: |
|
||||
Includes the enterprise project, which requires a keycloak compose
|
||||
stack on :8080. Bring one up first with:
|
||||
task e2e:oauth:up
|
||||
task e2e:saml:up
|
||||
cmds:
|
||||
- task: stubbed
|
||||
- task: live
|
||||
- task: enterprise
|
||||
|
||||
oauth:up:
|
||||
desc: "Start the OAuth keycloak test environment"
|
||||
summary: |
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:oauth:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:oauth:up -- --auto --nobuild
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-oauth-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
oauth:down:
|
||||
desc: "Stop the OAuth keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-oauth.yml down -v
|
||||
|
||||
saml:up:
|
||||
desc: "Start the SAML keycloak test environment"
|
||||
summary: |
|
||||
Set LICENSE_KEY=<KEY> to skip the interactive license prompt:
|
||||
task e2e:saml:up LICENSE_KEY=abc123
|
||||
Pass extra flags via -- :
|
||||
task e2e:saml:up -- --auto --with-storage --nobuild --language sv-SE
|
||||
ignore_error: true
|
||||
cmds:
|
||||
- bash testing/compose/start-saml-test.sh {{if .LICENSE_KEY}}--license-key "{{.LICENSE_KEY}}"{{end}} {{.CLI_ARGS}}
|
||||
|
||||
saml:down:
|
||||
desc: "Stop the SAML keycloak test environment"
|
||||
cmds:
|
||||
- docker compose -f testing/compose/docker-compose-keycloak-saml.yml down -v
|
||||
@@ -86,8 +86,9 @@ tasks:
|
||||
fix:
|
||||
desc: "Auto-fix lint + format"
|
||||
cmds:
|
||||
- task: format # Can auto-fix some things that `lint:fix` can't like line length violations
|
||||
- task: lint:fix
|
||||
- task: format
|
||||
- task: format # Ensure that after lint fixing that the code is still formatted correctly
|
||||
|
||||
check:
|
||||
desc: "Full engine quality gate"
|
||||
|
||||
+1
-12
@@ -239,6 +239,7 @@ tasks:
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -283,18 +284,6 @@ tasks:
|
||||
cmds:
|
||||
- npx vitest --coverage
|
||||
|
||||
test:e2e:
|
||||
desc: "Run E2E tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx playwright test {{.CLI_ARGS}}
|
||||
|
||||
test:e2e:install:
|
||||
desc: "Install E2E test browsers"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx playwright install {{.CLI_ARGS}} --with-deps
|
||||
|
||||
# ============================================================
|
||||
# Code Generation
|
||||
# ============================================================
|
||||
|
||||
@@ -183,26 +183,26 @@ Use this pattern for desktop-specific or proprietary-specific features WITHOUT r
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub)
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
interface WorkbenchBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
@@ -212,15 +212,15 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds)
|
||||
import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
export function WorkbenchBar() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
<WorkbenchBarFooterExtensions className="workbench-bar-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -431,7 +431,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
|
||||
- **Java Version**: Requires JDK 25.
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 21+, JDK 25 recommended)
|
||||
- Spring Boot (requires JDK 25)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
@@ -45,7 +45,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
- [Task](https://taskfile.dev/installation/) — unified command runner (recommended)
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Java JDK 25
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development)
|
||||
@@ -61,7 +61,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
2. Install Docker and JDK 25 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
|
||||
@@ -6,6 +6,8 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "app/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
|
||||
+6
-3
@@ -3,7 +3,7 @@ version: '3'
|
||||
output: prefixed
|
||||
|
||||
vars:
|
||||
FIND_FREE_PORT_PS: powershell -NoProfile -File scripts\find-free-port.ps1 -Preferred
|
||||
FIND_FREE_PORT_PS: powershell -NoProfile -File scripts/find-free-port.ps1
|
||||
FIND_FREE_PORT_SH: bash scripts/find-free-port.sh
|
||||
|
||||
includes:
|
||||
@@ -22,6 +22,9 @@ includes:
|
||||
desktop:
|
||||
taskfile: .taskfiles/desktop.yml
|
||||
dir: frontend
|
||||
e2e:
|
||||
taskfile: .taskfiles/e2e.yml
|
||||
dir: .
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
@@ -42,7 +45,7 @@ tasks:
|
||||
desc: "Start backend + frontend concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
@@ -59,7 +62,7 @@ tasks:
|
||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173,5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
ENGINE_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
|
||||
+30
-1
@@ -45,10 +45,39 @@ dependencies {
|
||||
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
// Tabula table extraction — used by the shared PDF parser and directly by downstream modules.
|
||||
// api-scoped so downstream modules (core, proprietary) retain it on their compile classpath.
|
||||
api ('technology.tabula:tabula:1.0.5') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
|
||||
api 'com.stirling:jpdfium:1.0.0'
|
||||
|
||||
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
|
||||
? jpdfiumAllPlatforms
|
||||
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0"
|
||||
}
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
}
|
||||
|
||||
@@ -68,6 +68,36 @@ public class EndpointConfiguration {
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a full request URI like {@code /api/v1/general/remove-pages} into the endpoint key
|
||||
* used by this configuration ({@code remove-pages}). Convert endpoints are a special case -
|
||||
* {@code /api/v1/convert/pdf/img} is registered as {@code pdf-to-img}. Returns {@code null} if
|
||||
* the URI is not an {@code /api/v1/<group>/<endpoint>} path.
|
||||
*/
|
||||
public static String endpointKeyForUri(String uri) {
|
||||
if (uri == null || !uri.contains("/api/v1")) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = uri.split("/");
|
||||
if (parts.length <= 4) {
|
||||
return null;
|
||||
}
|
||||
if ("convert".equals(parts[3]) && parts.length > 5) {
|
||||
return parts[4] + "-to-" + parts[5];
|
||||
}
|
||||
return parts[4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link #isEndpointEnabled(String)} that accepts a full request URI
|
||||
* and translates it to the endpoint key. Falls back to treating the URI itself as a key for
|
||||
* non-{@code /api/v1/...} paths so callers can pass arbitrary URIs.
|
||||
*/
|
||||
public boolean isEndpointEnabledForUri(String uri) {
|
||||
String key = endpointKeyForUri(uri);
|
||||
return isEndpointEnabled(key != null ? key : uri);
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Chains table parsers in priority order: Tabula lattice → Tabula stream → {@link
|
||||
* LineAlignmentTableParser}. The first parser returning a result above {@link
|
||||
* #TABULA_CONFIDENCE_THRESHOLD} wins; results from different parsers are never mixed on one page.
|
||||
*/
|
||||
@Service
|
||||
@Primary
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CompositeTableParser implements TableParser {
|
||||
|
||||
/** Min Tabula confidence to accept results; below this LineAlignment is tried instead. */
|
||||
static final float TABULA_CONFIDENCE_THRESHOLD = 0.5f;
|
||||
|
||||
private final TabulaTableParser tabulaParser;
|
||||
private final LineAlignmentTableParser lineAlignmentParser;
|
||||
|
||||
@Override
|
||||
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
|
||||
// Step 1: Tabula lattice mode (ruled/bordered tables).
|
||||
List<TableFragment> latticeResults = filterConfident(tabulaParser.parse(document, rawPage));
|
||||
if (!latticeResults.isEmpty()) {
|
||||
log.debug(
|
||||
"Page {}: using Tabula lattice ({} table(s))",
|
||||
rawPage.pageNumber(),
|
||||
latticeResults.size());
|
||||
return latticeResults;
|
||||
}
|
||||
|
||||
// Step 2: Tabula stream mode (borderless/whitespace-delimited tables).
|
||||
// parseStream is not on the TableParser interface — this intentionally couples to the
|
||||
// concrete TabulaTableParser since stream mode is a Tabula-specific concept.
|
||||
List<TableFragment> streamResults =
|
||||
filterConfident(tabulaParser.parseStream(document, rawPage));
|
||||
if (!streamResults.isEmpty()) {
|
||||
log.debug(
|
||||
"Page {}: using Tabula stream ({} table(s))",
|
||||
rawPage.pageNumber(),
|
||||
streamResults.size());
|
||||
return streamResults;
|
||||
}
|
||||
|
||||
// Step 3: Geometry-based line-alignment fallback.
|
||||
List<TableFragment> lineResults = lineAlignmentParser.parse(document, rawPage);
|
||||
if (!lineResults.isEmpty()) {
|
||||
log.debug(
|
||||
"Page {}: using LineAlignment ({} table(s))",
|
||||
rawPage.pageNumber(),
|
||||
lineResults.size());
|
||||
return lineResults;
|
||||
}
|
||||
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<TableFragment> filterConfident(List<TableFragment> tables) {
|
||||
return tables.stream().filter(t -> t.confidence() >= TABULA_CONFIDENCE_THRESHOLD).toList();
|
||||
}
|
||||
}
|
||||
+528
@@ -0,0 +1,528 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Fallback {@link TableParser} for borderless financial tables using text geometry.
|
||||
*
|
||||
* <p>Identifies "anchor lines" (≥2 numeric tokens), builds a column grid from their right-edge
|
||||
* positions, groups vertically proximate anchor lines into table candidates, then scores each group
|
||||
* on column consistency and anchor density (confidence ceiling 0.85).
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LineAlignmentTableParser implements TableParser {
|
||||
|
||||
/** Width in points of each column position bucket. */
|
||||
static final float COLUMN_BUCKET_PT = 5f;
|
||||
|
||||
/** Tolerance in buckets when matching a token's right-edge to a confirmed column position. */
|
||||
private static final int COLUMN_MATCH_BUCKETS = 2;
|
||||
|
||||
/** Maximum gap (as a multiple of modal line spacing) before splitting a group. */
|
||||
private static final float MAX_GAP_FACTOR = 2.5f;
|
||||
|
||||
/** Minimum anchor rows (numeric-heavy) to form a valid table. */
|
||||
static final int MIN_TABLE_ROWS = 3;
|
||||
|
||||
/** Minimum confirmed column positions to form a valid table. */
|
||||
static final int MIN_COLUMNS = 2;
|
||||
|
||||
/**
|
||||
* Min fraction of anchor lines a column must appear on to be confirmed (permissive for N/A
|
||||
* rows).
|
||||
*/
|
||||
private static final double COLUMN_MIN_FREQUENCY = 0.40;
|
||||
|
||||
/**
|
||||
* Matches financial numeric tokens: integers, decimals, parenthetical negatives, currency,
|
||||
* percent, nil dashes.
|
||||
*/
|
||||
private static final Pattern NUMERIC =
|
||||
Pattern.compile("^[\\(\\-\\$£€¥]?\\d[\\d,\\.]*[\\)%]?$|^[-–—]$");
|
||||
|
||||
/**
|
||||
* Lines within this y-distance are merged into one row (restores rows split by LineBuilder's
|
||||
* column-gap logic).
|
||||
*/
|
||||
static final float ROW_MERGE_TOLERANCE_PT = 2f;
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
|
||||
List<RawLine> lines = rawPage.lines();
|
||||
if (lines.size() < MIN_TABLE_ROWS) return List.of();
|
||||
|
||||
float modalSpacing = computeModalSpacing(lines);
|
||||
List<TokenizedLine> tokenized =
|
||||
mergeCoincidentLines(lines.stream().map(this::tokenize).toList());
|
||||
|
||||
List<TokenizedLine> anchors = tokenized.stream().filter(TokenizedLine::isAnchor).toList();
|
||||
|
||||
if (anchors.size() < MIN_TABLE_ROWS) return List.of();
|
||||
|
||||
List<Float> columnGrid = buildColumnGrid(anchors);
|
||||
if (columnGrid.size() < MIN_COLUMNS) {
|
||||
log.debug(
|
||||
"Page {}: LineAlignment — fewer than {} confirmed columns, skipping",
|
||||
rawPage.pageNumber(),
|
||||
MIN_COLUMNS);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<List<TokenizedLine>> groups = groupRows(tokenized, columnGrid, modalSpacing);
|
||||
|
||||
List<TableFragment> results = new ArrayList<>();
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
buildFragment(groups.get(i), columnGrid, rawPage.pageNumber(), i)
|
||||
.ifPresent(results::add);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Page {}: LineAlignment detected {} table(s) ({} anchor lines, {} columns)",
|
||||
rawPage.pageNumber(),
|
||||
results.size(),
|
||||
anchors.size(),
|
||||
columnGrid.size());
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── coincident-line merging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merges tokenised lines sharing the same y-position into one row, rejoining label/value halves
|
||||
* split by LineBuilder.
|
||||
*/
|
||||
List<TokenizedLine> mergeCoincidentLines(List<TokenizedLine> tokenized) {
|
||||
if (tokenized.size() < 2) return tokenized;
|
||||
|
||||
List<TokenizedLine> result = new ArrayList<>();
|
||||
int i = 0;
|
||||
|
||||
while (i < tokenized.size()) {
|
||||
float baseY = tokenized.get(i).line().bounds().y();
|
||||
int j = i + 1;
|
||||
while (j < tokenized.size()
|
||||
&& Math.abs(tokenized.get(j).line().bounds().y() - baseY)
|
||||
<= ROW_MERGE_TOLERANCE_PT) {
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j == i + 1) {
|
||||
result.add(tokenized.get(i));
|
||||
} else {
|
||||
result.add(mergeGroup(tokenized.subList(i, j)));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private TokenizedLine mergeGroup(List<TokenizedLine> group) {
|
||||
List<TextFragment> mergedFragments =
|
||||
group.stream()
|
||||
.flatMap(tl -> tl.line().fragments().stream())
|
||||
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
|
||||
.toList();
|
||||
|
||||
Bounds mergedBounds =
|
||||
group.stream()
|
||||
.map(tl -> tl.line().bounds())
|
||||
.reduce(Bounds::merge)
|
||||
.orElse(group.get(0).line().bounds());
|
||||
|
||||
RawLine mergedLine =
|
||||
new RawLine(
|
||||
group.get(0).line().lineId(),
|
||||
mergedFragments,
|
||||
mergedBounds,
|
||||
group.get(0).line().pageNumber());
|
||||
|
||||
return tokenize(mergedLine);
|
||||
}
|
||||
|
||||
// ── tokenisation ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits fragments into word-level tokens; x-positions are estimated linearly within each
|
||||
* fragment.
|
||||
*/
|
||||
TokenizedLine tokenize(RawLine line) {
|
||||
List<LineToken> tokens = new ArrayList<>();
|
||||
for (TextFragment frag : line.fragments()) {
|
||||
tokens.addAll(tokensFromFragment(frag));
|
||||
}
|
||||
List<LineToken> numeric = tokens.stream().filter(LineToken::numeric).toList();
|
||||
return new TokenizedLine(line, tokens, numeric);
|
||||
}
|
||||
|
||||
private List<LineToken> tokensFromFragment(TextFragment frag) {
|
||||
String raw = frag.text();
|
||||
if (raw == null || raw.isBlank()) return List.of();
|
||||
|
||||
float fragX = frag.bounds().x();
|
||||
float fragWidth = frag.bounds().width();
|
||||
int rawLen = raw.length();
|
||||
|
||||
List<LineToken> result = new ArrayList<>();
|
||||
int offset = 0;
|
||||
for (String part : raw.split("\\s+")) {
|
||||
if (part.isEmpty()) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
int idx = raw.indexOf(part, offset);
|
||||
if (idx < 0) idx = offset;
|
||||
|
||||
float tokenX = rawLen > 0 ? fragX + ((float) idx / rawLen) * fragWidth : fragX;
|
||||
float tokenRight =
|
||||
rawLen > 0
|
||||
? fragX + ((float) (idx + part.length()) / rawLen) * fragWidth
|
||||
: fragX + fragWidth;
|
||||
|
||||
result.add(new LineToken(part, tokenX, tokenRight, NUMERIC.matcher(part).matches()));
|
||||
offset = idx + part.length();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── column grid ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns confirmed column right-edge positions — those appearing on ≥ {@value
|
||||
* #COLUMN_MIN_FREQUENCY} × N anchor lines.
|
||||
*/
|
||||
private List<Float> buildColumnGrid(List<TokenizedLine> anchors) {
|
||||
// bucket → set of line indices that contributed a numeric token to that bucket
|
||||
Map<Integer, List<Integer>> bucketLines = new HashMap<>();
|
||||
for (int i = 0; i < anchors.size(); i++) {
|
||||
for (LineToken t : anchors.get(i).numeric()) {
|
||||
int bucket = bucket(t.right());
|
||||
bucketLines.computeIfAbsent(bucket, k -> new ArrayList<>()).add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int minHits =
|
||||
Math.max(MIN_TABLE_ROWS, (int) Math.ceil(anchors.size() * COLUMN_MIN_FREQUENCY));
|
||||
|
||||
// Confirmed buckets → average right-edge for that bucket
|
||||
TreeMap<Integer, Float> confirmed = new TreeMap<>();
|
||||
for (Map.Entry<Integer, List<Integer>> entry : bucketLines.entrySet()) {
|
||||
// Count distinct lines
|
||||
long distinctLines = entry.getValue().stream().distinct().count();
|
||||
if (distinctLines >= minHits) {
|
||||
double avg =
|
||||
entry.getValue().stream()
|
||||
.distinct() // weight each line equally regardless of token count
|
||||
.mapToDouble(
|
||||
lineIdx ->
|
||||
avgRightEdgeForBucket(
|
||||
anchors, lineIdx, entry.getKey()))
|
||||
.average()
|
||||
.orElse(entry.getKey() * (double) COLUMN_BUCKET_PT);
|
||||
confirmed.put(entry.getKey(), (float) avg);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(confirmed.values()); // already sorted by bucket (left to right)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the average right-edge position of tokens in {@code line} whose bucket matches {@code
|
||||
* targetBucket}, falling back to the bucket's nominal centre when no tokens match.
|
||||
*/
|
||||
private double avgRightEdgeForBucket(
|
||||
List<TokenizedLine> anchors, int lineIdx, int targetBucket) {
|
||||
return anchors.get(lineIdx).numeric().stream()
|
||||
.filter(t -> bucket(t.right()) == targetBucket)
|
||||
.mapToDouble(LineToken::right)
|
||||
.average()
|
||||
.orElse(targetBucket * (double) COLUMN_BUCKET_PT);
|
||||
}
|
||||
|
||||
// ── grouping ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Groups anchor lines into table candidates, including adjacent label rows; a gap >
|
||||
* MAX_GAP_FACTOR × modal spacing splits groups.
|
||||
*/
|
||||
private List<List<TokenizedLine>> groupRows(
|
||||
List<TokenizedLine> all, List<Float> columnGrid, float modalSpacing) {
|
||||
float maxGap = modalSpacing > 0 ? modalSpacing * MAX_GAP_FACTOR : 30f;
|
||||
|
||||
List<List<TokenizedLine>> groups = new ArrayList<>();
|
||||
List<TokenizedLine> current = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < all.size(); i++) {
|
||||
TokenizedLine tl = all.get(i);
|
||||
boolean fits = tl.isAnchor() && matchesGrid(tl, columnGrid);
|
||||
|
||||
if (current.isEmpty()) {
|
||||
if (fits) current.add(tl);
|
||||
continue;
|
||||
}
|
||||
|
||||
float gap =
|
||||
tl.line().bounds().y()
|
||||
- current.get(current.size() - 1).line().bounds().bottom();
|
||||
|
||||
if (gap > maxGap) {
|
||||
groups.add(current);
|
||||
current = new ArrayList<>();
|
||||
if (fits) current.add(tl);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fits) {
|
||||
current.add(tl);
|
||||
} else if (!tl.line().text().isBlank()) {
|
||||
// Include non-anchor lines (labels) only if they have text and are within
|
||||
// proximity.
|
||||
current.add(tl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!current.isEmpty()) groups.add(current);
|
||||
|
||||
return groups.stream().filter(g -> hasEnoughAnchorRows(g, columnGrid)).toList();
|
||||
}
|
||||
|
||||
private boolean hasEnoughAnchorRows(List<TokenizedLine> group, List<Float> columnGrid) {
|
||||
return group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count()
|
||||
>= MIN_TABLE_ROWS;
|
||||
}
|
||||
|
||||
/** A line "matches" the grid when ≥ 60 % of its numeric tokens land in confirmed columns. */
|
||||
private boolean matchesGrid(TokenizedLine tl, List<Float> columnGrid) {
|
||||
if (tl.numeric().isEmpty()) return false;
|
||||
long matches =
|
||||
tl.numeric().stream()
|
||||
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
|
||||
.count();
|
||||
return (double) matches / tl.numeric().size() >= 0.60;
|
||||
}
|
||||
|
||||
private boolean hasInconsistentColumnMatch(TokenizedLine tl, List<Float> columnGrid) {
|
||||
if (tl.numeric().isEmpty()) return false;
|
||||
long hits =
|
||||
tl.numeric().stream()
|
||||
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
|
||||
.count();
|
||||
return (double) hits / tl.numeric().size() < 0.60;
|
||||
}
|
||||
|
||||
// ── fragment assembly ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private Optional<TableFragment> buildFragment(
|
||||
List<TokenizedLine> group, List<Float> columnGrid, int pageNumber, int tableIndex) {
|
||||
|
||||
long anchorCount =
|
||||
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
|
||||
if (anchorCount < MIN_TABLE_ROWS) return Optional.empty();
|
||||
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<List<String>> rawRows = new ArrayList<>();
|
||||
List<TableRow> rows = new ArrayList<>();
|
||||
|
||||
for (int rowIdx = 0; rowIdx < group.size(); rowIdx++) {
|
||||
TokenizedLine tl = group.get(rowIdx);
|
||||
List<String> rawRow = buildRawRow(tl, columnGrid);
|
||||
rawRows.add(Collections.unmodifiableList(rawRow));
|
||||
rows.add(buildTableRow(rowIdx, tl, rawRow, columnGrid));
|
||||
}
|
||||
|
||||
// Column count = 1 label column + confirmed numeric columns
|
||||
int colCount = columnGrid.size() + 1;
|
||||
Bounds bounds = computeGroupBounds(group);
|
||||
float confidence = computeConfidence(group, columnGrid, warnings);
|
||||
|
||||
return Optional.of(
|
||||
new TableFragment(
|
||||
"tbl-la-p" + pageNumber + "-" + tableIndex,
|
||||
pageNumber,
|
||||
bounds,
|
||||
List.of(),
|
||||
Collections.unmodifiableList(rows),
|
||||
Collections.unmodifiableList(rawRows),
|
||||
colCount,
|
||||
confidence,
|
||||
Collections.unmodifiableList(warnings),
|
||||
null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a raw row as a list of strings: index 0 = label text, indices 1..N = column values.
|
||||
*/
|
||||
private List<String> buildRawRow(TokenizedLine tl, List<Float> columnGrid) {
|
||||
String[] cells = new String[columnGrid.size() + 1];
|
||||
Arrays.fill(cells, "");
|
||||
|
||||
// Separate label tokens (those not landing in any confirmed column) from column tokens.
|
||||
List<String> labelParts = new ArrayList<>();
|
||||
for (LineToken token : tl.all()) {
|
||||
int col = nearestColumnIndex(token.right(), columnGrid);
|
||||
if (col >= 0 && token.numeric()) {
|
||||
int cellIdx = col + 1;
|
||||
cells[cellIdx] =
|
||||
cells[cellIdx].isEmpty()
|
||||
? token.text()
|
||||
: cells[cellIdx] + " " + token.text();
|
||||
} else {
|
||||
labelParts.add(token.text());
|
||||
}
|
||||
}
|
||||
cells[0] = String.join(" ", labelParts).trim();
|
||||
return Arrays.asList(cells);
|
||||
}
|
||||
|
||||
private TableRow buildTableRow(
|
||||
int rowIdx, TokenizedLine tl, List<String> rawRow, List<Float> columnGrid) {
|
||||
List<TableCell> cells = new ArrayList<>(rawRow.size());
|
||||
|
||||
// Label cell: use the line's full bounds as an approximation.
|
||||
cells.add(TableCell.of(0, rawRow.get(0), tl.line().bounds()));
|
||||
|
||||
for (int col = 0; col < columnGrid.size(); col++) {
|
||||
String text = col + 1 < rawRow.size() ? rawRow.get(col + 1) : "";
|
||||
float right = columnGrid.get(col);
|
||||
float left = col > 0 ? columnGrid.get(col - 1) : right - 50f;
|
||||
Bounds cellBounds =
|
||||
new Bounds(
|
||||
left,
|
||||
tl.line().bounds().y(),
|
||||
right - left,
|
||||
tl.line().bounds().height());
|
||||
cells.add(TableCell.of(col + 1, text, cellBounds));
|
||||
}
|
||||
return new TableRow(rowIdx, Collections.unmodifiableList(cells));
|
||||
}
|
||||
|
||||
// ── confidence scoring ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Heuristic score in [0.0, 0.85] (ceiling keeps results below Tabula lattice which starts at
|
||||
* 1.0). Base 0.70; +0.05/col beyond 2 (max +0.10); +0.05 at ≥5 anchors, +0.05 at ≥8; −0.15 if
|
||||
* >30 % of anchors have inconsistent columns; −0.10 if non-anchors outnumber anchors.
|
||||
*/
|
||||
private float computeConfidence(
|
||||
List<TokenizedLine> group, List<Float> columnGrid, List<String> warnings) {
|
||||
float score = 0.70f;
|
||||
|
||||
long anchorCount =
|
||||
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
|
||||
long totalRows = group.size();
|
||||
|
||||
// More columns
|
||||
int extraCols = Math.min(columnGrid.size() - MIN_COLUMNS, 2);
|
||||
score += extraCols * 0.05f;
|
||||
|
||||
// More anchor rows
|
||||
if (anchorCount >= 5) score += 0.05f;
|
||||
if (anchorCount >= 8) score += 0.05f;
|
||||
|
||||
// Inconsistent column matching
|
||||
long inconsistent =
|
||||
group.stream()
|
||||
.filter(TokenizedLine::isAnchor)
|
||||
.filter(tl -> hasInconsistentColumnMatch(tl, columnGrid))
|
||||
.count();
|
||||
if (inconsistent > anchorCount * 0.30) {
|
||||
score -= 0.15f;
|
||||
warnings.add(
|
||||
"Column match inconsistent on "
|
||||
+ inconsistent
|
||||
+ "/"
|
||||
+ anchorCount
|
||||
+ " anchor rows");
|
||||
}
|
||||
|
||||
// Label-heavy
|
||||
long nonAnchor = totalRows - anchorCount;
|
||||
if (nonAnchor > anchorCount) {
|
||||
score -= 0.10f;
|
||||
warnings.add(
|
||||
"Non-anchor rows ("
|
||||
+ nonAnchor
|
||||
+ ") outnumber anchor rows ("
|
||||
+ anchorCount
|
||||
+ ")");
|
||||
}
|
||||
|
||||
return Math.max(0f, Math.min(0.85f, score));
|
||||
}
|
||||
|
||||
// ── utility ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the grid index nearest to {@code rightEdge}, or -1 if none is within {@value
|
||||
* #COLUMN_MATCH_BUCKETS} buckets.
|
||||
*/
|
||||
private int nearestColumnIndex(float rightEdge, List<Float> grid) {
|
||||
int nearest = -1;
|
||||
float minDist = COLUMN_MATCH_BUCKETS * COLUMN_BUCKET_PT + 1f;
|
||||
for (int i = 0; i < grid.size(); i++) {
|
||||
float dist = Math.abs(rightEdge - grid.get(i));
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
nearest = i;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private Bounds computeGroupBounds(List<TokenizedLine> group) {
|
||||
return group.stream()
|
||||
.map(tl -> tl.line().bounds())
|
||||
.reduce(Bounds::merge)
|
||||
.orElse(new Bounds(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
/** Modal gap between consecutive line edges, used to calibrate the group-split threshold. */
|
||||
private float computeModalSpacing(List<RawLine> lines) {
|
||||
if (lines.size() < 2) return 0f;
|
||||
Map<Float, Long> freq = new HashMap<>();
|
||||
for (int i = 1; i < lines.size(); i++) {
|
||||
float gap = lines.get(i).bounds().y() - lines.get(i - 1).bounds().bottom();
|
||||
if (gap > 0) freq.merge(Math.round(gap / 2f) * 2f, 1L, Long::sum);
|
||||
}
|
||||
return freq.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse(0f);
|
||||
}
|
||||
|
||||
private static int bucket(float x) {
|
||||
return Math.round(x / COLUMN_BUCKET_PT);
|
||||
}
|
||||
|
||||
// ── private data types ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A word-level token with an approximate right-edge x-position. */
|
||||
record LineToken(String text, float x, float right, boolean numeric) {}
|
||||
|
||||
/** A {@link RawLine} with tokens pre-computed; an "anchor" has ≥ 2 numeric tokens. */
|
||||
record TokenizedLine(RawLine line, List<LineToken> all, List<LineToken> numeric) {
|
||||
boolean isAnchor() {
|
||||
return numeric.size() >= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Groups {@link TextFragment} objects into visual {@link RawLine}s using baseline proximity.
|
||||
*
|
||||
* <p>Fragments are on the same line when their baselines are within a font-size-derived tolerance.
|
||||
* A new line starts whenever the horizontal gap exceeds an adaptive column-gap threshold ({@code
|
||||
* max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT)}), splitting two-column text.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LineBuilder {
|
||||
|
||||
/** Baseline tolerance as a fraction of font size; 0.5 keeps mixed-size text on one line. */
|
||||
private static final float BASELINE_TOLERANCE_FACTOR = 0.5f;
|
||||
|
||||
/** Absolute minimum tolerance so tiny font sizes don't collapse multi-line content. */
|
||||
private static final float MIN_BASELINE_TOLERANCE = 2f;
|
||||
|
||||
/**
|
||||
* Column-gap threshold as a fraction of page width; 0.10 clears tab stops but stays below
|
||||
* two-column gutters.
|
||||
*/
|
||||
static final float COLUMN_GAP_RATIO = 0.10f;
|
||||
|
||||
/** Floor for the column-gap threshold so narrow pages don't over-split lines. */
|
||||
static final float COLUMN_GAP_MIN_PT = 40f;
|
||||
|
||||
public List<RawLine> build(List<TextFragment> fragments, int pageNumber) {
|
||||
if (fragments.isEmpty()) return List.of();
|
||||
|
||||
float effectiveWidth = inferEffectiveWidth(fragments);
|
||||
float columnGapThreshold = Math.max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT);
|
||||
log.debug(
|
||||
"LineBuilder page {}: effectiveWidth={:.1f}pt, columnGapThreshold={:.1f}pt",
|
||||
pageNumber,
|
||||
effectiveWidth,
|
||||
columnGapThreshold);
|
||||
|
||||
// Sort top-to-bottom first, then left-to-right within the same baseline band.
|
||||
List<TextFragment> sorted =
|
||||
fragments.stream()
|
||||
.sorted(
|
||||
Comparator.comparingDouble(TextFragment::baseline)
|
||||
.thenComparingDouble(f -> f.bounds().x()))
|
||||
.toList();
|
||||
|
||||
List<List<TextFragment>> groups = groupByBaseline(sorted, columnGapThreshold);
|
||||
|
||||
List<RawLine> lines = new ArrayList<>(groups.size());
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
List<TextFragment> group =
|
||||
groups.get(i).stream()
|
||||
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
|
||||
.toList();
|
||||
|
||||
Bounds lineBounds =
|
||||
group.stream()
|
||||
.map(TextFragment::bounds)
|
||||
.reduce(Bounds::merge)
|
||||
.orElse(new Bounds(0, 0, 0, 0));
|
||||
|
||||
lines.add(new RawLine("ln-p" + pageNumber + "-" + i, group, lineBounds, pageNumber));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private List<List<TextFragment>> groupByBaseline(
|
||||
List<TextFragment> sorted, float columnGapThreshold) {
|
||||
List<List<TextFragment>> groups = new ArrayList<>();
|
||||
List<TextFragment> current = new ArrayList<>();
|
||||
float currentBaseline = Float.NaN;
|
||||
|
||||
for (TextFragment fragment : sorted) {
|
||||
if (current.isEmpty()) {
|
||||
current.add(fragment);
|
||||
currentBaseline = fragment.baseline();
|
||||
continue;
|
||||
}
|
||||
|
||||
float maxFontSize =
|
||||
Math.max(
|
||||
fragment.fontSize(),
|
||||
(float)
|
||||
current.stream()
|
||||
.mapToDouble(TextFragment::fontSize)
|
||||
.max()
|
||||
.orElse(0));
|
||||
float tolerance =
|
||||
Math.max(maxFontSize * BASELINE_TOLERANCE_FACTOR, MIN_BASELINE_TOLERANCE);
|
||||
|
||||
boolean sameBaseline = Math.abs(fragment.baseline() - currentBaseline) <= tolerance;
|
||||
boolean columnGap = sameBaseline && hasColumnGap(fragment, current, columnGapThreshold);
|
||||
|
||||
if (sameBaseline && !columnGap) {
|
||||
current.add(fragment);
|
||||
// Anchor to the weighted mean baseline so long lines stay stable.
|
||||
currentBaseline =
|
||||
(currentBaseline * (current.size() - 1) + fragment.baseline())
|
||||
/ current.size();
|
||||
} else {
|
||||
groups.add(current);
|
||||
current = new ArrayList<>();
|
||||
current.add(fragment);
|
||||
currentBaseline = fragment.baseline();
|
||||
}
|
||||
}
|
||||
|
||||
if (!current.isEmpty()) groups.add(current);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the gap from the rightmost fragment in {@code group} to {@code next} exceeds {@code
|
||||
* threshold}.
|
||||
*/
|
||||
private static boolean hasColumnGap(
|
||||
TextFragment next, List<TextFragment> group, float threshold) {
|
||||
float lastRight = group.get(group.size() - 1).bounds().right();
|
||||
return next.bounds().x() - lastRight > threshold;
|
||||
}
|
||||
|
||||
/** Infers effective page width from the rightmost fragment right-edge plus a 10 % margin. */
|
||||
private static float inferEffectiveWidth(List<TextFragment> fragments) {
|
||||
double maxRight =
|
||||
fragments.stream().mapToDouble(f -> f.bounds().right()).max().orElse(500.0);
|
||||
return (float) maxRight * 1.10f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Runs the per-page ingestion pipeline: {@link WordExtractingStripper} → {@link LineBuilder} →
|
||||
* {@link TableParser}, producing a {@link PdfModels.ParsedPage} per page. The caller owns the
|
||||
* {@link PDDocument} lifecycle.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PdfIngester {
|
||||
|
||||
private final LineBuilder lineBuilder;
|
||||
private final TableParser tableParser;
|
||||
|
||||
public List<ParsedPage> parse(PDDocument document) throws IOException {
|
||||
return parse(document, document.getNumberOfPages());
|
||||
}
|
||||
|
||||
public List<ParsedPage> parse(PDDocument document, int maxPages) throws IOException {
|
||||
int pageCount = Math.min(document.getNumberOfPages(), maxPages);
|
||||
List<ParsedPage> pages = new ArrayList<>(pageCount);
|
||||
long fragmentsMs = 0;
|
||||
long tablesMs = 0;
|
||||
long t0 = System.currentTimeMillis();
|
||||
|
||||
for (int p = 1; p <= pageCount; p++) {
|
||||
long ft = System.currentTimeMillis();
|
||||
List<TextFragment> fragments = extractFragments(document, p);
|
||||
fragmentsMs += System.currentTimeMillis() - ft;
|
||||
|
||||
PDPage page = document.getPage(p - 1);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
List<RawLine> lines = lineBuilder.build(fragments, p);
|
||||
RawPage rawPage = new RawPage(p, mediaBox.getWidth(), mediaBox.getHeight(), lines);
|
||||
|
||||
long tt = System.currentTimeMillis();
|
||||
List<TableFragment> tables = tableParser.parse(document, rawPage);
|
||||
tablesMs += System.currentTimeMillis() - tt;
|
||||
|
||||
log.debug(
|
||||
"Page {}: {} fragments → {} lines, {} table(s)",
|
||||
p,
|
||||
fragments.size(),
|
||||
lines.size(),
|
||||
tables.size());
|
||||
pages.add(new ParsedPage(p, mediaBox.getWidth(), mediaBox.getHeight(), tables, lines));
|
||||
}
|
||||
|
||||
log.info(
|
||||
"[timing] parse pages={} total={}ms fragments={}ms tables={}ms",
|
||||
pageCount,
|
||||
System.currentTimeMillis() - t0,
|
||||
fragmentsMs,
|
||||
tablesMs);
|
||||
return pages;
|
||||
}
|
||||
|
||||
private List<TextFragment> extractFragments(PDDocument document, int pageNumber)
|
||||
throws IOException {
|
||||
WordExtractingStripper stripper = new WordExtractingStripper(pageNumber);
|
||||
stripper.getText(document);
|
||||
return stripper.getFragments();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
|
||||
/**
|
||||
* All PDF parser model types and the table-parser contract in one place.
|
||||
*
|
||||
* <p>Import as {@code import static stirling.software.SPDF.pdf.parser.PdfModels.*;} to use all
|
||||
* nested types without qualification.
|
||||
*/
|
||||
public final class PdfModels {
|
||||
|
||||
private PdfModels() {}
|
||||
|
||||
// ── Geometry ──────────────────────────────────────────────────────────────
|
||||
|
||||
public record Bounds(float x, float y, float width, float height) {
|
||||
|
||||
public float right() {
|
||||
return x + width;
|
||||
}
|
||||
|
||||
public float bottom() {
|
||||
return y + height;
|
||||
}
|
||||
|
||||
public static Bounds merge(Bounds a, Bounds b) {
|
||||
float x = Math.min(a.x, b.x);
|
||||
float y = Math.min(a.y, b.y);
|
||||
return new Bounds(
|
||||
x, y, Math.max(a.right(), b.right()) - x, Math.max(a.bottom(), b.bottom()) - y);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text fragments and lines ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A contiguous run of text from a single PDF content-stream string operation. {@code baseline}
|
||||
* is preserved separately from bounds for line-grouping — characters of different sizes on the
|
||||
* same visual line share a baseline but differ in top Y.
|
||||
*/
|
||||
public record TextFragment(
|
||||
String fragmentId,
|
||||
String text,
|
||||
Bounds bounds,
|
||||
float baseline,
|
||||
float fontSize,
|
||||
String fontName,
|
||||
boolean bold) {}
|
||||
|
||||
public record RawLine(
|
||||
String lineId, List<TextFragment> fragments, Bounds bounds, int pageNumber) {
|
||||
|
||||
public String text() {
|
||||
if (fragments.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
TextFragment prev = null;
|
||||
for (TextFragment f : fragments) {
|
||||
if (prev != null) {
|
||||
float gap = f.bounds().x() - prev.bounds().right();
|
||||
float avgCharWidth = prev.bounds().width() / Math.max(prev.text().length(), 1);
|
||||
if (gap > avgCharWidth * 0.5f) sb.append(' ');
|
||||
}
|
||||
sb.append(f.text());
|
||||
prev = f;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public float dominantFontSize() {
|
||||
return fragments.stream()
|
||||
.collect(Collectors.groupingBy(TextFragment::fontSize, Collectors.counting()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse(0f);
|
||||
}
|
||||
|
||||
public boolean hasBold() {
|
||||
return fragments.stream().anyMatch(TextFragment::bold);
|
||||
}
|
||||
}
|
||||
|
||||
public record RawPage(int pageNumber, float widthPt, float heightPt, List<RawLine> lines) {}
|
||||
|
||||
// ── Table model ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single cell within a {@link TableRow}. {@code colSpan} and {@code rowSpan} are always 1 in
|
||||
* v1 — span detection is deferred but the fields keep the schema stable.
|
||||
*/
|
||||
public record TableCell(int colIndex, String text, Bounds bounds, int colSpan, int rowSpan) {
|
||||
|
||||
public static TableCell of(int colIndex, String text, Bounds bounds) {
|
||||
return new TableCell(colIndex, text, bounds, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public record TableRow(int rowIndex, List<TableCell> cells) {}
|
||||
|
||||
/**
|
||||
* A table as it appears on a single page. {@code headers} is empty in v1 — all rows are in
|
||||
* {@code rows}. {@code rawRows} preserves exact Tabula text output for diagnostics. {@code
|
||||
* confidence} is a heuristic score in [0.0, 1.0]. {@code continuedFromPage} is null in v1.
|
||||
*/
|
||||
public record TableFragment(
|
||||
String tableId,
|
||||
int pageNumber,
|
||||
Bounds bounds,
|
||||
List<TableRow> headers,
|
||||
List<TableRow> rows,
|
||||
List<List<String>> rawRows,
|
||||
int columnCount,
|
||||
float confidence,
|
||||
List<String> warnings,
|
||||
Integer continuedFromPage) {}
|
||||
|
||||
// ── Page output ───────────────────────────────────────────────────────────
|
||||
|
||||
public record ParsedPage(
|
||||
int pageNumber,
|
||||
float widthPt,
|
||||
float heightPt,
|
||||
List<TableFragment> tables,
|
||||
List<RawLine> layoutLines) {}
|
||||
|
||||
// ── Parser contract ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts tables from a single page of a PDF. The caller owns the document lifecycle;
|
||||
* implementations must not close it.
|
||||
*/
|
||||
public interface TableParser {
|
||||
|
||||
/**
|
||||
* @param document open PDF; must not be closed by the implementation
|
||||
* @param rawPage page metadata and lines for the page to process
|
||||
* @return zero or more table fragments found on the page, never null
|
||||
*/
|
||||
List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import technology.tabula.ObjectExtractor;
|
||||
import technology.tabula.Page;
|
||||
import technology.tabula.RectangularTextContainer;
|
||||
import technology.tabula.Table;
|
||||
import technology.tabula.extractors.BasicExtractionAlgorithm;
|
||||
import technology.tabula.extractors.ExtractionAlgorithm;
|
||||
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
|
||||
|
||||
/**
|
||||
* Primary {@link TableParser} implementation using Tabula's lattice-mode extraction.
|
||||
*
|
||||
* <h3>Algorithm</h3>
|
||||
*
|
||||
* Uses {@link SpreadsheetExtractionAlgorithm} (lattice mode), which detects tables from ruled lines
|
||||
* (horizontal and vertical PDF path operators). This is reliable for tables with visible borders.
|
||||
* Borderless or whitespace-delimited tables will not be detected — that requires stream mode, which
|
||||
* is deferred to a later iteration.
|
||||
*
|
||||
* <h3>Coordinate system</h3>
|
||||
*
|
||||
* Tabula normalises page coordinates so that (0,0) is the top-left corner and Y increases downward,
|
||||
* matching the {@link Bounds} convention used throughout this parser. No coordinate transformation
|
||||
* is needed when mapping Tabula cell bounds to {@link Bounds}.
|
||||
*
|
||||
* <h3>Known limitations</h3>
|
||||
*
|
||||
* <ul>
|
||||
* <li>Borderless tables are not detected (use stream mode, deferred).
|
||||
* <li>Colspan and rowspan are not detected; all cells report colSpan=1, rowSpan=1.
|
||||
* <li>Header rows are not identified; all rows appear in {@code rows}, headers is empty.
|
||||
* <li>Cross-page table linking is not performed; each page is independent.
|
||||
* <li>Rotated tables (90°/270° pages) may produce incorrect bounds.
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TabulaTableParser implements TableParser {
|
||||
|
||||
/** Lattice mode — reliable for tables with visible ruled borders. */
|
||||
@Override
|
||||
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
|
||||
return parseWithAlgorithm(
|
||||
document, rawPage, new SpreadsheetExtractionAlgorithm(), "lattice");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload for callers that only have a page number, not a full {@link RawPage}.
|
||||
*/
|
||||
public List<TableFragment> parse(PDDocument document, int pageNumber) throws IOException {
|
||||
return parse(document, new RawPage(pageNumber, 0f, 0f, List.of()));
|
||||
}
|
||||
|
||||
/** Stream mode — whitespace-based column detection for borderless tables. */
|
||||
public List<TableFragment> parseStream(PDDocument document, RawPage rawPage)
|
||||
throws IOException {
|
||||
return parseWithAlgorithm(document, rawPage, new BasicExtractionAlgorithm(), "stream");
|
||||
}
|
||||
|
||||
private List<TableFragment> parseWithAlgorithm(
|
||||
PDDocument document, RawPage rawPage, ExtractionAlgorithm algorithm, String modeName)
|
||||
throws IOException {
|
||||
int pageNumber = rawPage.pageNumber();
|
||||
|
||||
List<Table> tabulaTables;
|
||||
try {
|
||||
// Do NOT use try-with-resources: ObjectExtractor.close() closes the underlying
|
||||
// PDDocument, which we don't own. The extractor holds no resources of its own.
|
||||
ObjectExtractor extractor = new ObjectExtractor(document);
|
||||
Page page = extractor.extract(pageNumber);
|
||||
tabulaTables = new ArrayList<>(algorithm.extract(page));
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Tabula {} extraction failed on page {}: {}",
|
||||
modeName,
|
||||
pageNumber,
|
||||
e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (tabulaTables.isEmpty()) {
|
||||
log.debug("Page {}: no tables detected by Tabula ({})", pageNumber, modeName);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Page {}: Tabula ({}) detected {} table(s)",
|
||||
pageNumber,
|
||||
modeName,
|
||||
tabulaTables.size());
|
||||
|
||||
List<TableFragment> fragments = new ArrayList<>(tabulaTables.size());
|
||||
for (int i = 0; i < tabulaTables.size(); i++) {
|
||||
fragments.add(toFragment(tabulaTables.get(i), pageNumber, i));
|
||||
}
|
||||
return fragments;
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private TableFragment toFragment(Table table, int pageNumber, int tableIndex) {
|
||||
List<List<RectangularTextContainer>> tabulaRows = table.getRows();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
List<List<String>> rawRows = buildRawRows(tabulaRows);
|
||||
int colCount = inferColumnCount(rawRows, warnings);
|
||||
List<TableRow> rows = buildRows(tabulaRows, colCount, warnings);
|
||||
float confidence = computeConfidence(rawRows, colCount, warnings);
|
||||
Bounds bounds = tableBounds(table);
|
||||
|
||||
String tableId = "tbl-p" + pageNumber + "-" + tableIndex;
|
||||
|
||||
if (!warnings.isEmpty()) {
|
||||
log.warn("Page {}, table {}: {}", pageNumber, tableIndex, warnings);
|
||||
}
|
||||
|
||||
return new TableFragment(
|
||||
tableId,
|
||||
pageNumber,
|
||||
bounds,
|
||||
List.of(), // headers: deferred to v2
|
||||
rows,
|
||||
rawRows,
|
||||
colCount,
|
||||
confidence,
|
||||
Collections.unmodifiableList(warnings),
|
||||
null); // continuedFromPage: deferred to v2
|
||||
}
|
||||
|
||||
private List<List<String>> buildRawRows(List<List<RectangularTextContainer>> tabulaRows) {
|
||||
List<List<String>> rawRows = new ArrayList<>(tabulaRows.size());
|
||||
for (List<RectangularTextContainer> tabulaRow : tabulaRows) {
|
||||
List<String> cells = new ArrayList<>(tabulaRow.size());
|
||||
for (RectangularTextContainer cell : tabulaRow) {
|
||||
cells.add(normaliseText(cell.getText()));
|
||||
}
|
||||
rawRows.add(Collections.unmodifiableList(cells));
|
||||
}
|
||||
return rawRows;
|
||||
}
|
||||
|
||||
private List<TableRow> buildRows(
|
||||
List<List<RectangularTextContainer>> tabulaRows, int colCount, List<String> warnings) {
|
||||
List<TableRow> rows = new ArrayList<>(tabulaRows.size());
|
||||
for (int rowIdx = 0; rowIdx < tabulaRows.size(); rowIdx++) {
|
||||
List<RectangularTextContainer> tabulaRow = tabulaRows.get(rowIdx);
|
||||
List<TableCell> cells = new ArrayList<>(tabulaRow.size());
|
||||
|
||||
for (int colIdx = 0; colIdx < tabulaRow.size(); colIdx++) {
|
||||
RectangularTextContainer c = tabulaRow.get(colIdx);
|
||||
Bounds cellBounds =
|
||||
new Bounds(
|
||||
(float) c.getX(),
|
||||
(float) c.getY(),
|
||||
(float) c.getWidth(),
|
||||
(float) c.getHeight());
|
||||
cells.add(TableCell.of(colIdx, normaliseText(c.getText()), cellBounds));
|
||||
}
|
||||
|
||||
if (tabulaRow.size() != colCount) {
|
||||
warnings.add(
|
||||
"Row "
|
||||
+ rowIdx
|
||||
+ " has "
|
||||
+ tabulaRow.size()
|
||||
+ " cells; expected "
|
||||
+ colCount);
|
||||
}
|
||||
|
||||
rows.add(new TableRow(rowIdx, Collections.unmodifiableList(cells)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical column count for a table is the size of the widest row. Tabula can produce
|
||||
* uneven rows when a cell's ruling lines are partially missing.
|
||||
*/
|
||||
private int inferColumnCount(List<List<String>> rawRows, List<String> warnings) {
|
||||
if (rawRows.isEmpty()) return 0;
|
||||
int max = rawRows.stream().mapToInt(List::size).max().orElse(0);
|
||||
int mode =
|
||||
rawRows.stream()
|
||||
.collect(
|
||||
java.util.stream.Collectors.groupingBy(
|
||||
List::size, java.util.stream.Collectors.counting()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.max(java.util.Map.Entry.comparingByValue())
|
||||
.map(java.util.Map.Entry::getKey)
|
||||
.orElse(0);
|
||||
if (max != mode) {
|
||||
warnings.add("Inconsistent column count: modal=" + mode + " max=" + max);
|
||||
}
|
||||
return mode > 0 ? mode : max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic confidence score in [0.0, 1.0].
|
||||
*
|
||||
* <ul>
|
||||
* <li>Starts at 1.0.
|
||||
* <li>-0.3 if only one column (single-column tables are usually not real tables).
|
||||
* <li>-0.1 per row with an inconsistent column count, capped at -0.4.
|
||||
* <li>-0.3 if the empty-cell ratio across all cells exceeds 80%.
|
||||
* </ul>
|
||||
*/
|
||||
private float computeConfidence(
|
||||
List<List<String>> rawRows, int colCount, List<String> warnings) {
|
||||
if (rawRows.isEmpty() || colCount == 0) return 0f;
|
||||
|
||||
float score = 1.0f;
|
||||
|
||||
if (colCount == 1) score -= 0.3f;
|
||||
|
||||
long inconsistentRows = rawRows.stream().filter(r -> r.size() != colCount).count();
|
||||
score -= Math.min(inconsistentRows * 0.1f, 0.4f);
|
||||
|
||||
long totalCells = rawRows.stream().mapToLong(List::size).sum();
|
||||
long emptyCells =
|
||||
rawRows.stream().flatMap(Collection::stream).filter(String::isBlank).count();
|
||||
if (totalCells > 0 && (float) emptyCells / totalCells > 0.8f) {
|
||||
score -= 0.3f;
|
||||
}
|
||||
|
||||
return Math.max(0f, Math.min(1f, score));
|
||||
}
|
||||
|
||||
private Bounds tableBounds(Table table) {
|
||||
return new Bounds(
|
||||
(float) table.getX(),
|
||||
(float) table.getY(),
|
||||
(float) table.getWidth(),
|
||||
(float) table.getHeight());
|
||||
}
|
||||
|
||||
private String normaliseText(String raw) {
|
||||
if (raw == null) return "";
|
||||
// Tabula wraps multi-line cell content with \r\n — collapse to a single space.
|
||||
return raw.replace("\r\n", " ").replace("\n", " ").replace("\r", " ").trim();
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
|
||||
/**
|
||||
* Extends {@link PDFTextStripper} to capture per-fragment geometry and font metadata.
|
||||
*
|
||||
* <p>Overrides {@link #writeString} to split each content-stream string into word-level {@link
|
||||
* TextFragment}s with bounding boxes, baseline, font name, and bold flag. Coordinates are in
|
||||
* PDFTextStripper space: (0,0) top-left, Y increases downward, {@code getY()} is the baseline.
|
||||
*/
|
||||
class WordExtractingStripper extends PDFTextStripper {
|
||||
|
||||
private final int targetPage;
|
||||
private final List<TextFragment> fragments = new ArrayList<>();
|
||||
private int fragmentIndex = 0;
|
||||
|
||||
WordExtractingStripper(int pageNumber) throws IOException {
|
||||
this.targetPage = pageNumber;
|
||||
setStartPage(pageNumber);
|
||||
setEndPage(pageNumber);
|
||||
setSortByPosition(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage(PDPage page) throws IOException {
|
||||
super.startPage(page);
|
||||
fragments.clear();
|
||||
fragmentIndex = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions) throws IOException {
|
||||
if (text == null || text.isBlank()) return;
|
||||
|
||||
// Fast path: no whitespace → emit one fragment (most financial PDFs have each
|
||||
// number as its own string operation, so this is the common case).
|
||||
if (text.indexOf(' ') < 0) {
|
||||
emitFragment(text, textPositions);
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-word splitting requires 1:1 text-char to TextPosition correspondence.
|
||||
// Fall back to one fragment when sizes differ (ligatures, encoding edge cases).
|
||||
if (textPositions.size() != text.length()) {
|
||||
emitFragment(text, textPositions);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit one TextFragment per whitespace-delimited word with accurate per-word bounds.
|
||||
int start = 0;
|
||||
for (int i = 0; i <= text.length(); i++) {
|
||||
if (i == text.length() || text.charAt(i) == ' ') {
|
||||
if (start < i) {
|
||||
emitFragment(text.substring(start, i), textPositions.subList(start, i));
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void emitFragment(String text, List<TextPosition> positions) {
|
||||
if (positions.isEmpty()) return;
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxRight = -Float.MAX_VALUE;
|
||||
float maxBaseline = -Float.MAX_VALUE;
|
||||
TextPosition first = null;
|
||||
|
||||
for (TextPosition tp : positions) {
|
||||
if (tp == null) continue;
|
||||
if (first == null) first = tp;
|
||||
|
||||
float x = tp.getX();
|
||||
// getY() is the baseline; top of character = getY() - getHeight().
|
||||
float top = tp.getY() - tp.getHeight();
|
||||
float right = x + tp.getWidth();
|
||||
float baseline = tp.getY();
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, top);
|
||||
maxRight = Math.max(maxRight, right);
|
||||
maxBaseline = Math.max(maxBaseline, baseline);
|
||||
}
|
||||
|
||||
if (first == null) return;
|
||||
|
||||
PDFont font = first.getFont();
|
||||
String fontName = font != null ? font.getName() : "";
|
||||
boolean bold = fontName != null && fontName.toLowerCase().contains("bold");
|
||||
// getHeight() gives the rendered glyph height, which is the most reliable visual size.
|
||||
float fontSize = first.getHeight();
|
||||
|
||||
Bounds bounds = new Bounds(minX, minY, maxRight - minX, maxBaseline - minY);
|
||||
String id = "tf-p" + targetPage + "-" + fragmentIndex++;
|
||||
fragments.add(new TextFragment(id, text, bounds, maxBaseline, fontSize, fontName, bold));
|
||||
}
|
||||
|
||||
List<TextFragment> getFragments() {
|
||||
return Collections.unmodifiableList(fragments);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -75,8 +75,8 @@ public @interface AutoJobPostMapping {
|
||||
boolean queueable() default false;
|
||||
|
||||
/**
|
||||
* Relative resource weight (1–100) used by the scheduler to prioritise / throttle jobs. Values
|
||||
* below 1 are clamped to 1, values above 100 to 100.
|
||||
* Relative resource weight (1-100). See {@link
|
||||
* stirling.software.common.enumeration.ResourceWeight} for the standard tiers.
|
||||
*/
|
||||
int resourceWeight() default 50;
|
||||
int resourceWeight() default 1;
|
||||
}
|
||||
|
||||
+12
@@ -59,6 +59,18 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
private static String initializeBasePath() {
|
||||
// Allow tests / harnesses to redirect the entire state tree (configs,
|
||||
// backups, customFiles, pipeline, logs) to an isolated location via
|
||||
// -Dstirling.base-path=... or STIRLING_BASE_PATH=... so a Playwright
|
||||
// run never touches a developer's working state.
|
||||
String override = System.getProperty("stirling.base-path");
|
||||
if (override == null || override.isBlank()) {
|
||||
override = System.getenv("STIRLING_BASE_PATH");
|
||||
}
|
||||
if (override != null && !override.isBlank()) {
|
||||
boolean hasTrailingSeparator = override.endsWith("/") || override.endsWith("\\");
|
||||
return hasTrailingSeparator ? override : override + File.separator;
|
||||
}
|
||||
return "." + File.separator;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.common.enumeration;
|
||||
|
||||
/**
|
||||
* Standard resource-weight tiers for {@link
|
||||
* stirling.software.common.annotations.AutoJobPostMapping#resourceWeight()}.
|
||||
*/
|
||||
public final class ResourceWeight {
|
||||
|
||||
/** Lightweight: rotate, page numbers, extract pages, permissions, etc. */
|
||||
public static final int SMALL_WEIGHT = 1;
|
||||
|
||||
/** Medium: merge, split, multi-tool batch. */
|
||||
public static final int MEDIUM_WEIGHT = 3;
|
||||
|
||||
/** Heavy: compress, OCR (small), conversions, raster. */
|
||||
public static final int LARGE_WEIGHT = 5;
|
||||
|
||||
/** Extra heavy: OCR on large files, full-document re-render, AI-assisted edits. */
|
||||
public static final int XLARGE_WEIGHT = 10;
|
||||
|
||||
private ResourceWeight() {}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ public class ApplicationProperties {
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -96,7 +97,14 @@ public class ApplicationProperties {
|
||||
EncodedResource encodedResource = new EncodedResource(resource);
|
||||
PropertySource<?> propertySource =
|
||||
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
|
||||
environment.getPropertySources().addFirst(propertySource);
|
||||
|
||||
boolean saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
|
||||
if (saasActive) {
|
||||
// Saas-pinned values in application-saas.properties must beat settings.yml.
|
||||
environment.getPropertySources().addLast(propertySource);
|
||||
} else {
|
||||
environment.getPropertySources().addFirst(propertySource);
|
||||
}
|
||||
|
||||
log.debug("Loaded properties: {}", propertySource.getSource());
|
||||
|
||||
@@ -237,6 +245,26 @@ public class ApplicationProperties {
|
||||
private boolean enabled = false;
|
||||
private String url = "http://localhost:5001";
|
||||
private int timeoutSeconds = 120;
|
||||
|
||||
/**
|
||||
* Longer timeout for heavy operations like RAG ingestion, which embeds the whole document
|
||||
* and can take multiple minutes for large books. Applied per-call when the caller
|
||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||
*/
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow
|
||||
* executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an
|
||||
* infinite loop in a PDF processing service) from stalling the entire chat workflow forever.
|
||||
* Tools that legitimately need longer than the read timeout should be invoked through the async
|
||||
* job executor instead of synchronously.
|
||||
*/
|
||||
@Data
|
||||
public static class InternalApi {
|
||||
private int connectTimeoutSeconds = 10;
|
||||
private int readTimeoutSeconds = 300;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -14,7 +14,6 @@ import lombok.NoArgsConstructor;
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
@Schema(description = "PDF file input - either upload a file or provide a server-side file ID")
|
||||
public class PDFFile {
|
||||
|
||||
@Schema(
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Absolute position of a PDF annotation in the document.
|
||||
*
|
||||
* <p>Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
|
||||
* PDFBox's {@code PDRectangle} convention.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number the annotation lives on.
|
||||
* @param x bottom-left x coordinate of the annotation rectangle.
|
||||
* @param y bottom-left y coordinate of the annotation rectangle.
|
||||
* @param width width of the annotation rectangle, in user-space units.
|
||||
* @param height height of the annotation rectangle, in user-space units.
|
||||
*/
|
||||
public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Description of a single sticky-note (PDF Text) annotation to place on a document.
|
||||
*
|
||||
* <p>{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
|
||||
* author/subject from {@code PdfAnnotationService}.
|
||||
*
|
||||
* @param location where to anchor the annotation icon, in PDF user-space.
|
||||
* @param text the comment body shown in the popup (required, non-blank).
|
||||
* @param author optional author label shown in the popup; {@code null} → service default.
|
||||
* @param subject optional subject line shown in the popup; {@code null} → service default.
|
||||
*/
|
||||
public record StickyNoteSpec(
|
||||
AnnotationLocation location, String text, String author, String subject) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.common.model.api.general;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class EditTextOperation {
|
||||
|
||||
@Schema(description = "The literal text to find.", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String find;
|
||||
|
||||
@Schema(
|
||||
description = "The replacement text. May be empty to delete the matched text.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String replace;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.common.model.enumeration;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/** Team invitation status */
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum InvitationStatus {
|
||||
PENDING("PENDING"),
|
||||
ACCEPTED("ACCEPTED"),
|
||||
REJECTED("REJECTED"),
|
||||
CANCELLED("CANCELLED"),
|
||||
EXPIRED("EXPIRED");
|
||||
|
||||
private final String statusName;
|
||||
|
||||
public static InvitationStatus fromString(String statusName) {
|
||||
for (InvitationStatus status : InvitationStatus.values()) {
|
||||
if (status.getStatusName().equalsIgnoreCase(statusName)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No InvitationStatus defined for name: " + statusName);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ public enum Role {
|
||||
// Unlimited access
|
||||
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
|
||||
|
||||
// Paid tier; set by Stripe webhooks under saas mode.
|
||||
PRO_USER("ROLE_PRO_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.proUser"),
|
||||
|
||||
// 40 API calls Per Day, 40 web calls
|
||||
LIMITED_API_USER("ROLE_LIMITED_API_USER", 40, 40, "adminUserSettings.apiUser"),
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package stirling.software.common.model.enumeration;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Team membership roles LEADER: Can invite/remove members, manage settings, view usage, manage
|
||||
* billing MEMBER: Regular team member with standard access
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum TeamRole {
|
||||
LEADER("LEADER"),
|
||||
MEMBER("MEMBER");
|
||||
|
||||
private final String roleName;
|
||||
|
||||
public static TeamRole fromString(String roleName) {
|
||||
for (TeamRole role : TeamRole.values()) {
|
||||
if (role.getRoleName().equalsIgnoreCase(roleName)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No TeamRole defined for name: " + roleName);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.io.UncheckedIOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -12,15 +13,18 @@ import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -34,26 +38,45 @@ import stirling.software.common.util.TempFileManager;
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
|
||||
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
//
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final Environment environment;
|
||||
private final Duration readTimeout;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public InternalApiClient(
|
||||
ServletContext servletContext,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
TempFileManager tempFileManager,
|
||||
Environment environment) {
|
||||
Environment environment,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.servletContext = servletContext;
|
||||
this.userService = userService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.environment = environment;
|
||||
ApplicationProperties.InternalApi internalApi = applicationProperties.getInternalApi();
|
||||
// A bounded read timeout is what protects the workflow when an internal tool hangs
|
||||
// (e.g. an infinite loop in a PDF processing service). The connect timeout is short
|
||||
// because this is a loopback call; if connecting takes longer than a few seconds the
|
||||
// local server is itself unhealthy.
|
||||
this.readTimeout = Duration.ofSeconds(internalApi.getReadTimeoutSeconds());
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()));
|
||||
factory.setReadTimeout(readTimeout);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +91,6 @@ public class InternalApiClient {
|
||||
validateUrl(endpointPath);
|
||||
String url = getBaseUrl() + endpointPath;
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
String apiKey = getApiKeyForUser();
|
||||
if (apiKey != null && !apiKey.isEmpty()) {
|
||||
@@ -78,26 +100,38 @@ public class InternalApiClient {
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
|
||||
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return restTemplate.execute(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestCallback,
|
||||
response -> {
|
||||
try {
|
||||
TempFile tempFile =
|
||||
tempFileManager.createManagedTempFile("internal-api");
|
||||
Files.copy(
|
||||
response.getBody(),
|
||||
tempFile.getPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
String filename = extractFilename(response.getHeaders());
|
||||
TempFileResource resource = new TempFileResource(tempFile, filename);
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
} catch (ResourceAccessException e) {
|
||||
// RestTemplate wraps low-level I/O failures in ResourceAccessException. Only the
|
||||
// SocketTimeoutException-rooted case is a real timeout; other I/O failures (connection
|
||||
// refused, DNS, etc.) propagate as-is so the upstream generic handler can describe
|
||||
// them accurately.
|
||||
if (e.getCause() instanceof java.net.SocketTimeoutException) {
|
||||
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Thrown when an internal Stirling tool invocation exceeds its configured read timeout or otherwise
|
||||
* fails at the transport layer. Distinguishes a hung/timed-out tool from a tool that returned a
|
||||
* non-2xx HTTP response (which is reported via the response status itself), so callers can present
|
||||
* a clear "the tool didn't respond in time" message to the user instead of a generic stack trace.
|
||||
*/
|
||||
public class InternalApiTimeoutException extends RuntimeException {
|
||||
|
||||
private final String endpointPath;
|
||||
private final Duration readTimeout;
|
||||
|
||||
public InternalApiTimeoutException(String endpointPath, Duration readTimeout, Throwable cause) {
|
||||
super(buildMessage(endpointPath, readTimeout, cause), cause);
|
||||
this.endpointPath = endpointPath;
|
||||
this.readTimeout = readTimeout;
|
||||
}
|
||||
|
||||
public String getEndpointPath() {
|
||||
return endpointPath;
|
||||
}
|
||||
|
||||
public Duration getReadTimeout() {
|
||||
return readTimeout;
|
||||
}
|
||||
|
||||
private static String buildMessage(String endpointPath, Duration readTimeout, Throwable cause) {
|
||||
String reason = cause != null && cause.getMessage() != null ? cause.getMessage() : "";
|
||||
return String.format(
|
||||
"Internal tool %s did not respond within %ds (%s)",
|
||||
endpointPath, readTimeout.toSeconds(), reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
/**
|
||||
* Shared primitive for adding sticky-note (PDF Text) annotations to a document.
|
||||
*
|
||||
* <p>Used by:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code /api/v1/misc/add-comments} — a deterministic, reusable tool.
|
||||
* <li>AI-agent flows that generate comment specs (e.g. PDF review agent, math auditor review
|
||||
* mode) and hand them off to this service for deterministic placement.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfAnnotationService {
|
||||
|
||||
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
|
||||
private static final float[] STICKY_NOTE_COLOR_RGB = {1f, 0.95f, 0.4f};
|
||||
|
||||
/** Opacity for the sticky-note icon. */
|
||||
private static final float ANNOTATION_OPACITY = 0.9f;
|
||||
|
||||
/** PDF Text-annotation icon name — {@code "Comment"} is one of the standard icons. */
|
||||
private static final String ANNOTATION_ICON_NAME = "Comment";
|
||||
|
||||
/** Default subject shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_SUBJECT = "Stirling AI Comment";
|
||||
|
||||
/** Default author label shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_AUTHOR = "Stirling AI";
|
||||
|
||||
/**
|
||||
* Cap on sticky-note text length. PDF annotation bodies can technically be much longer, but
|
||||
* anything beyond this is almost certainly pathological (accidental document-dump or malicious
|
||||
* payload) and would bloat the output file.
|
||||
*/
|
||||
private static final int MAX_COMMENT_TEXT_LENGTH = 100_000;
|
||||
|
||||
/**
|
||||
* Add a list of sticky notes to {@code doc}. Specs that reference an out-of-range page or
|
||||
* contain blank text are logged and skipped; this method never throws for a single bad spec.
|
||||
*
|
||||
* @return the number of annotations actually applied
|
||||
*/
|
||||
public int addStickyNotes(PDDocument doc, List<StickyNoteSpec> specs) {
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalPages = doc.getNumberOfPages();
|
||||
Calendar now = Calendar.getInstance();
|
||||
int applied = 0;
|
||||
for (int i = 0; i < specs.size(); i++) {
|
||||
StickyNoteSpec spec = specs.get(i);
|
||||
if (!isValid(spec, totalPages, i)) {
|
||||
continue;
|
||||
}
|
||||
apply(doc, spec, now);
|
||||
applied++;
|
||||
}
|
||||
if (applied < specs.size()) {
|
||||
log.warn(
|
||||
"Applied {}/{} sticky notes; {} skipped due to invalid specs.",
|
||||
applied,
|
||||
specs.size(),
|
||||
specs.size() - applied);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
|
||||
* List)} when placing multiple annotations so log output is batched.
|
||||
*/
|
||||
public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
|
||||
addStickyNotes(doc, List.of(spec));
|
||||
}
|
||||
|
||||
private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
|
||||
if (spec == null || spec.location() == null) {
|
||||
log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text() == null || spec.text().isBlank()) {
|
||||
log.warn("Skipping sticky-note[{}]: text is blank.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: text length {} exceeds limit {}.",
|
||||
index,
|
||||
spec.text().length(),
|
||||
MAX_COMMENT_TEXT_LENGTH);
|
||||
return false;
|
||||
}
|
||||
AnnotationLocation loc = spec.location();
|
||||
if (loc.width() <= 0f || loc.height() <= 0f) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
|
||||
index,
|
||||
loc.width(),
|
||||
loc.height());
|
||||
return false;
|
||||
}
|
||||
int page = loc.pageIndex();
|
||||
if (page < 0 || page >= totalPages) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
|
||||
index,
|
||||
page,
|
||||
totalPages);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
|
||||
AnnotationLocation loc = spec.location();
|
||||
|
||||
PDAnnotationText annot = new PDAnnotationText();
|
||||
annot.setContents(spec.text());
|
||||
annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
|
||||
annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
|
||||
annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
|
||||
annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
|
||||
annot.setCreationDate(now);
|
||||
annot.setConstantOpacity(ANNOTATION_OPACITY);
|
||||
annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
|
||||
|
||||
try {
|
||||
doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
|
||||
} catch (java.io.IOException e) {
|
||||
log.warn(
|
||||
"Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nonBlankOr(String value, String fallback) {
|
||||
return value != null && !value.isBlank() ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -2289,6 +2289,85 @@ public class FormUtils {
|
||||
acroForm.getFields().add(field);
|
||||
}
|
||||
|
||||
/** Drops AcroForm fields whose widgets are no longer on any page of {@code document}. */
|
||||
public void pruneOrphanedFormFields(PDDocument document) {
|
||||
if (document == null) {
|
||||
return;
|
||||
}
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog == null) {
|
||||
return;
|
||||
}
|
||||
PDAcroForm form = catalog.getAcroForm(null);
|
||||
if (form == null) {
|
||||
return;
|
||||
}
|
||||
List<PDField> fields = form.getFields();
|
||||
if (fields.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<COSDictionary> liveWidgets = collectLiveWidgetDictionaries(document);
|
||||
List<PDField> kept = pruneFieldList(fields, liveWidgets);
|
||||
if (kept.isEmpty()) {
|
||||
catalog.setAcroForm(null);
|
||||
} else if (kept.size() != fields.size()) {
|
||||
form.setFields(kept);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<COSDictionary> collectLiveWidgetDictionaries(PDDocument document) {
|
||||
Set<COSDictionary> live = new HashSet<>();
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
for (PDAnnotation annotation : document.getPage(i).getAnnotations()) {
|
||||
if (annotation instanceof PDAnnotationWidget) {
|
||||
live.add(annotation.getCOSObject());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed reading page {} annotations: {}", i, e.getMessage());
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
private List<PDField> pruneFieldList(List<PDField> fields, Set<COSDictionary> liveWidgets) {
|
||||
List<PDField> kept = new ArrayList<>(fields.size());
|
||||
for (PDField field : fields) {
|
||||
if (field instanceof PDNonTerminalField nonTerminal) {
|
||||
List<PDField> children = nonTerminal.getChildren();
|
||||
List<PDField> remaining = pruneFieldList(children, liveWidgets);
|
||||
if (remaining.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (remaining.size() != children.size()) {
|
||||
nonTerminal.setChildren(remaining);
|
||||
}
|
||||
kept.add(nonTerminal);
|
||||
} else if (field instanceof PDTerminalField terminal) {
|
||||
List<PDAnnotationWidget> widgets = terminal.getWidgets();
|
||||
List<PDAnnotationWidget> liveOnes = new ArrayList<>(widgets.size());
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (liveWidgets.contains(widget.getCOSObject())) {
|
||||
liveOnes.add(widget);
|
||||
}
|
||||
}
|
||||
if (liveOnes.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (liveOnes.size() != widgets.size()) {
|
||||
terminal.setWidgets(liveOnes);
|
||||
}
|
||||
kept.add(terminal);
|
||||
} else {
|
||||
kept.add(field);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
// Delegation methods to GeneralFormCopyUtils for form field transformation
|
||||
public boolean hasAnyRotatedPage(PDDocument document) {
|
||||
return stirling.software.common.util.GeneralFormCopyUtils.hasAnyRotatedPage(document);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
|
||||
* origin). Used by tools that receive "anchor by text" hints — e.g. {@code
|
||||
* /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
|
||||
* coordinates.
|
||||
*
|
||||
* <p>Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
|
||||
* a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
|
||||
* "Total Revenue"} matches {@code "Total Revenue."}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PdfTextLocator {
|
||||
|
||||
/** One found line of text with its user-space bounding box. */
|
||||
public record MatchedBox(float x, float y, float width, float height) {}
|
||||
|
||||
/**
|
||||
* Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
|
||||
* the tolerant match. Returns empty when no match, when the page index is out of range, or when
|
||||
* the needle is blank.
|
||||
*/
|
||||
public Optional<MatchedBox> findOnPage(PDDocument doc, int pageIndex, String needle) {
|
||||
if (doc == null
|
||||
|| needle == null
|
||||
|| needle.isBlank()
|
||||
|| pageIndex < 0
|
||||
|| pageIndex >= doc.getNumberOfPages()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalizedNeedle = normalize(needle);
|
||||
if (normalizedNeedle.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<CapturedLine> lines = new ArrayList<>();
|
||||
LineCapturingStripper stripper;
|
||||
try {
|
||||
stripper = new LineCapturingStripper(lines);
|
||||
stripper.setStartPage(pageIndex + 1);
|
||||
stripper.setEndPage(pageIndex + 1);
|
||||
stripper.setSortByPosition(true);
|
||||
// Side effect: populates `lines`. We don't need the concatenated text.
|
||||
stripper.getText(doc);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"PdfTextLocator failed to extract text on page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
|
||||
float pageHeight = mediaBox.getHeight();
|
||||
|
||||
for (CapturedLine line : lines) {
|
||||
if (normalize(line.text).contains(normalizedNeedle)) {
|
||||
// PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
|
||||
// user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
|
||||
float userSpaceY = pageHeight - line.yTopDown - line.height;
|
||||
return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
|
||||
private static String normalize(String s) {
|
||||
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class CapturedLine {
|
||||
String text;
|
||||
float x;
|
||||
float yTopDown;
|
||||
float width;
|
||||
float height;
|
||||
}
|
||||
|
||||
private static final class LineCapturingStripper extends PDFTextStripper {
|
||||
private final List<CapturedLine> lines;
|
||||
|
||||
LineCapturingStripper(List<CapturedLine> sink) throws IOException {
|
||||
super();
|
||||
this.lines = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
if (textPositions != null && !textPositions.isEmpty()) {
|
||||
CapturedLine line = new CapturedLine();
|
||||
line.text = text;
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxRight = 0f;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxHeight = 0f;
|
||||
for (TextPosition p : textPositions) {
|
||||
float x = p.getXDirAdj();
|
||||
float y = p.getYDirAdj();
|
||||
float w = p.getWidthDirAdj();
|
||||
float h = p.getHeightDir();
|
||||
if (h == 0f) {
|
||||
// Workaround: some fonts report 0 height via TextPosition; fall back to
|
||||
// the nominal font size so downstream bboxes are never zero-height.
|
||||
h = p.getFontSizeInPt();
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (x + w > maxRight) maxRight = x + w;
|
||||
if (y < minY) minY = y;
|
||||
if (h > maxHeight) maxHeight = h;
|
||||
}
|
||||
line.x = minX;
|
||||
line.width = maxRight - minX;
|
||||
line.yTopDown = minY;
|
||||
line.height = maxHeight;
|
||||
lines.add(line);
|
||||
}
|
||||
super.writeString(text, textPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
@@ -202,7 +203,15 @@ public class ProcessExecutor {
|
||||
boolean useSemaphore = true;
|
||||
List<String> commandToRun = command;
|
||||
if (shouldUseUnoServerPool(command)) {
|
||||
unoLease = unoServerPool.acquireEndpoint();
|
||||
try {
|
||||
unoLease = unoServerPool.acquireEndpoint(timeoutDuration, TimeUnit.MINUTES);
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException(
|
||||
"All unoserver endpoints busy; request timed out after "
|
||||
+ timeoutDuration
|
||||
+ " minutes",
|
||||
e);
|
||||
}
|
||||
commandToRun = applyUnoServerEndpoint(command, unoLease.getEndpoint());
|
||||
useSemaphore = false;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package stirling.software.common.util;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -42,6 +45,27 @@ public class UnoServerPool {
|
||||
return new UnoServerLease(endpoints.get(index), index, this);
|
||||
}
|
||||
|
||||
/** Fail-fast variant; non-positive timeout falls back to unbounded acquire. */
|
||||
public UnoServerLease acquireEndpoint(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, TimeoutException {
|
||||
if (endpoints.isEmpty()) {
|
||||
return new UnoServerLease(defaultEndpoint(), null, this);
|
||||
}
|
||||
if (timeout <= 0) {
|
||||
return acquireEndpoint();
|
||||
}
|
||||
|
||||
Integer index = availableIndices.poll(timeout, unit);
|
||||
if (index == null) {
|
||||
throw new TimeoutException(
|
||||
"Timed out waiting for a free unoserver endpoint after "
|
||||
+ timeout
|
||||
+ " "
|
||||
+ unit.name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return new UnoServerLease(endpoints.get(index), index, this);
|
||||
}
|
||||
|
||||
private void releaseEndpoint(Integer index) {
|
||||
if (index != null) {
|
||||
availableIndices.offer(index);
|
||||
|
||||
+18
-6
@@ -6,15 +6,18 @@ import java.util.List;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.JavaType;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Spring property editor that decodes a JSON string into a typed {@link ArrayList}. Used to bind
|
||||
* complex list parameters (e.g. {@code List<RedactionArea>}, {@code List<EditTextOperation>}) from
|
||||
* multipart form fields, where Spring's default binding cannot deserialize a JSON array.
|
||||
*/
|
||||
@Slf4j
|
||||
public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
public class StringToArrayListPropertyEditor<T> extends PropertyEditorSupport {
|
||||
|
||||
private final ObjectMapper objectMapper =
|
||||
JsonMapper.builder()
|
||||
@@ -22,6 +25,12 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build();
|
||||
|
||||
private final Class<T> elementType;
|
||||
|
||||
public StringToArrayListPropertyEditor(Class<T> elementType) {
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAsText(String text) throws IllegalArgumentException {
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
@@ -29,8 +38,11 @@ public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TypeReference<ArrayList<RedactionArea>> typeRef = new TypeReference<>() {};
|
||||
List<RedactionArea> list = objectMapper.readValue(text, typeRef);
|
||||
JavaType listType =
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(ArrayList.class, elementType);
|
||||
List<T> list = objectMapper.readValue(text, listType);
|
||||
setValue(list);
|
||||
} catch (Exception e) {
|
||||
log.error("Exception while converting {}", e);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EndpointConfigurationTest {
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriExtractsSimpleKebab() {
|
||||
assertEquals(
|
||||
"remove-pages",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
|
||||
assertEquals(
|
||||
"compress-pdf",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/misc/compress-pdf"));
|
||||
assertEquals(
|
||||
"add-watermark",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/security/add-watermark"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriComposesConvertEndpoints() {
|
||||
assertEquals(
|
||||
"pdf-to-img", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
|
||||
assertEquals(
|
||||
"pdf-to-word", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/word"));
|
||||
assertEquals(
|
||||
"html-to-pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/html/pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriReturnsNullForNonApiPaths() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri(null));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/some-page"));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package stirling.software.SPDF.pdf.parser;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LineAlignmentTableParser}, focused on the coincident-line merge logic and
|
||||
* column-grid construction.
|
||||
*/
|
||||
class LineAlignmentTableParserTest {
|
||||
|
||||
private final LineAlignmentTableParser parser = new LineAlignmentTableParser();
|
||||
|
||||
// ── mergeCoincidentLines ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_singleLine_unchanged() {
|
||||
var lines = List.of(tokenized(rawLine(10f, 100f, "Revenue")));
|
||||
assertThat(parser.mergeCoincidentLines(lines)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_distinctYLines_unchanged() {
|
||||
// Two lines at different y positions — must NOT be merged.
|
||||
var lines =
|
||||
List.of(
|
||||
tokenized(rawLine(10f, 100f, "Revenue")),
|
||||
tokenized(rawLine(10f, 115f, "Cost")));
|
||||
assertThat(parser.mergeCoincidentLines(lines)).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_sameY_merged() {
|
||||
// Simulates a financial-table row split by LineBuilder at the column gap:
|
||||
// label fragment at x=72 → "Revenue"
|
||||
// value fragment at x=350 → "1,234"
|
||||
// Both have y=100. After merge they should form one TokenizedLine.
|
||||
var label = rawLine(72f, 100f, "Revenue");
|
||||
var value = rawLine(350f, 100f, "1,234");
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
// The merged line should contain tokens from both halves.
|
||||
var tokens = merged.get(0).all();
|
||||
assertThat(tokens.stream().map(t -> t.text()).toList())
|
||||
.containsExactlyInAnyOrder("Revenue", "1,234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_sameY_mergedLineHasCorrectBounds() {
|
||||
var label = rawLine(72f, 100f, "Revenue"); // 7 chars × 6pt = 42pt wide → right = 114
|
||||
var value = rawLine(350f, 100f, "1,234"); // 5 chars × 6pt = 30pt wide → right = 380
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
|
||||
|
||||
var bounds = merged.get(0).line().bounds();
|
||||
assertThat(bounds.x()).isEqualTo(72f);
|
||||
assertThat(bounds.right()).isEqualTo(380f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_withinTolerance_merged() {
|
||||
// Lines 1.5pt apart (within ROW_MERGE_TOLERANCE_PT = 2pt) should merge.
|
||||
var a = rawLine(10f, 100.0f, "Alpha");
|
||||
var b = rawLine(200f, 101.5f, "99");
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
|
||||
assertThat(merged).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_beyondTolerance_notMerged() {
|
||||
// Lines 3pt apart (beyond ROW_MERGE_TOLERANCE_PT = 2pt) should NOT merge.
|
||||
var a = rawLine(10f, 100.0f, "Alpha");
|
||||
var b = rawLine(200f, 103.0f, "99");
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
|
||||
assertThat(merged).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_threeCoincident_allMerged() {
|
||||
// Three fragments at the same y (e.g. wide financial table with two value columns).
|
||||
var a = rawLine(72f, 100f, "Revenue");
|
||||
var b = rawLine(300f, 100f, "1,234");
|
||||
var c = rawLine(400f, 100f, "5,678");
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).all()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_coincidentPairFollowedByDistinctLine_twoGroups() {
|
||||
var a = rawLine(72f, 100f, "Revenue");
|
||||
var b = rawLine(350f, 100f, "1,234"); // same y as a → merges with a
|
||||
var c = rawLine(10f, 115f, "Expenses"); // different y → stays separate
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
|
||||
assertThat(merged).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCoincidentLines_numericAnchorStatus_correctAfterMerge() {
|
||||
// After merging, the combined line should be an anchor (≥2 numeric tokens).
|
||||
// "Revenue" alone → not an anchor. "1,234 567" alone → anchor.
|
||||
// Merged → anchor with at least 2 numerics.
|
||||
var label = rawLine(72f, 100f, "Revenue");
|
||||
var values = rawLineMultiWord(350f, 100f, "1,234", 30f, "567", 30f);
|
||||
|
||||
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(values)));
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).isAnchor()).isTrue();
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Creates a RawLine with a single TextFragment of the given text at the given position. */
|
||||
private static RawLine rawLine(float x, float y, String text) {
|
||||
float width = text.length() * 6f; // ~6pt per char — rough but consistent
|
||||
float height = 12f;
|
||||
Bounds bounds = new Bounds(x, y, width, height);
|
||||
TextFragment fragment =
|
||||
new TextFragment("tf-test", text, bounds, y + height, 11f, "Helvetica", false);
|
||||
return new RawLine("ln-test", List.of(fragment), bounds, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a RawLine with two TextFragments representing two words separated by a small gap.
|
||||
* Used to simulate a values-only line with multiple numeric tokens.
|
||||
*/
|
||||
private static RawLine rawLineMultiWord(
|
||||
float x, float y, String word1, float w1, String word2, float w2) {
|
||||
float height = 12f;
|
||||
Bounds b1 = new Bounds(x, y, w1, height);
|
||||
Bounds b2 = new Bounds(x + w1 + 5f, y, w2, height);
|
||||
TextFragment f1 = new TextFragment("tf-1", word1, b1, y + height, 11f, "Helvetica", false);
|
||||
TextFragment f2 = new TextFragment("tf-2", word2, b2, y + height, 11f, "Helvetica", false);
|
||||
Bounds lineBounds = new Bounds(x, y, x + w1 + 5f + w2 - x, height);
|
||||
return new RawLine("ln-test", List.of(f1, f2), lineBounds, 1);
|
||||
}
|
||||
|
||||
/** Tokenises a RawLine via the parser's own tokenise logic (package-private access). */
|
||||
private LineAlignmentTableParser.TokenizedLine tokenized(RawLine line) {
|
||||
return parser.tokenize(line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package stirling.software.common.architecture;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
|
||||
/**
|
||||
* Module dependency-direction guardrails. Allowed direction: {@code saas → proprietary → common}
|
||||
* and {@code stirling-pdf → proprietary → common}.
|
||||
*/
|
||||
class ArchitectureTest {
|
||||
|
||||
private static final JavaClasses commonClasses =
|
||||
new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages("stirling.software.common");
|
||||
|
||||
@Test
|
||||
void commonDoesNotDependOnCore() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.SPDF..");
|
||||
rule.check(commonClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonDoesNotDependOnProprietary() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.proprietary..");
|
||||
rule.check(commonClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonDoesNotDependOnSaas() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.saas..");
|
||||
rule.check(commonClasses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package stirling.software.common.jpdfium;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
class JPDFiumSmokeTest {
|
||||
|
||||
@Test
|
||||
void opensExamplePdfAndReadsPageCount(@TempDir Path tmp) throws IOException {
|
||||
Path pdf = tmp.resolve("example.pdf");
|
||||
try (InputStream in = getClass().getResourceAsStream("/example.pdf")) {
|
||||
assertNotNull(in, "example.pdf must exist under src/test/resources");
|
||||
Files.copy(in, pdf);
|
||||
}
|
||||
|
||||
try (PdfDocument doc = PdfDocument.open(pdf)) {
|
||||
assertTrue(
|
||||
doc.pageCount() >= 1,
|
||||
"PdfDocument should report at least one page for example.pdf");
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
-2
@@ -22,11 +22,13 @@ import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RequestCallback;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.ResponseExtractor;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import jakarta.servlet.ServletContext;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@@ -42,8 +44,19 @@ class InternalApiClientTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(servletContext.getContextPath()).thenReturn("");
|
||||
client = newClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh client. Tests that use {@link org.mockito.Mockito#mockConstruction} to
|
||||
* intercept {@link RestTemplate} must call this from inside their {@code mockConstruction}
|
||||
* block, since the client now caches one RestTemplate per instance at construction time.
|
||||
*/
|
||||
private InternalApiClient newClient() {
|
||||
MockEnvironment environment = new MockEnvironment().withProperty("server.port", "8080");
|
||||
client = new InternalApiClient(servletContext, userService, tempFileManager, environment);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
return new InternalApiClient(
|
||||
servletContext, userService, tempFileManager, environment, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -75,7 +88,10 @@ class InternalApiClientTest {
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
ResponseEntity<Resource> response = client.post("/api/v1/general/merge-pdfs", body);
|
||||
// Reconstruct the client so its cached RestTemplate is the mocked one.
|
||||
InternalApiClient mockedClient = newClient();
|
||||
ResponseEntity<Resource> response =
|
||||
mockedClient.post("/api/v1/general/merge-pdfs", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -86,12 +102,123 @@ class InternalApiClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsSocketTimeoutAsInternalApiTimeoutException() {
|
||||
// Simulates the read-timeout case: RestTemplate wraps SocketTimeoutException in
|
||||
// ResourceAccessException when the underlying socket times out. The client must repackage
|
||||
// that into a typed timeout exception that carries the failing endpoint and the configured
|
||||
// read timeout, so the workflow layer can surface a clean "tool didn't respond" message
|
||||
// to the user.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(inv -> (RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenThrow(
|
||||
new ResourceAccessException(
|
||||
"I/O error on POST request: Read timed out",
|
||||
new java.net.SocketTimeoutException(
|
||||
"Read timed out")));
|
||||
})) {
|
||||
|
||||
InternalApiClient mockedClient = newClient();
|
||||
InternalApiTimeoutException thrown =
|
||||
assertThrows(
|
||||
InternalApiTimeoutException.class,
|
||||
() -> mockedClient.post("/api/v1/general/merge-pdfs", body));
|
||||
|
||||
assertEquals("/api/v1/general/merge-pdfs", thrown.getEndpointPath());
|
||||
assertNotNull(thrown.getReadTimeout());
|
||||
assertTrue(thrown.getMessage().contains("/api/v1/general/merge-pdfs"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRethrowsNonTimeoutResourceAccessExceptionAsIs() {
|
||||
// ResourceAccessException covers more than just timeouts (e.g. connection refused, DNS
|
||||
// failure). Only SocketTimeoutException-rooted failures are timeouts; everything else
|
||||
// must propagate so the upstream generic handler can label it correctly instead of lying
|
||||
// about a "tool didn't respond" timeout.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenAnswer(inv -> (RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenThrow(
|
||||
new ResourceAccessException(
|
||||
"I/O error on POST request: Connection refused",
|
||||
new java.net.ConnectException(
|
||||
"Connection refused")));
|
||||
})) {
|
||||
|
||||
InternalApiClient mockedClient = newClient();
|
||||
assertThrows(
|
||||
ResourceAccessException.class,
|
||||
() -> mockedClient.post("/api/v1/general/merge-pdfs", body));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsDisallowedPath() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
|
||||
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
|
||||
// dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
|
||||
// namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
|
||||
// introduce recursion risk.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postAcceptsAiToolsSubnamespace() throws Exception {
|
||||
// Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
|
||||
// should be dispatchable by the orchestrator's plan executor.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
|
||||
TempFile tempFile = mock(TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenReturn((RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
// Reconstruct the client so its cached RestTemplate is the mocked one.
|
||||
InternalApiClient mockedClient = newClient();
|
||||
ResponseEntity<Resource> response =
|
||||
mockedClient.post("/api/v1/ai/tools/pdf-comment-agent", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsPathTraversal() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
class PdfAnnotationServiceTest {
|
||||
|
||||
private PdfAnnotationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdfAnnotationService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "First comment", "alice", null),
|
||||
spec(1, 100f, 650f, "Second comment", null, "Second"));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(2, applied);
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
|
||||
|
||||
PDAnnotationText first =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertEquals("First comment", first.getContents());
|
||||
assertEquals("alice", first.getTitlePopup(), "author override propagates");
|
||||
assertNotNull(first.getSubject(), "subject falls back to default when null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithBlankText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Valid", null, null),
|
||||
spec(0, 72f, 680f, " ", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Blank-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOutOfRangePageIndex() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "OK", null, null),
|
||||
spec(99, 72f, 700f, "Too far", null, null),
|
||||
spec(-1, 72f, 700f, "Negative", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the in-range spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNullAndEmptySpecList() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
assertEquals(0, service.addStickyNotes(doc, null));
|
||||
assertEquals(0, service.addStickyNotes(doc, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithNonPositiveDimensions() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
StickyNoteSpec zeroWidth =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 700f, 0f, 20f),
|
||||
"Zero width",
|
||||
null,
|
||||
null);
|
||||
StickyNoteSpec negativeHeight =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 680f, 20f, -5f),
|
||||
"Negative height",
|
||||
null,
|
||||
null);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the positively-sized spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOverlongText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
String overlong = "x".repeat(100_001);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Short", null, null),
|
||||
spec(0, 72f, 680f, overlong, null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Overlong-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
|
||||
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAnnotationText annot =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertTrue(
|
||||
annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
|
||||
"Default author should be applied");
|
||||
assertTrue(
|
||||
annot.getSubject() != null && !annot.getSubject().isBlank(),
|
||||
"Default subject should be applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static StickyNoteSpec spec(
|
||||
int page, float x, float y, String text, String author, String subject) {
|
||||
return new StickyNoteSpec(
|
||||
new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FormUtilsPruneOrphanedFieldsTest {
|
||||
|
||||
@Test
|
||||
void noAcroFormIsNoOp() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
assertNull(document.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsFieldsWhoseWidgetsAreAllOnRemovedPages() throws IOException {
|
||||
byte[] pdfBytes = buildPdfWithFieldPerPage(3);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
document.removePage(2);
|
||||
document.removePage(0);
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
List<String> remainingNames = new ArrayList<>();
|
||||
for (PDField field : form.getFields()) {
|
||||
remainingNames.add(field.getPartialName());
|
||||
}
|
||||
assertEquals(List.of("field_1"), remainingNames);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsAcroFormEntirelyWhenNoFieldsSurvive() throws IOException {
|
||||
byte[] pdfBytes = buildPdfWithFieldPerPage(2);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
document.removePage(1);
|
||||
document.removePage(0);
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
|
||||
assertNull(document.getDocumentCatalog().getAcroForm(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLiveWidgetsAndDropsOrphanWidgetsFromMultiWidgetField() throws IOException {
|
||||
byte[] pdfBytes = buildPdfWithMultiWidgetField();
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
document.removePage(0);
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
assertEquals(1, form.getFields().size());
|
||||
PDField field = form.getFields().get(0);
|
||||
assertEquals("multi", field.getPartialName());
|
||||
assertEquals(2, field.getWidgets().size(), "two widgets remain after one is dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void survivesRoundTripWithoutOrphanPagesInOutput() throws IOException {
|
||||
byte[] pdfBytes = buildPdfWithFieldPerPage(3);
|
||||
|
||||
byte[] writtenBytes;
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
document.removePage(2);
|
||||
document.removePage(1);
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
document.save(out);
|
||||
writtenBytes = out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(writtenBytes)) {
|
||||
assertEquals(1, reloaded.getNumberOfPages());
|
||||
PDAcroForm form = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
assertEquals(1, form.getFields().size());
|
||||
assertEquals("field_0", form.getFields().get(0).getPartialName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void prunesNestedNonTerminalFields() throws IOException {
|
||||
byte[] pdfBytes = buildPdfWithNestedFields();
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
document.removePage(1);
|
||||
|
||||
FormUtils.pruneOrphanedFormFields(document);
|
||||
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(form);
|
||||
assertEquals(1, form.getFields().size());
|
||||
PDField group = form.getFields().get(0);
|
||||
assertEquals("group", group.getPartialName());
|
||||
assertTrue(group instanceof PDNonTerminalField);
|
||||
PDNonTerminalField nonTerminal = (PDNonTerminalField) group;
|
||||
assertEquals(1, nonTerminal.getChildren().size());
|
||||
assertEquals("kept", nonTerminal.getChildren().get(0).getPartialName());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildPdfWithFieldPerPage(int pageCount) throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
|
||||
PDTextField field = new PDTextField(acroForm);
|
||||
field.setPartialName("field_" + i);
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(50, 50, 100, 20));
|
||||
widget.setPage(page);
|
||||
field.setWidgets(List.of(widget));
|
||||
acroForm.getFields().add(field);
|
||||
page.getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildPdfWithMultiWidgetField() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(new PDRectangle(50, 50, 100, 20));
|
||||
widget.setPage(page);
|
||||
page.getAnnotations().add(widget);
|
||||
widgets.add(widget);
|
||||
}
|
||||
|
||||
PDTextField field = new PDTextField(acroForm);
|
||||
field.setPartialName("multi");
|
||||
field.setWidgets(widgets);
|
||||
acroForm.getFields().add(field);
|
||||
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildPdfWithNestedFields() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
PDPage pageA = new PDPage(PDRectangle.A4);
|
||||
PDPage pageB = new PDPage(PDRectangle.A4);
|
||||
document.addPage(pageA);
|
||||
document.addPage(pageB);
|
||||
|
||||
PDNonTerminalField group = new PDNonTerminalField(acroForm);
|
||||
group.setPartialName("group");
|
||||
|
||||
PDTextField kept = new PDTextField(acroForm);
|
||||
kept.setPartialName("kept");
|
||||
PDAnnotationWidget keptWidget = new PDAnnotationWidget();
|
||||
keptWidget.setRectangle(new PDRectangle(50, 50, 100, 20));
|
||||
keptWidget.setPage(pageA);
|
||||
kept.setWidgets(List.of(keptWidget));
|
||||
pageA.getAnnotations().add(keptWidget);
|
||||
|
||||
PDTextField dropped = new PDTextField(acroForm);
|
||||
dropped.setPartialName("dropped");
|
||||
PDAnnotationWidget droppedWidget = new PDAnnotationWidget();
|
||||
droppedWidget.setRectangle(new PDRectangle(50, 100, 100, 20));
|
||||
droppedWidget.setPage(pageB);
|
||||
dropped.setWidgets(List.of(droppedWidget));
|
||||
pageB.getAnnotations().add(droppedWidget);
|
||||
|
||||
group.setChildren(List.of(kept, dropped));
|
||||
acroForm.getFields().add(group);
|
||||
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
|
||||
class PdfTextLocatorTest {
|
||||
|
||||
private final PdfTextLocator locator = new PdfTextLocator();
|
||||
|
||||
@Test
|
||||
void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "215000");
|
||||
assertThat(match).isPresent();
|
||||
MatchedBox box = match.get();
|
||||
// Line was drawn at y=720 in user-space (bottom-left origin); locator
|
||||
// should return a bbox close to that height band with non-zero width.
|
||||
assertThat(box.width()).isGreaterThan(0f);
|
||||
assertThat(box.height()).isGreaterThan(0f);
|
||||
assertThat(box.y()).isBetween(700f, 740f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchIsCaseAndPunctuationInsensitive() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "total revenue");
|
||||
assertThat(match).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenNeedleNotFound() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "not-on-this-page");
|
||||
assertThat(match).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForBlankNeedle() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Any text"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForOutOfRangePage() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Single page"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithLines(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -173,6 +174,70 @@ public class UnoServerPoolTest {
|
||||
assertEquals(port1, port2, "Should reuse the same endpoint after release");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithTimeoutFailsFast() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
|
||||
UnoServerPool.UnoServerLease held = pool.acquireEndpoint();
|
||||
|
||||
long start = System.nanoTime();
|
||||
assertThrows(
|
||||
TimeoutException.class,
|
||||
() -> pool.acquireEndpoint(100, TimeUnit.MILLISECONDS),
|
||||
"Should time out when no endpoint available");
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertTrue(
|
||||
elapsedMs >= 100 && elapsedMs < 1000,
|
||||
"Should fail-fast after roughly the timeout, got " + elapsedMs + "ms");
|
||||
|
||||
held.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithTimeoutSucceedsWhenAvailable()
|
||||
throws InterruptedException, TimeoutException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
|
||||
try (UnoServerPool.UnoServerLease lease = pool.acquireEndpoint(1, TimeUnit.SECONDS)) {
|
||||
assertNotNull(lease);
|
||||
assertEquals(2003, lease.getEndpoint().getPort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAcquireWithZeroTimeoutBlocksUnbounded() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints =
|
||||
createEndpoints(1);
|
||||
UnoServerPool pool = new UnoServerPool(endpoints);
|
||||
UnoServerPool.UnoServerLease held = pool.acquireEndpoint();
|
||||
|
||||
AtomicInteger acquired = new AtomicInteger(0);
|
||||
Thread t =
|
||||
Thread.ofVirtual()
|
||||
.start(
|
||||
() -> {
|
||||
try {
|
||||
UnoServerPool.UnoServerLease lease =
|
||||
pool.acquireEndpoint(0, TimeUnit.MILLISECONDS);
|
||||
acquired.incrementAndGet();
|
||||
lease.close();
|
||||
} catch (Exception e) {
|
||||
fail("unexpected: " + e);
|
||||
}
|
||||
});
|
||||
|
||||
Thread.sleep(150);
|
||||
assertEquals(0, acquired.get(), "Should still be blocked");
|
||||
held.close();
|
||||
t.join(1000);
|
||||
assertEquals(1, acquired.get(), "Should acquire after release");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHostLocationAndProtocol() throws InterruptedException {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints = new ArrayList<>();
|
||||
|
||||
+2
-2
@@ -11,11 +11,11 @@ import stirling.software.common.model.api.security.RedactionArea;
|
||||
|
||||
class StringToArrayListPropertyEditorTest {
|
||||
|
||||
private StringToArrayListPropertyEditor editor;
|
||||
private StringToArrayListPropertyEditor<RedactionArea> editor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
editor = new StringToArrayListPropertyEditor();
|
||||
editor = new StringToArrayListPropertyEditor<>(RedactionArea.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+35
-15
@@ -39,12 +39,14 @@ spotless {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|
||||
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
|
||||
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
|
||||
if (!gradle.ext.disableAdditional) {
|
||||
implementation project(':proprietary')
|
||||
}
|
||||
|
||||
if (gradle.ext.enableSaas) {
|
||||
implementation project(':saas')
|
||||
}
|
||||
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
|
||||
@@ -65,7 +67,7 @@ dependencies {
|
||||
exclude group: 'com.fasterxml.jackson.jaxrs'
|
||||
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
|
||||
}
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation 'commons-io:commons-io:2.22.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
@@ -87,12 +89,6 @@ dependencies {
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
implementation ('technology.tabula:tabula:1.0.5') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||
@@ -166,7 +162,8 @@ bootJar {
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
'Implementation-Version': project.version
|
||||
'Implementation-Version': project.version,
|
||||
'Enable-Native-Access': 'ALL-UNNAMED'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -179,6 +176,23 @@ springBoot {
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
|
||||
|
||||
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
|
||||
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
|
||||
def frontendMode
|
||||
if (buildPrototypes) {
|
||||
frontendMode = 'prototypes'
|
||||
} else if (frontendModeOverride) {
|
||||
frontendMode = frontendModeOverride
|
||||
} else if (gradle.ext.enableSaas) {
|
||||
frontendMode = 'saas'
|
||||
} else if (gradle.ext.disableAdditional) {
|
||||
frontendMode = 'core'
|
||||
} else {
|
||||
frontendMode = 'proprietary'
|
||||
}
|
||||
def frontendBuildTask = "frontend:build:${frontendMode}"
|
||||
|
||||
def frontendDir = file('../../frontend')
|
||||
def frontendDistDir = file('../../frontend/dist')
|
||||
def resourcesStaticDir = file('src/main/resources/static')
|
||||
@@ -247,7 +261,7 @@ tasks.register('npmBuild', Exec) {
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
workingDir file('../..')
|
||||
commandLine = buildPrototypes ? ['task', 'frontend:build:prototypes'] : ['task', 'frontend:build']
|
||||
commandLine = ['task', frontendBuildTask]
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.dir(new File(frontendDir, 'public'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
@@ -262,7 +276,7 @@ tasks.register('npmBuild', Exec) {
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
|
||||
doFirst {
|
||||
println "Building frontend application for production (VITE_API_BASE_URL=/)"
|
||||
println "Building frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +285,7 @@ tasks.register('copyFrontendAssets', Copy) {
|
||||
group = 'frontend'
|
||||
description = 'Copy frontend build to static resources'
|
||||
dependsOn npmBuild
|
||||
dependsOn cleanFrontendAssets
|
||||
from(frontendDistDir) {
|
||||
// Exclude files that conflict with backend static resources
|
||||
exclude 'robots.txt' // Backend already has this
|
||||
@@ -311,7 +326,7 @@ tasks.named('copyFrontendAssets').configure {
|
||||
}
|
||||
|
||||
if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
println "Frontend build enabled - JAR will include React frontend (mode=${frontendMode})"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
||||
@@ -320,4 +335,9 @@ if (buildWithFrontend) {
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
if (!gradle.ext.disableAdditional) {
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
}
|
||||
if (gradle.ext.enableSaas) {
|
||||
bootJar.dependsOn ':saas:jar'
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
scanBasePackages = {
|
||||
"stirling.software.SPDF",
|
||||
"stirling.software.common",
|
||||
"stirling.software.proprietary"
|
||||
"stirling.software.proprietary",
|
||||
"stirling.software.saas"
|
||||
})
|
||||
public class SPDFApplication {
|
||||
|
||||
@@ -205,15 +206,22 @@ public class SPDFApplication {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Detect if SecurityConfiguration is present on classpath
|
||||
if (isClassPresent(
|
||||
"stirling.software.proprietary.security.configuration.SecurityConfiguration")) {
|
||||
// 2. Detect classpath shape and pick the matching profile chain.
|
||||
boolean hasSaas = isClassPresent("stirling.software.saas.security.SupabaseSecurityConfig");
|
||||
boolean hasSecurity =
|
||||
isClassPresent(
|
||||
"stirling.software.proprietary.security.configuration.SecurityConfiguration");
|
||||
|
||||
if (hasSaas) {
|
||||
log.info("SaaS features in jar");
|
||||
return new String[] {"security", "saas"};
|
||||
}
|
||||
if (hasSecurity) {
|
||||
log.info("Additional features in jar");
|
||||
return new String[] {"security"};
|
||||
} else {
|
||||
log.info("Without additional features in jar");
|
||||
return new String[] {"default"};
|
||||
}
|
||||
log.info("Without additional features in jar");
|
||||
return new String[] {"default"};
|
||||
}
|
||||
|
||||
private static boolean isClassPresent(String className) {
|
||||
|
||||
@@ -21,28 +21,7 @@ public class EndpointInterceptor implements HandlerInterceptor {
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String requestURI = request.getRequestURI();
|
||||
boolean isEnabled;
|
||||
|
||||
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
|
||||
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
|
||||
|
||||
String[] requestURIParts = requestURI.split("/");
|
||||
String requestEndpoint;
|
||||
|
||||
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
|
||||
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
|
||||
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
|
||||
} else {
|
||||
requestEndpoint = requestURIParts[4];
|
||||
}
|
||||
|
||||
log.debug("Request endpoint: {}", requestEndpoint);
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
|
||||
log.debug("Is endpoint enabled: {}", isEnabled);
|
||||
} else {
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
|
||||
}
|
||||
|
||||
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
|
||||
if (!isEnabled) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
return false;
|
||||
|
||||
+31
-8
@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.AnalysisApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@@ -32,7 +33,10 @@ public class AnalysisController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/page-count", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/page-count",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get PDF page count",
|
||||
@@ -43,7 +47,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/basic-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/basic-info",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get basic PDF information",
|
||||
@@ -60,7 +67,8 @@ public class AnalysisController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/document-properties",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get PDF document properties",
|
||||
@@ -90,7 +98,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/page-dimensions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/page-dimensions",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get page dimensions for all pages",
|
||||
@@ -110,7 +121,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/form-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/form-fields",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get form field information",
|
||||
@@ -134,7 +148,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/annotation-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/annotation-info",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get annotation information",
|
||||
@@ -159,7 +176,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/font-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/font-info",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get font information",
|
||||
@@ -185,7 +205,10 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/security-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/security-info",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Get security information",
|
||||
|
||||
+16
-1
@@ -25,18 +25,22 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BookletImpositionController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@@ -44,7 +48,8 @@ public class BookletImpositionController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/booklet-imposition",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
@@ -71,6 +76,16 @@ public class BookletImpositionController {
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox imposes.
|
||||
// Holdout: JPDFium PdfPrint.booklet lacks gutter, duplex passes, flipOnShortEdge, border.
|
||||
if (file != null) {
|
||||
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -126,7 +127,10 @@ public class CropController {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/crop",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
description =
|
||||
|
||||
+5
-2
@@ -25,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -44,7 +45,8 @@ public class EditTableOfContentsController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@@ -147,7 +149,8 @@ public class EditTableOfContentsController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/edit-table-of-contents",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Edit Table of Contents",
|
||||
description = "Add or edit bookmarks/table of contents in a PDF document.")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.EditTextRequest;
|
||||
import stirling.software.SPDF.model.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPage;
|
||||
import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
|
||||
|
||||
/**
|
||||
* Find/replace text editing for PDFs. Round-trips through {@link PdfJsonConversionService}: the
|
||||
* input PDF is parsed into the editable JSON model, find/replace operations are applied to the
|
||||
* {@code text} field of each text element, and the mutated model is rebuilt into a PDF.
|
||||
*
|
||||
* <p>Matching joins all text elements on a page into a single string before searching, so find
|
||||
* strings can span multiple visual runs (titles split per word, kerning-broken phrases, etc.).
|
||||
*
|
||||
* <p>For cross-element matches the entire replacement is written into the first matched element,
|
||||
* any intermediate elements are emptied, and the suffix of the last matched element is preserved.
|
||||
* This anchors the new text at the leftmost match position and lets the font lay it out as one run,
|
||||
* which is more visually reliable than redistributing words to the original per-element X positions
|
||||
* (those positions are calibrated to the original word widths and rarely match the replacement
|
||||
* widths). Centered or tracked layouts will become left-aligned at the original first-word position
|
||||
* when their text is replaced - the trade-off is accepted in favour of avoiding glyph overlaps and
|
||||
* gaps from variable-width word distribution.
|
||||
*/
|
||||
@Slf4j
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
public class EditTextController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
binder.registerCustomEditor(
|
||||
List.class,
|
||||
"edits",
|
||||
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
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."
|
||||
+ " Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
List<EditTextOperation> edits = request.getEdits();
|
||||
if (edits == null || edits.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.editText.no.edits",
|
||||
"No find/replace operations provided for text editing");
|
||||
}
|
||||
for (EditTextOperation edit : edits) {
|
||||
if (edit == null || edit.getFind() == null || edit.getFind().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.editText.empty.find", "Each edit must have a non-empty find string");
|
||||
}
|
||||
}
|
||||
|
||||
boolean wholeWordSearch = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
List<CompiledEdit> compiledEdits = compileEdits(edits, wholeWordSearch);
|
||||
|
||||
PdfJsonDocument document = pdfJsonConversionService.convertPdfToJsonDocument(inputFile);
|
||||
Set<Integer> pageFilter = resolvePageFilter(request, document);
|
||||
int modifiedSpans = applyEdits(document, compiledEdits, pageFilter);
|
||||
log.info(
|
||||
"edit-text: modified {} text span(s) using {} edit(s) on {} page(s)",
|
||||
modifiedSpans,
|
||||
compiledEdits.size(),
|
||||
pageFilter == null
|
||||
? document.getPages() == null ? 0 : document.getPages().size()
|
||||
: pageFilter.size());
|
||||
|
||||
String docName = buildOutputFilename(inputFile);
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (OutputStream os = Files.newOutputStream(tempOut.getPath())) {
|
||||
pdfJsonConversionService.convertJsonToPdf(document, os);
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
|
||||
}
|
||||
|
||||
private List<CompiledEdit> compileEdits(
|
||||
List<EditTextOperation> edits, boolean wholeWordSearch) {
|
||||
return edits.stream().map(edit -> compileEdit(edit, wholeWordSearch)).toList();
|
||||
}
|
||||
|
||||
private CompiledEdit compileEdit(EditTextOperation edit, boolean wholeWordSearch) {
|
||||
// Always treat the user-supplied find string as a literal: Pattern.quote escapes any
|
||||
// regex metacharacters, so the constructed pattern can only ever do a literal match
|
||||
// (optionally bounded by our own word-boundary anchors). This rules out catastrophic
|
||||
// backtracking from a malicious find string.
|
||||
String findRaw = edit.getFind();
|
||||
String replacement = Objects.toString(edit.getReplace(), "");
|
||||
String regex = Pattern.quote(findRaw);
|
||||
if (wholeWordSearch) {
|
||||
// Use lookarounds rather than \b so the bound works even when the find string starts
|
||||
// or ends with a non-word character (e.g. "-foo" or "$id"). \b only fires at a
|
||||
// word/non-word transition, which would never trigger for a find that starts with a
|
||||
// non-word char and was preceded by another non-word char in the source text.
|
||||
regex = "(?<!\\w)(?:" + regex + ")(?!\\w)";
|
||||
}
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
String safeReplacement = Matcher.quoteReplacement(replacement);
|
||||
return new CompiledEdit(pattern, safeReplacement);
|
||||
}
|
||||
|
||||
private Set<Integer> resolvePageFilter(EditTextRequest request, PdfJsonDocument document) {
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
int totalPages = document.getPages() == null ? 0 : document.getPages().size();
|
||||
if (totalPages == 0) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
if (pageNumbers == null || pageNumbers.isBlank() || "all".equalsIgnoreCase(pageNumbers)) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> pages = GeneralUtils.parsePageList(pageNumbers, totalPages, true);
|
||||
return new HashSet<>(pages);
|
||||
}
|
||||
|
||||
private int applyEdits(
|
||||
PdfJsonDocument document, List<CompiledEdit> edits, Set<Integer> pageFilter) {
|
||||
List<PdfJsonPage> pages = document.getPages();
|
||||
if (pages == null) {
|
||||
return 0;
|
||||
}
|
||||
int modifiedSpans = 0;
|
||||
// The page filter is built from parsePageList against pages.size(), so it returns 1-based
|
||||
// positional page numbers. Match against the same positional numbering here rather than
|
||||
// the per-page pageNumber field, which can become non-sequential after split/merge round
|
||||
// trips and would misalign the filter.
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
int positionalPageNumber = i + 1;
|
||||
if (pageFilter != null && !pageFilter.contains(positionalPageNumber)) {
|
||||
continue;
|
||||
}
|
||||
modifiedSpans += applyEditsToPage(pages.get(i), edits);
|
||||
}
|
||||
return modifiedSpans;
|
||||
}
|
||||
|
||||
private int applyEditsToPage(PdfJsonPage page, List<CompiledEdit> edits) {
|
||||
List<PdfJsonTextElement> elements = page.getTextElements();
|
||||
if (elements == null || elements.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Set<Integer> modifiedIndices = new HashSet<>();
|
||||
for (CompiledEdit edit : edits) {
|
||||
applyEditToPage(elements, edit, modifiedIndices);
|
||||
}
|
||||
for (Integer index : modifiedIndices) {
|
||||
// Char codes were captured for the original glyph sequence; clear them so the rebuild
|
||||
// re-encodes from the new text via the font.
|
||||
elements.get(index).setCharCodes(null);
|
||||
}
|
||||
return modifiedIndices.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single edit across the page by matching against the concatenation of all element
|
||||
* texts, then writing the replacement back into the originating element(s).
|
||||
*/
|
||||
private void applyEditToPage(
|
||||
List<PdfJsonTextElement> elements, CompiledEdit edit, Set<Integer> modifiedIndices) {
|
||||
StringBuilder joined = new StringBuilder();
|
||||
int[] starts = new int[elements.size()];
|
||||
int[] ends = new int[elements.size()];
|
||||
for (int i = 0; i < elements.size(); i++) {
|
||||
starts[i] = joined.length();
|
||||
String text = elements.get(i).getText();
|
||||
if (text != null) {
|
||||
joined.append(text);
|
||||
}
|
||||
ends[i] = joined.length();
|
||||
}
|
||||
|
||||
Matcher matcher = edit.pattern().matcher(joined);
|
||||
List<MatchSpan> spans = new ArrayList<>();
|
||||
StringBuffer interpolation = new StringBuffer();
|
||||
int previousAppendPosition = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() == matcher.end()) {
|
||||
// Skip zero-length matches (e.g. /a*/ on empty input) — they cannot be applied.
|
||||
continue;
|
||||
}
|
||||
int sizeBefore = interpolation.length();
|
||||
matcher.appendReplacement(interpolation, edit.replacement());
|
||||
int prefixLength = matcher.start() - previousAppendPosition;
|
||||
String actualReplacement =
|
||||
interpolation.substring(sizeBefore + prefixLength, interpolation.length());
|
||||
spans.add(new MatchSpan(matcher.start(), matcher.end(), actualReplacement));
|
||||
previousAppendPosition = matcher.end();
|
||||
}
|
||||
|
||||
// Apply right-to-left so earlier match positions stay valid as we mutate elements.
|
||||
for (int i = spans.size() - 1; i >= 0; i--) {
|
||||
MatchSpan span = spans.get(i);
|
||||
int firstElement = findElementForCharIndex(starts, ends, span.start());
|
||||
int lastElement = findElementForCharIndex(starts, ends, span.end() - 1);
|
||||
if (firstElement < 0 || lastElement < 0) {
|
||||
continue;
|
||||
}
|
||||
applyMatchToElements(
|
||||
elements, starts, span, firstElement, lastElement, modifiedIndices);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element whose text covers the character at {@code charIndex} in the joined string.
|
||||
* Returns -1 if no element covers that index (which should not happen for valid match spans).
|
||||
*/
|
||||
private static int findElementForCharIndex(int[] starts, int[] ends, int charIndex) {
|
||||
for (int i = 0; i < starts.length; i++) {
|
||||
if (starts[i] <= charIndex && charIndex < ends[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void applyMatchToElements(
|
||||
List<PdfJsonTextElement> elements,
|
||||
int[] starts,
|
||||
MatchSpan span,
|
||||
int firstElement,
|
||||
int lastElement,
|
||||
Set<Integer> modifiedIndices) {
|
||||
if (firstElement == lastElement) {
|
||||
PdfJsonTextElement element = elements.get(firstElement);
|
||||
String text = nullToEmpty(element.getText());
|
||||
int matchStartInElement = span.start() - starts[firstElement];
|
||||
int matchEndInElement = span.end() - starts[firstElement];
|
||||
element.setText(
|
||||
text.substring(0, matchStartInElement)
|
||||
+ span.replacement()
|
||||
+ text.substring(matchEndInElement));
|
||||
modifiedIndices.add(firstElement);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-element match: write the whole replacement into the first matched element, empty
|
||||
// any intermediate elements, and keep only the suffix of the last matched element. The
|
||||
// JSON->PDF rebuild concatenates per-token text, so the font lays out the replacement as
|
||||
// one continuous run anchored at the first element's X position.
|
||||
String firstText = nullToEmpty(elements.get(firstElement).getText());
|
||||
int firstSplit = span.start() - starts[firstElement];
|
||||
elements.get(firstElement).setText(firstText.substring(0, firstSplit) + span.replacement());
|
||||
modifiedIndices.add(firstElement);
|
||||
|
||||
for (int mid = firstElement + 1; mid < lastElement; mid++) {
|
||||
elements.get(mid).setText("");
|
||||
modifiedIndices.add(mid);
|
||||
}
|
||||
|
||||
String lastText = nullToEmpty(elements.get(lastElement).getText());
|
||||
int lastSplit = span.end() - starts[lastElement];
|
||||
elements.get(lastElement).setText(lastText.substring(lastSplit));
|
||||
modifiedIndices.add(lastElement);
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
private String buildOutputFilename(MultipartFile inputFile) {
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
return baseName + "_edited.pdf";
|
||||
}
|
||||
|
||||
private record CompiledEdit(Pattern pattern, String replacement) {}
|
||||
|
||||
private record MatchSpan(int start, int end, String replacement) {}
|
||||
}
|
||||
+171
-65
@@ -3,13 +3,14 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
@@ -39,6 +40,7 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -46,6 +48,11 @@ import stirling.software.common.util.PdfErrorUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.PdfMerge;
|
||||
import stirling.software.jpdfium.doc.Bookmark;
|
||||
import stirling.software.jpdfium.doc.PdfBookmarkEditor;
|
||||
import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@@ -56,7 +63,6 @@ public class MergeController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
// Merges a list of PDDocument objects into a single PDDocument
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
|
||||
boolean success = false;
|
||||
@@ -75,11 +81,8 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-order files to match the explicit order provided by the front-end.
|
||||
// fileOrder is newline-delimited original filenames in the desired order.
|
||||
private static MultipartFile[] reorderFilesByProvidedOrder(
|
||||
MultipartFile[] files, String fileOrder) {
|
||||
// Split by various line endings and trim each entry
|
||||
String[] desired =
|
||||
stirling.software.common.util.RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
@@ -106,7 +109,6 @@ public class MergeController {
|
||||
return ordered.toArray(new MultipartFile[0]);
|
||||
}
|
||||
|
||||
// Returns a comparator for sorting MultipartFile arrays based on the given sort type
|
||||
private Comparator<MultipartFile> getSortComparator(String sortType) {
|
||||
return switch (sortType) {
|
||||
case "byFileName" ->
|
||||
@@ -154,18 +156,16 @@ public class MergeController {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided
|
||||
default -> (file1, file2) -> 0; // Default is the order provided
|
||||
case "orderProvided" -> (file1, file2) -> 0;
|
||||
default -> (file1, file2) -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
@@ -185,39 +185,29 @@ public class MergeController {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
int pageIndex = 0; // Current page index in the merged document
|
||||
|
||||
// Iterate through the original files
|
||||
int pageIndex = 0;
|
||||
for (MultipartFile file : files) {
|
||||
// Get the filename without extension to use as bookmark title
|
||||
String filename = file.getOriginalFilename();
|
||||
String title = GeneralUtils.removeExtension(filename);
|
||||
|
||||
// Create an outline item for this file
|
||||
PDOutlineItem item = new PDOutlineItem();
|
||||
item.setTitle(title);
|
||||
|
||||
// Set the destination to the first page of this file in the merged document
|
||||
if (pageIndex < mergedDocument.getNumberOfPages()) {
|
||||
PDPage page = mergedDocument.getPage(pageIndex);
|
||||
item.setDestination(page);
|
||||
}
|
||||
|
||||
// Add the item to the outline
|
||||
outline.addLast(item);
|
||||
|
||||
// Increment page index for the next file
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
pageIndex += doc.getNumberOfPages();
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document loading for TOC generation", e);
|
||||
pageIndex++; // Increment by at least one if we can't determine page count
|
||||
pageIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,7 +225,6 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to XMP metadata if Info dates are missing
|
||||
PDMetadata metadata = doc.getDocumentCatalog().getMetadata();
|
||||
if (metadata != null) {
|
||||
try (InputStream is = metadata.createInputStream()) {
|
||||
@@ -271,7 +260,10 @@ public class MergeController {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/merge-pdfs")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/merge-pdfs",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Merge multiple PDF files into one",
|
||||
@@ -283,7 +275,7 @@ public class MergeController {
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
throws IOException {
|
||||
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
|
||||
List<File> filesToDelete = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
|
||||
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
|
||||
@@ -294,48 +286,35 @@ public class MergeController {
|
||||
files = new MultipartFile[0];
|
||||
}
|
||||
|
||||
// If front-end provided explicit visible order, honor it and override backend sorting
|
||||
if (fileOrder != null && !fileOrder.isBlank()) {
|
||||
log.info("Reordering files based on fileOrder parameter");
|
||||
files = reorderFilesByProvidedOrder(files, fileOrder);
|
||||
} else {
|
||||
log.info("Sorting files based on sortType: {}", request.getSortType());
|
||||
Arrays.sort(
|
||||
files,
|
||||
getSortComparator(
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
Arrays.sort(files, getSortComparator(request.getSortType()));
|
||||
}
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
List<Path> inputPaths = new ArrayList<>(files.length);
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
tempFileManager.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
|
||||
filesToDelete.add(tempFile);
|
||||
inputPaths.add(tempFile.toPath());
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
try (PdfDocument ignored = PdfDocument.open(tempFile.toPath())) {
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
|
||||
|
||||
int[] pageCounts;
|
||||
try {
|
||||
mergerUtility.mergeDocuments(
|
||||
pdfDocumentFactory.getStreamCacheFunction(
|
||||
totalSize)); // Merge the documents
|
||||
pageCounts =
|
||||
mergeWithJpdfium(inputPaths, files, generateToc, mt.getFile().toPath());
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF merge", e);
|
||||
if (PdfErrorUtils.isCorruptedPdfError(e)) {
|
||||
@@ -344,10 +323,26 @@ public class MergeController {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Load the merged PDF document and operate on it inside try-with-resources
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
|
||||
// Remove signatures if removeCertSign is true
|
||||
if (removeCertSign) {
|
||||
boolean sigFlattenNeeded = false;
|
||||
if (removeCertSign) {
|
||||
try (PdfDocument check = PdfDocument.open(mt.getFile().toPath())) {
|
||||
sigFlattenNeeded = !check.signatures().isEmpty();
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"JPDFium signature pre-check failed; falling back to PDFBox flatten:"
|
||||
+ " {}",
|
||||
e.getMessage());
|
||||
sigFlattenNeeded = true;
|
||||
}
|
||||
if (!sigFlattenNeeded) {
|
||||
log.info(
|
||||
"removeCertSign requested but merged document has no signature"
|
||||
+ " fields; skipping PDFBox flatten pass");
|
||||
}
|
||||
}
|
||||
|
||||
if (sigFlattenNeeded) {
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
|
||||
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
@@ -355,24 +350,26 @@ public class MergeController {
|
||||
acroForm.getFields().stream()
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(
|
||||
fieldsToRemove,
|
||||
false); // Flatten the fields, effectively removing them
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
}
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add table of contents if generateToc is true
|
||||
if (generateToc && files.length > 0) {
|
||||
addTableOfContents(mergedDocument, files);
|
||||
}
|
||||
|
||||
// Save the modified document to a temporary file
|
||||
} else {
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
Files.copy(
|
||||
mt.getFile().toPath(),
|
||||
outputTempFile.getFile().toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
@@ -391,7 +388,7 @@ public class MergeController {
|
||||
throw ex;
|
||||
} finally {
|
||||
for (File file : filesToDelete) {
|
||||
tempFileManager.deleteTempFile(file); // Delete temporary files
|
||||
tempFileManager.deleteTempFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,4 +398,113 @@ public class MergeController {
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
|
||||
}
|
||||
|
||||
private int[] mergeWithJpdfium(
|
||||
List<Path> inputPaths, MultipartFile[] files, boolean generateToc, Path outputPath)
|
||||
throws IOException {
|
||||
if (inputPaths.isEmpty()) {
|
||||
try (PdfDocument empty = PdfDocument.open(new byte[0])) {
|
||||
empty.save(outputPath);
|
||||
} catch (Exception ignored) {
|
||||
Files.write(outputPath, new byte[0]);
|
||||
}
|
||||
return new int[0];
|
||||
}
|
||||
|
||||
List<PdfDocument> docs = new ArrayList<>(inputPaths.size());
|
||||
int[] pageCounts = new int[inputPaths.size()];
|
||||
int[] pageOffsets = new int[inputPaths.size()];
|
||||
List<List<Bookmark>> sourceBookmarks = new ArrayList<>(inputPaths.size());
|
||||
int runningOffset = 0;
|
||||
try {
|
||||
for (int i = 0; i < inputPaths.size(); i++) {
|
||||
Path p = inputPaths.get(i);
|
||||
PdfDocument doc = PdfDocument.open(p);
|
||||
docs.add(doc);
|
||||
pageCounts[i] = doc.pageCount();
|
||||
pageOffsets[i] = runningOffset;
|
||||
sourceBookmarks.add(doc.bookmarks());
|
||||
runningOffset += pageCounts[i];
|
||||
}
|
||||
|
||||
BookmarkTree combinedTree =
|
||||
buildCombinedBookmarkTree(files, pageOffsets, sourceBookmarks, generateToc);
|
||||
|
||||
try (PdfDocument merged = PdfMerge.merge(docs)) {
|
||||
if (combinedTree.entries().isEmpty()) {
|
||||
merged.save(outputPath);
|
||||
} else {
|
||||
PdfBookmarkEditor.setBookmarks(merged, combinedTree, outputPath);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("JPDFium merge failed", e);
|
||||
} finally {
|
||||
for (PdfDocument doc : docs) {
|
||||
try {
|
||||
doc.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageCounts;
|
||||
}
|
||||
|
||||
private BookmarkTree buildCombinedBookmarkTree(
|
||||
MultipartFile[] files,
|
||||
int[] pageOffsets,
|
||||
List<List<Bookmark>> sourceBookmarks,
|
||||
boolean generateToc) {
|
||||
BookmarkTree.Builder builder = BookmarkTree.builder();
|
||||
|
||||
if (generateToc) {
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
String filename = files[i].getOriginalFilename();
|
||||
String title = GeneralUtils.removeExtension(filename);
|
||||
if (title == null || title.isBlank()) {
|
||||
title = "Document " + (i + 1);
|
||||
}
|
||||
builder.add(title, pageOffsets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < sourceBookmarks.size(); i++) {
|
||||
int offset = pageOffsets[i];
|
||||
for (Bookmark bm : sourceBookmarks.get(i)) {
|
||||
addBookmarkFlat(builder, bm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private void addBookmarkFlat(BookmarkTree.Builder builder, Bookmark root, int offset) {
|
||||
final int maxNodes = 100_000;
|
||||
java.util.Deque<Bookmark> stack = new java.util.ArrayDeque<>();
|
||||
java.util.Set<Bookmark> visited =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
stack.push(root);
|
||||
int processed = 0;
|
||||
while (!stack.isEmpty() && processed < maxNodes) {
|
||||
Bookmark bm = stack.pop();
|
||||
if (!visited.add(bm)) {
|
||||
continue;
|
||||
}
|
||||
processed++;
|
||||
if (bm.isInternal() && bm.title() != null) {
|
||||
builder.add(bm.title(), offset + bm.pageIndex());
|
||||
}
|
||||
if (bm.hasChildren()) {
|
||||
List<Bookmark> children = bm.children();
|
||||
for (int i = children.size() - 1; i >= 0; i--) {
|
||||
stack.push(children.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (processed >= maxNodes) {
|
||||
log.warn(
|
||||
"Source bookmark traversal hit {}-node cap; remaining bookmarks dropped",
|
||||
maxNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -24,12 +24,14 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralFormCopyUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@@ -41,7 +43,8 @@ public class MultiPageLayoutController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/multi-page-layout",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Merge multiple pages of a PDF document into a single page",
|
||||
description =
|
||||
@@ -189,6 +192,16 @@ public class MultiPageLayoutController {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox lays out.
|
||||
// Holdout: JPDFium NUpLayout has no margins, borders, RTL, BY_COLUMNS, or form copy.
|
||||
if (file != null) {
|
||||
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
|
||||
+5
-1
@@ -25,6 +25,7 @@ import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -39,7 +40,10 @@ public class PdfOverlayController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/overlay-pdfs",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Overlay PDF files in various modes",
|
||||
|
||||
+16
-1
@@ -28,12 +28,14 @@ import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@@ -43,7 +45,10 @@ public class PosterPdfController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(value = "/split-for-poster-print", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(
|
||||
value = "/split-for-poster-print",
|
||||
consumes = "multipart/form-data",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Split large PDF pages into smaller printable chunks",
|
||||
@@ -61,6 +66,16 @@ public class PosterPdfController {
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
|
||||
log.debug("Base filename for output: {}", filename);
|
||||
|
||||
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox tiles.
|
||||
// Holdout: JPDFium PdfPosterizer edits in-place without scaling to target paper or RTL.
|
||||
if (file != null) {
|
||||
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user