Compare commits

...
Author SHA1 Message Date
Anthony Stirling 1ada0cdf5d audit: jpdfium layout 2026-05-22 09:57:52 +01:00
Anthony Stirling eb2c18325b impl migration to pdfium for layout 2026-05-22 09:41:35 +01:00
Anthony Stirling 2f1fe2c80c Delete MergeBenchmark 2026-05-21 23:00:00 +01:00
Anthony Stirling 1051f28c52 Drop remaining why/how comments 2026-05-21 19:11:52 +01:00
Anthony Stirling 29ccbf7ae6 Trim verbose comments across JPDFium integration 2026-05-21 19:03:31 +01:00
Anthony Stirling 007c8e17de Exclude JPDFium native cache from temp-file regression check 2026-05-21 17:46:46 +01:00
Anthony Stirling 81dbb29be3 Iterative addBookmarkFlat with depth + cycle + node-count guards 2026-05-21 17:17:06 +01:00
Anthony Stirling caef0477a9 Merge remote-tracking branch 'origin/main' into feat/jpdfium-integration
# Conflicts:
#	app/common/build.gradle
2026-05-21 16:55:18 +01:00
Anthony Stirling 7682a0dd54 sign-jpdfium-dylibs: sign both Gradle and Tauri bootJar copies 2026-05-21 16:26:52 +01:00
Anthony StirlingandConnorYoh 9d081d1792 SaaS Consolidation (#6384)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2026-05-21 16:05:35 +01:00
Anthony Stirling f90ed4657a Apply spotless; bash 3.2-compatible array handling in sign-jpdfium-dylibs 2026-05-21 15:53:28 +01:00
Anthony Stirling a6c7a68242 Move sign-jpdfium-dylibs-in-bootjar.sh into frontend/scripts/ 2026-05-21 15:21:25 +01:00
Anthony Stirling a184b394d6 Strip rationale-history comments from JDK 25 references 2026-05-21 15:20:11 +01:00
Anthony Stirling 302d04c201 Merge remote-tracking branch 'origin/main' into feat/jpdfium-integration
# Conflicts:
#	build.gradle
2026-05-21 15:05:51 +01:00
Anthony Stirling 68ae9d52fb Bump JPDFium dep to 1.0.0; drop snapshot repo 2026-05-21 14:46:10 +01:00
089de247b4 Updated Spanish translation (#6309)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-21 13:16:07 +01:00
Anthony Stirling 6552ba905c Cleanup: drop local mavenLocal hack; gate MergeBenchmark with -Dmerge.bench=true 2026-05-21 11:58:43 +01:00
Anthony Stirling ee36b6f616 MergeController: preserve source bookmarks through merge (PDFium drops them) 2026-05-21 11:39:59 +01:00
Anthony Stirling 627091f8df MergeController: use JPDFium native merge (94% less heap on 5-doc merge) 2026-05-21 10:37:01 +01:00
Anthony Stirling 67a37d3291 Filter JPDFium natives per Tauri target OS (saves ~45 MB per bundle) 2026-05-21 00:28:31 +01:00
dependabot[bot] 0c3a512b21 build(deps): bump softprops/action-gh-release from 2.6.1 to 3.0.0 (#6346)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:34:16 +01:00
dependabot[bot] 707cfa3140 build(deps): bump devalue from 5.6.4 to 5.8.1 in /frontend (#6367)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:18:43 +01:00
dependabot[bot] ea91a35518 build(deps): bump org.postgresql:postgresql from 42.7.10 to 42.7.11 (#6375)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:17:36 +01:00
dependabot[bot] 5a5aada2d8 build(deps): bump pypdf from 6.10.2 to 6.11.0 in /testing/cucumber (#6376)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:17:18 +01:00
dependabot[bot] a4ef70eaf8 build(deps): bump sigstore/cosign-installer from 4.1.1 to 4.1.2 (#6377)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:17:04 +01:00
dependabot[bot] d76d25654a build(deps): bump actions/dependency-review-action from 4.9.0 to 5.0.0 (#6378)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:16:48 +01:00
dependabot[bot] 31b00b7d06 build(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#6379)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:16:42 +01:00
dependabot[bot] f00c12ad8a build(deps): bump step-security/harden-runner from 2.19.0 to 2.19.1 (#6380)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:16:38 +01:00
dependabot[bot] 1f5c046362 build(deps): bump tokio from 1.49.0 to 1.50.0 in /frontend/src-tauri in the tokio group across 1 directory (#6296)
Bumps the tokio group with 1 update in the /frontend/src-tauri
directory: [tokio](https://github.com/tokio-rs/tokio).

Updates `tokio` from 1.49.0 to 1.50.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tokio-rs/tokio/releases">tokio's
releases</a>.</em></p>
<blockquote>
<h2>Tokio v1.50.0</h2>
<h1>1.50.0 (Mar 3rd, 2026)</h1>
<h3>Added</h3>
<ul>
<li>net: add <code>TcpStream::set_zero_linger</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7837">#7837</a>)</li>
<li>rt: add <code>is_rt_shutdown_err</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7771">#7771</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>io: add optimizer hint that <code>memchr</code> returns in-bounds
pointer (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7792">#7792</a>)</li>
<li>io: implement vectored writes for <code>write_buf</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7871">#7871</a>)</li>
<li>runtime: panic when <code>event_interval</code> is set to 0 (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7838">#7838</a>)</li>
<li>runtime: shorten default thread name to fit in Linux limit (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7880">#7880</a>)</li>
<li>signal: remember the result of <code>SetConsoleCtrlHandler</code>
(<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7833">#7833</a>)</li>
<li>signal: specialize windows <code>Registry</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7885">#7885</a>)</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>io: always cleanup <code>AsyncFd</code> registration list on
deregister (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7773">#7773</a>)</li>
<li>macros: remove (most) local <code>use</code> declarations in
<code>tokio::select!</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7929">#7929</a>)</li>
<li>net: fix <code>GET_BUF_SIZE</code> constant for <code>target_os =
&quot;android&quot;</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7889">#7889</a>)</li>
<li>runtime: avoid redundant unpark in current_thread scheduler (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7834">#7834</a>)</li>
<li>runtime: don't park in <code>current_thread</code> if
<code>before_park</code> defers waker (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7835">#7835</a>)</li>
<li>io: fix write readiness on ESP32 on short writes (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7872">#7872</a>)</li>
<li>runtime: wake deferred tasks before entering
<code>block_in_place</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7879">#7879</a>)</li>
<li>sync: drop rx waker when oneshot receiver is dropped (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7886">#7886</a>)</li>
<li>runtime: fix double increment of <code>num_idle_threads</code> on
shutdown (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7910">#7910</a>,
<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7918">#7918</a>,
<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7922">#7922</a>)</li>
</ul>
<h3>Unstable</h3>
<ul>
<li>fs: check for io-uring opcode support (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7815">#7815</a>)</li>
<li>runtime: avoid lock acquisition after uring init (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7850">#7850</a>)</li>
</ul>
<h3>Documented</h3>
<ul>
<li>docs: update outdated unstable features section (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7839">#7839</a>)</li>
<li>io: clarify the behavior of <code>AsyncWriteExt::shutdown()</code>
(<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7908">#7908</a>)</li>
<li>io: explain how to flush stdout/stderr (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7904">#7904</a>)</li>
<li>io: fix incorrect and confusing <code>AsyncWrite</code>
documentation (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7875">#7875</a>)</li>
<li>rt: clarify the documentation of <code>Runtime::spawn</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7803">#7803</a>)</li>
<li>rt: fix missing quotation in docs (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7925">#7925</a>)</li>
<li>runtime: correct the default thread name in docs (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7896">#7896</a>)</li>
<li>runtime: fix <code>event_interval</code> doc (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7932">#7932</a>)</li>
<li>sync: clarify RwLock fairness documentation (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7919">#7919</a>)</li>
<li>sync: clarify that <code>recv</code> returns <code>None</code> once
closed and no more messages (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7920">#7920</a>)</li>
<li>task: clarify when to use <code>spawn_blocking</code> vs dedicated
threads (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7923">#7923</a>)</li>
<li>task: doc that task drops before <code>JoinHandle</code> completion
(<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7825">#7825</a>)</li>
<li>signal: guarantee that listeners never return <code>None</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7869">#7869</a>)</li>
<li>task: fix task module feature flags in docs (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7891">#7891</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tokio-rs/tokio/commit/0273e45ead199dac7725faee1e3dc35a9c8753ab"><code>0273e45</code></a>
chore: prepare Tokio v1.50.0 (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7934">#7934</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/e3ee4e58dc9bb7accf26dfd51b0a2146922b5269"><code>e3ee4e5</code></a>
chore: prepare tokio-macros v2.6.1 (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7943">#7943</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/8c980ea75a0f8dd2799403777db700c2e8f4cda4"><code>8c980ea</code></a>
io: add <code>write_all_vectored</code> to <code>tokio-util</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7768">#7768</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/e35fd6d6b7d9a8ba37ee621835ef91372c2565cb"><code>e35fd6d</code></a>
ci: fix patch during clippy step (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7935">#7935</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/03fe44c10302fdb55c29dbe5b08d4f8769c80272"><code>03fe44c</code></a>
runtime: fix <code>event_interval</code> doc (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7932">#7932</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/d18e5dfbb0cdc28725bebb28cde80a6c11ee32bc"><code>d18e5df</code></a>
io: fix race in <code>Mock::poll_write</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7882">#7882</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/f21f2693f02aec9a876ac2bd21566c85e15b682e"><code>f21f269</code></a>
runtime: fix race condition during the blocking pool shutdown (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7922">#7922</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/d81e8f0acbdd7d866bce4f733b3545fd834c7840"><code>d81e8f0</code></a>
macros: remove (most) local <code>use</code> declarations in
<code>tokio::select!</code> (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7929">#7929</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/25e7f2641ef2555d688c267059431a2802805f1d"><code>25e7f26</code></a>
rt: fix missing quotation in docs (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7925">#7925</a>)</li>
<li><a
href="https://github.com/tokio-rs/tokio/commit/e1a91ef114a301b542d810abab9956f2868861b9"><code>e1a91ef</code></a>
util: fix typo in docs (<a
href="https://redirect.github.com/tokio-rs/tokio/issues/7926">#7926</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tokio-rs/tokio/compare/tokio-1.49.0...tokio-1.50.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 14:16:18 +01:00
dependabot[bot] 1787443102 build(deps): bump fast-uri from 3.0.6 to 3.1.2 in /devTools (#6332)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:15:08 +01:00
dependabot[bot] 8fb281525b build(deps): bump quinn-proto from 0.11.13 to 0.11.14 in /frontend/src-tauri (#6322)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:13:31 +01:00
Ludy 4c3be4992f deps: align Bouncy Castle modules to shared version property (#6362) 2026-05-20 14:11:43 +01:00
dependabot[bot] 2790467241 build(deps): bump urllib3 from 2.6.3 to 2.7.0 in /testing/cucumber in the pip group across 1 directory (#6352)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:11:11 +01:00
dependabot[bot] dd770dfa76 build(deps): bump rustls-webpki from 0.103.9 to 0.103.13 in /frontend/src-tauri (#6323)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 14:10:56 +01:00
ConnorYoh b146d9994d fix(task): make task dev / task dev:all work on Windows (#6392)
## Summary

`#6145` (port picker) and `#6244` (gradle unification) combined to break
`task dev` / `task dev:all` on Windows. Two independent regressions,
both addressed here.

### 1. `find-free-port.ps1` panic (`#6145`)

```
$ task --dry dev
The argument 'scriptsfind-free-port.ps1' to the -File parameter does not exist.
panic: ended up with a non-nil exitStatus.err but a zero exitStatus.code
```

- **Backslash stripped.** `Taskfile.yml` had
`scripts\find-free-port.ps1`. go-task pipes the `sh:` block through
mvdan/sh, which treats `\` as a POSIX escape and silently drops it,
leaving `scriptsfind-free-port.ps1`. PowerShell can't find the file,
exits non-zero, mvdan/sh panics on the inconsistent exit status.
Switched to a forward slash.
- **`-Preferred 8080,5173` is brittle.** Relies on PowerShell parsing
the comma-list into `[int[]]`. Dropped the named flag; switched the
script's `param` to `[Parameter(ValueFromRemainingArguments =
$true)][int[]]$Preferred`; pass each port as its own positional token
(matching how the bash variant is called).

### 2. `bash gradlew` can't find Java on Windows (`#6244`)

```
[backend:dev] ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
```

`#6244` unified all gradle invocations under `bash gradlew` on the
assumption that Git-Bash inherits Windows-side env. It doesn't (and
neither does WSL bash, which can also shadow `bash` on a developer's
PATH). The standard Adoptium/Temurin installer sets `JAVA_HOME` in the
Windows env only, so `bash gradlew` fails before Spring Boot even loads.

Restored the per-platform branch for every backend task: `cmd /c
".\gradlew.bat ..."` on Windows, `./gradlew ...` on Linux/macOS. Two
Windows-specific gotchas to be aware of for future edits:

- mvdan/sh strips `\` outside of double quotes, so `cmd /c
.\gradlew.bat` ends up as `.gradlew.bat`. The entire payload must be
wrapped in double quotes (`cmd /c "..."`).
- Modern Windows excludes cwd from cmd.exe's search path, so the leading
`.\` is required — bare `cmd /c gradlew.bat` errors with "is not
recognized" even from the repo root.

## Test plan

- [x] `task --dry dev` / `task --dry dev:all` on Windows resolve ports
cleanly and dispatch the inner tasks
- [x] `powershell -NoProfile -File scripts/find-free-port.ps1 8080 5173
5001` prints three ports
- [x] `task backend:dev PORT=8081` on Windows starts Spring Boot in ~9s;
`/api/v1/info/status` returns `{"status":"UP"}`
- [x] `task dev:all` on Windows brings up all three services healthy:
backend `/api/v1/info/status` 200, engine `/health` 200, frontend `/`
200
- [ ] Linux / macOS path unaffected (only the Windows branches changed
in behaviour)
2026-05-20 12:57:41 +00:00
Anthony Stirling 4b6d4885f4 sign-jpdfium-dylibs: resolve native-jar paths via jar tf first
`jar xf <jar> <path>` doesn't expand glob patterns in <path> the way
`unzip` does — paths must be exact. My initial script passed
`BOOT-INF/lib/jpdfium-natives-darwin-x64-*.jar` as a literal path,
which matched nothing, so the script always reported
"No JPDFium darwin natives in bootJar; nothing to sign" and exited
without actually signing anything.

Fix: `jar tf` to LIST the bootJar's contents, grep with a regex for
the snapshot-versioned native-jar paths, then `jar xf` those exact
paths.

The natives jars are normally:
  BOOT-INF/lib/jpdfium-natives-darwin-x64-1.0.0-SNAPSHOT.jar
  BOOT-INF/lib/jpdfium-natives-darwin-arm64-1.0.0-SNAPSHOT.jar
…but the version number floats with the snapshot timestamp, hence
the regex match instead of a fixed path.
2026-05-20 09:46:07 +01:00
Anthony Stirling 2a151b65f7 Move JPDFium dylib signing step AFTER cert import
My first placement put the sign-jpdfium-dylibs-in-bootjar step at
the wrong point in the workflow: BEFORE the "Verify Certificate"
step that sets APPLE_SIGNING_IDENTITY in GITHUB_ENV. So the gate
`if: ... && env.APPLE_SIGNING_IDENTITY != ''` always evaluated to
false and the step silently skipped, leaving the dylibs unsigned
and notarytool still rejecting the .app.

Move it to right after Verify Certificate (which sets the env var
from the keychain identity). Also switch the gate to checking
env.APPLE_CERTIFICATE (the secret that's set at job level and
available from step 1) rather than env.APPLE_SIGNING_IDENTITY (set
mid-workflow via GITHUB_ENV) — the latter is fine in `run:` blocks
but flaky in `if:` evaluation depending on GH Actions evaluation
timing.
2026-05-20 09:08:37 +01:00
Anthony Stirling 9458fcd0e2 Re-run CI against JPDFium with Windows ICU trim + harfbuzz-no-glib 2026-05-20 00:45:22 +01:00
Anthony Stirling 1e8c41425b Sign JPDFium dylibs in bootJar before Tauri notarization (macOS)
Tauri-build macos-universal has been failing notarytool because the
.dylib files inside the JPDFium native jars (jpdfium-natives-darwin-
x64-*.jar / -arm64-*.jar) ship unsigned — JPDFium's publish workflow
has no Apple Developer credentials, so it can't sign during publish.
Apple's notarytool walks INTO nested .jars in the .app and reports:

  "The binary is not signed."
  path: Stirling-PDF.zip/Stirling-PDF.app/Contents/Resources/libs/
   stirling-pdf-*.jar/BOOT-INF/lib/
   jpdfium-natives-darwin-x64-1.0.0-SNAPSHOT.jar/natives/darwin-x64/
   libjpdfium.dylib

Tauri's own codesign walk doesn't open .jars, so the fix has to
happen here before tauri-action runs. New step between
`task desktop:prepare` (builds bootJar) and `tauri-action` (builds
.app + notarizes):

  scripts/sign-jpdfium-dylibs-in-bootjar.sh
    1. jar xf bootJar BOOT-INF/lib/jpdfium-natives-darwin-*.jar
    2. for each native jar, explode it, codesign every .dylib with
       APPLE_SIGNING_IDENTITY + --options runtime + --timestamp
    3. jar cfM0 to repack the natives jar (stored, no deflate —
       matches Spring Boot's preferred layout)
    4. jar uf bootJar to replace the natives jars in the outer
       bootJar with the freshly-signed versions

Gated on macOS-15 + APPLE_SIGNING_IDENTITY being set, so PR builds
from forks (no secret) fall through and the existing
"binary not signed" failure persists — no regression vs current.
2026-05-19 23:32:09 +01:00
Anthony StirlingandConnorYoh 2399da6893 migrate exportUpdatedPages from bytes to stream (#6201)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2026-05-19 23:03:44 +01:00
EthanHealy01 2c15044dfa Snag/disable logo drag (#6393) 2026-05-19 18:13:58 +00:00
Anthony Stirling b3277a18c8 Re-run CI: pick up Rust-LTO-trimmed JPDFium snapshot 2026-05-19 18:56:29 +01:00
Anthony Stirling 5ca1586976 Re-run CI: pick up smaller (ICU+qpdf trim) JPDFium snapshot 2026-05-19 18:18:21 +01:00
Anthony Stirling ce47a4e3af Re-run CI: pick up ICU-trimmed JPDFium snapshot 2026-05-19 17:08:17 +01:00
ConnorYoh 31f6ea4b25 perf(frontend): stabilize hot-path context subscriptions to fix excessive rerenders (#6373)
## Summary

The frontend was rerendering excessively across many interactions —
typing, clicking tools, opening modals, toggling the sidebar — because
**multiple compounding ref-instability cascades defeated `memo()` checks
in hot paths**. This PR fixes the cascades structurally.

Seven focused commits, low-to-high blast radius:

1. `perf(contexts): memoize BannerContext provider value`
2. `perf(contexts): memoize CommentAuthor and ActiveDocument provider
values`
3. `perf(contexts): memoize AppConfigContext provider value`
4. `perf(useToolManagement): stop spreading tool entries to keep refs
stable` — **root-cause fix**
5. `refactor(ToolPicker): hoist module-scope styles and helpers`
6. `feat(ToolWorkflowContext): add ref-stable Actions and Data subset
contexts` — additive
7. `perf(tools): migrate hot consumers to slim contexts and wrap in
memo()`

(Plus `style: apply prettier formatting` for CI.)

## What was wrong

Whenever something high-up in the tree caused a render, a chain of
unstable references propagated downward and forced every `ToolButton` to
re-execute its full body (hooks, derived computations, hook
subscriptions to other contexts). The chain:

- **4 unstable Context providers** (`Banner`, `CommentAuthor`,
`ActiveDocument`, `AppConfig`) were passing fresh `value={{ … }}`
objects on every render. Every consumer rerendered on every ancestor
render.
- **`useToolManagement.toolRegistry`** spread `{...baseTool, name,
description}` — a no-op spread that manufactured a new tool object
identity on every memo recompute.
- **The big `ToolWorkflowContext`** (25+ fields including
`state.searchQuery`) rebuilt its entire value on every
keystroke/click/toggle, forcing every `useToolWorkflow()` consumer (~36
files) to rerender.
- **`useToolNavigation`** transitively subscribed every `ToolButton` to
the full workflow context.
- **`ToolButton` & `ToolPicker`** weren't `memo()`-wrapped, so nothing
checked.
- **`ToolPanel`** passed inline `onSelect={(id) =>
handleToolSelect(...)}` — fresh ref every render, defeats child
memoization.
- **`ToolPicker`** allocated inline styles / `[]` / `toTitleCase` inside
the function body — churned `useToolSections`'s internal memo.

## Interaction matrix — what improves

The PR fixes the underlying ref-stability problem; the same fix benefits
*every* interaction that previously triggered the cascade:

| Interaction | Before | After |
|---|---|---|
| **Typing in tool search** | All visible buttons rerender per keystroke
| Only buttons whose matched-text changes rerender |
| **Clicking a tool** | All 36 `useToolWorkflow()` consumers rerender |
Only previously-selected and newly-selected buttons rerender (via
`isSelected` prop) |
| **Toggling sidebar / panel mode / reader mode** | Every tool button
rerenders | Tool components stay still (slim context doesn't see UI
state) |
| **Switching workbench / navigation** | `handleToolSelect` identity
changes → cascades through `onSelect` props | Ref-stabilized in Actions
context. Identity stable. Children's memo bails |
| **Modal/dialog open/close** | AppConfig churns → every `useAppConfig`
consumer rerenders (ToolButton reads `premiumEnabled`) | AppConfig
memoized; consumers rerender only when config changes |
| **Banner show/hide** | BannerProvider value churns → every consumer
rerenders on any ancestor render | Memoized; AppLayout rerenders only
when banner content changes |
| **Any state update high in the tree** | Compounding cascade defeats
memo everywhere | Stable subscriptions; memo bails out |

## Evidence

Per-keystroke prop instability on `ToolButton` (cleanest measurable
signal, captured via custom memo comparators logging which prop refs
differ):

| | `tool` ref diffs | `onSelect` ref diffs | `matchedSynonym` value
diffs | Total |
|---|---|---|---|---|
| Before | 18 | 18 | 6 | **42** |
| After  | 0  | 0  | 6 | **6 (all legitimate)** |

→ **86% reduction** in spurious per-keystroke prop instability. The 6
remaining matched-synonym diffs are correct (different substring
highlighted per keystroke).

Context value rebuild counts during a keystroke (verified with
instrumented `useMemo` factories): `useToolWorkflowData=0`,
`useToolWorkflowActions=0`, `AppConfigContext=0`.

The same stabilization applies to click/toggle/modal interactions — they
were all driven by the same cascading invalidations.

## Honest caveat on render-count metrics

`React.Profiler` counts and function-body execution counts in **dev
mode** came back identical before vs after (StrictMode + concurrent
rendering + Mantine internal commits dominate the numbers). The PR's
value is measured against the **prop-stability signal** above, not
Profiler counts. Production builds — where StrictMode doesn't
double-render and Mantine internals aren't constantly committing — will
show memo bail out properly.

## Risk × benefit

| # | Commit | Risk | Benefit |
|---|--------|------|---------|
| 1 | BannerContext memo |  Trivial | 🟦 Small |
| 2 | CommentAuthor + ActiveDocument memo |  Trivial | 🟦 Small |
| 3 | AppConfig memo |  Trivial | 🟦 Moderate (wide consumer base) |
| 4 | useToolManagement spread removal |  Trivial | 🟥 **High (root
cause)** |
| 5 | ToolPicker hoist |  Trivial | 🟦 Small |
| 6 | ToolWorkflowContext split | 🟧 Low-Med | 🟥 **High (foundation)** |
| 7 | Hot consumer migration + memo | 🟧 Low-Med | 🟥 **High
(actualization)** |

Commit 6 introduces an invariant: ref-stabilized callbacks in the
Actions context must only be invoked from event handlers (post-commit),
never during render. All current call sites comply.

## Test plan

- [x] `npx playwright test --project=stubbed` — 145 / 6 skipped / 0
failed before and after.
- [x] Targeted regression: `main-dashboard`, `tool-search`, `navigation`
— 11/11 passing.
- [x] CI passing on commits (one infrastructure flake on
`docker-compose-tests` — "No space left on device" — unrelated;
rerunning).
- [ ] Manual sanity check in a dev build after merge.

## What this enables

The same Actions + Data subset-context pattern can be applied to
`FileContext`, `NavigationContext`, and other big contexts. The
foundation is in place.
2026-05-19 15:56:50 +00:00
Anthony Stirling 859a2d97c2 Always re-resolve JPDFium snapshots in CI
PR rerun against new JPDFium snapshots kept using Gradle's cached
versions from earlier runs. Gradle caches 'changing modules' (anything
ending in -SNAPSHOT) for 24h by default, and gh run rerun --failed
reuses the existing run's Gradle home — so even after publishing a
fresh com.stirling:jpdfium-natives-*:1.0.0-SNAPSHOT, the bootJar kept
embedding the old natives.

cacheChangingModulesFor 0 means every Gradle invocation re-checks the
snapshot's maven-metadata.xml and pulls a newer timestamped build if
present. cacheDynamicVersionsFor 0 does the same for non-snapshot
dynamic versions (e.g. '1.+'). Both are scoped to the
subprojects.configurations.all block alongside the existing security
force-versions.

Trade-off: every Gradle invocation in CI does a HEAD against Maven
Central per snapshot dep (4 natives + 1 main jpdfium = 5 small
requests). Negligible compared to a download. Reverts naturally when
JPDFium ships a tagged release.
2026-05-19 15:00:02 +01:00
Anthony Stirling f9fbc37800 add translations (#6390) 2026-05-19 11:25:13 +01:00
Anthony Stirling a422deecdb Re-add jpdfium-natives-darwin-x64 (Intel Mac) runtime dep
The publish-github-packages workflow's restored darwin-x64 build now
cross-compiles from macos-14 under Rosetta + a second Homebrew, so the
natives jar is available again. Adding it back to the bootJar classpath
lets the tauri-build macos-15 universal binary cover Intel Macs too.
2026-05-19 10:15:16 +01:00
Anthony Stirling 7f8f09c899 Native-access via bootJar manifest, not CLI flags everywhere
JDK 22+ honors an 'Enable-Native-Access' attribute on the executable
jar's manifest (JEP 472). Setting it once on the Spring Boot bootJar
removes the need to remember --enable-native-access=ALL-UNNAMED in:
  - Docker init script's JAVA_BASE_OPTS injection
  - Tauri Rust launcher's java_options vector
  - Anywhere else somebody runs the bootJar
…and also future-proofs against JDK 26's hard-fail behavior without
each launch point having to track the flag.

app/core/build.gradle: add 'Enable-Native-Access': 'ALL-UNNAMED' to the
bootJar manifest attributes block.

build.gradle (bootRun jvmArgs): keep --enable-native-access=ALL-UNNAMED
because bootRun launches from classfiles, not the bootJar — the
manifest mechanism doesn't apply to that codepath, only to 'java -jar'.

scripts/init-without-ocr.sh: drop the FFM injection.
frontend/src-tauri/src/commands/backend.rs: drop the CLI arg.
2026-05-18 21:35:06 +01:00
Anthony Stirling 96920b1186 build.gradle: modernJavaVersion 21 -> 25
This was the actual culprit behind 'JVM runtime version 21 vs 25' Gradle
variant-attribute mismatch errors during PR CI. modernJavaVersion drives:
- options.release on JavaCompile.configureEach across all subprojects
- restart-helper compile target

Even though sourceCompatibility / targetCompatibility were already bumped
to VERSION_25 in an earlier commit, --release 21 on javac overrode them
and made Gradle's compileClasspath declare org.gradle.jvm.version=21,
which then refused to resolve com.stirling:jpdfium:1.0.0-SNAPSHOT
(published as JVM 25).

Bumping modernJavaVersion to 25 aligns everything.
2026-05-18 19:09:28 +01:00
Anthony Stirling 3626319685 docs+ci: scrub remaining JDK 21 references
JPDFium's published artifacts target JVM 25 and Stirling-PDF's main
sources are now compiled with sourceCompatibility = VERSION_25, so JDK
21 is no longer supported. Update header comments and developer-facing
docs to reflect the hard floor.

- backend-build.yml header: 'JDK 21/25 \xc3\x97 spring-security' -> 'JDK 25 \xc3\x97 spring-security'
- AGENTS.md Important Notes: minimum 21 -> requires 25
- DeveloperGuide.md prerequisites + setup steps: drop 'or later, 25 recommended'
  language, say 'JDK 25' outright.
2026-05-18 19:07:11 +01:00
Anthony Stirling 755f270a31 ci: drop JDK 21 from backend build matrix
JPDFium's published artifacts carry org.gradle.jvm.version=25 (FFM is
finalized at JDK 22+; JPDFium picks 25 as the source target). Gradle's
variant attribute matching refuses to resolve the dep on JDK 21
targets, so the 21 cells in this matrix were guaranteed to fail.

Now that targetCompatibility is bumped to 25 across the project, JDK 25
is the only supported toolchain.
2026-05-18 19:04:59 +01:00
Anthony Stirling 6e1a7454ca Integrate Stirling-Tools JPDFium for PDF operations
This is a bring-up branch to validate JPDFium consumption across all
Stirling-PDF release targets (server jar, Docker, Tauri desktop). Open
as PR for cross-platform CI validation.

Source dep
- com.stirling:jpdfium:1.0.0-SNAPSHOT  (pulled from Maven Central snapshots,
  fully anonymous — no PAT or GitHub Packages auth needed)
- Per-platform natives: linux-x64, linux-arm64, darwin-arm64, windows-x64
  All four are added as runtimeOnly so the bootJar carries every platform
  and NativeLoader picks the right one at startup. The natives jars are
  hermetic — each bundles its bridge, PDFium component libs, and the
  third-party deps (pcre2/freetype/harfbuzz/icu/qpdf/pugixml/libunibreak),
  so no system package install is required at runtime.
- TODO follow-up: jpdfium-natives-darwin-x64 (Intel Mac) — dropped from
  the upstream publish matrix while cross-compile-on-macos-14 setup lands.

JDK 25 enforcement
- Bump root + subprojects from sourceCompatibility / targetCompatibility
  21 -> 25. JPDFium publishes with org.gradle.jvm.version=25 (FFM is final
  in JDK 22+; JPDFium picks 25 as the floor), and a 21-targeted Stirling-PDF
  jar would fail Gradle's variant attribute match at compile time.
- bootRun jvmArgs: add --enable-native-access=ALL-UNNAMED. JDK 25 warns
  on restricted FFM methods without it; JDK 26 will hard-fail.

Docker
- scripts/init-without-ocr.sh: prepend --enable-native-access=ALL-UNNAMED
  to JAVA_BASE_OPTS so the JAVA_TOOL_OPTIONS env var that the container
  sets includes it for the running JVM. Idempotent — won't duplicate if
  already present.

Tauri
- frontend/src-tauri/src/commands/backend.rs: the sidecar Java spawn now
  passes --enable-native-access=ALL-UNNAMED. The bundled jlink JRE's
  module list (desktop.yml JLINK_MODULES) already includes java.desktop
  and java.net.http which is everything JPDFium's module-info requires.

Smoke test
- app/common/src/test/java/stirling/software/common/jpdfium/JPDFiumSmokeTest.java
  Opens the existing common test resource example.pdf via PdfDocument.open
  and asserts a positive page count. Confirms the dep resolves, the right
  natives jar lands on the classpath, the bridge + PDFium component libs
  + third-party deps all load, and the FFM bindings parse a real PDF
  end-to-end. Runs as part of :common:test on each platform's CI runner.
2026-05-18 18:31:37 +01:00
Reece BrowneandConnorYoh 6b9567cf38 Split and delete forms (#6277)
Delete orphaned forms when removing pages and maintain forms correctly
when splitting

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2026-05-18 11:14:55 +00:00
c731d5fd5d UI redesign staging (#6149)
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-15 15:36:50 +01:00
James Brunton beb99e273b Improve edit agent's knowledge of tools (#6356)
# Description of Changes
Give Edit Agent access to descriptions of the request from the Java API.
This opens the door to us better documenting our Java APIs to give the
stirling engine better knowledge of what the various tools are and how
to use them.

Also improves the tool selection sub-agent to get the tool parameters
and descriptions so it can more intelligently decide which operations
should be used to fulfil the user's request. Also provides it more
encouragement to string together multiple operations if necessary.
2026-05-14 18:30:39 +00:00
EthanHealy01andJames Brunton ece1bb6865 Feature/pdf to markdown agent (#6271)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-14 16:20:45 +00:00
Ludy 5b9ef852ab ci: remove frontend validation PR comment after successful check (#6360)
# Description of Changes

- Added a GitHub Actions step to remove the frontend validation check
comment when the frontend check succeeds on pull requests.
- The step searches for an existing PR comment containing the `<!--
frontend-check -->` marker and deletes it if found.
- The change was made to keep pull request discussions clean by removing
stale frontend check comments once the validation passes.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-05-14 13:19:55 +00:00
James Brunton 672e81d286 Add ability for Stirling engine to reason across large documents (#6314)
# Description of Changes
Adds storage in the database for full document content alongside the RAG
content (and changes the service to `DocumentService` instead of
`RagService`). Then adds a generic capability that should be usable by
any agent (currently just used by the Question Agent) which allows the
agent to pull out the full contents of the doc, chunks it into various
sections that will fit in the context window, and then processes them in
parallel to create an intermediate result, and then processes the
intermediate result into a final answer. It will re-chunk as many times
as necessary to get the content small enough for the actual answer to be
analysed (I've tested on PDFs ~3500 pages long, which is well above the
context limit and requires maybe 3 rounds of compression to get an
answer).

The new full doc analysis stuff is heavier than the RAG lookup so both
remain. The agents should use RAG for targeted info and the chunked
reasoner for info that requires reading the full doc.
2026-05-14 13:19:38 +00:00
James Brunton 8abe734f0b Fix main frontend validation (#6361)
# Description of Changes
#6312 reformatted `tauri.conf.json` via the Gradle script, which
reformats the entire file to not match the Prettier style. This PR
reformats the file back to Prettier format and changes the script to
update the version number without reformatting the entire file.

To be honest I'm not a huge fan of updating the version number with
regexes but it'd be a fool's errand to try and get Gradle to output JSON
in Prettier format, and this seems simpler than shelling out to run
Prettier over the file after the version string has been updated. Any
better ideas, let me know.
2026-05-14 12:31:45 +00:00
stirlingbot[bot]andAnthony Stirling 8a59c10f42 Update Backend 3rd Party Licenses (#6312)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

---------

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-13 16:30:58 +01:00
Anthony Stirling d62f2ad3ed unoserver docker (#6328)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-05-12 13:22:15 +01:00
Anthony StirlingandJames Brunton f60a075443 Add Playwright/bootRun/test.sh tasks (#6244)
## Description

Consolidates Playwright running under cohesive Task namespaces, isolates
Playwright state from the developer's local working tree, and swaps CI's
frontend webserver from `vite` dev to `vite preview` against a pre-built
`dist/`.

### `e2e:*` namespace

Renames `.taskfiles/testing.yml` to `.taskfiles/e2e.yml` and
consolidates everything Playwright-related under one `e2e:` namespace:

- `e2e:stubbed` / `e2e:live` / `e2e:enterprise` / `e2e:cross-browser`:
project-specific runners
- `e2e:check` (no-Docker subset) and `e2e:check:all` (full)
- `e2e:oauth:up` / `:down`, `e2e:saml:up` / `:down`: symmetric lifecycle
for the keycloak compose stacks
- `e2e:install`: Playwright browser install
- `docker:test`: full Docker integration suite

The redundant `frontend:test:e2e:*` project shortcuts are removed. CI
workflows (`e2e-stubbed.yml`, `e2e-live.yml`, `build-enterprise.yml`,
`nightly.yml`) are updated to call the new task names.

### Isolated Playwright state

New `STIRLING_BASE_PATH` (and `-Dstirling.base-path=`) override in
`InstallationPathConfig` redirects the entire state tree (configs,
backups, customFiles, pipeline, logs) at startup. `task e2e:live` points
it at `.test-state/playwright/` (purged on every invocation) so the
suite never touches the developer's local DB, settings.yml or backups.

`task e2e:live` auto-spawns gradle, waits for `/api/v1/info/status` to
come up, runs Playwright, then tears down the whole backend process
tree.

### CI runs Playwright against `vite preview`

Builds the frontend up-front with `VITE_BUILD_FOR_PREVIEW=1` (forces
absolute base so deep SPA routes resolve `/assets/...`) and the
playwright `webServer` now uses `vite preview --port 5173 --strictPort`
in CI. Avoids the per-page on-demand transform cost that was blowing the
30s navigation timeout under `--workers=3` on
`all-tool-pages-load.spec.ts`. Local dev keeps `vite` dev for HMR.

### OAuth/SAML compose helpers

`start-oauth-test.sh` and `start-saml-test.sh` gain a `--license-key
<KEY>` (`-k`) flag so CI and scripted runs can skip the interactive
license prompt. `start-oauth-test.sh` also moves from `for arg in "$@"`
to a `while`-with-`shift` arg loop to support multi-arg flags
consistently with the SAML script.

### Backend gradlew unification

Drops the per-platform `cmd /c gradlew.bat` branches from `backend.yml`
and routes every gradle invocation through `bash gradlew`. Works
uniformly on Linux/macOS and Windows-with-Git-Bash.

### Compare.tsx flake fix (re-land of
[#6316](https://github.com/Stirling-Tools/Stirling-PDF/pull/6316))

Piggybacks Anthony's never-merged fix from #6316. Without it,
`e2e:stubbed` continues to flake under `--workers=3` on
`compare.spec.ts`'s second-upload case via a React "Maximum update depth
exceeded" infinite loop in the Compare auto-fill effect. CI traces from
recent failed runs match exactly; 10 local runs of `compare.spec.ts`
with `CI=1 --workers=3` pass cleanly with the fix applied.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-11 14:50:07 +00:00
James Brunton c059e13423 Fix desktop app overscrolling inappropriately (#6350)
# Description of Changes
Fix #6348
2026-05-11 14:31:00 +00:00
James BruntonandEthanHealy01 575684ee4b Add edit text support to stirling engine (#6245)
# Description of Changes
Hooks up the (alpha) PDF Editor backend to the AI engine Edit Agent via
an intermediary API which is easier for the agent to call. It suffers
from all the same issues that the PDF Editor does in actually editing
the text, but should also benefit from any fixes to that.

It also adds protection against the underlying tools misbehaving by
hanging, and fixes a hanging bug in the PDF Editor.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-11 09:57:41 +00:00
dependabot[bot] 294b616a63 build(deps): bump org.springframework.boot:spring-boot-dependencies from 4.0.5 to 4.0.6 (#6337)
Bumps
[org.springframework.boot:spring-boot-dependencies](https://github.com/spring-projects/spring-boot)
from 4.0.5 to 4.0.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/spring-projects/spring-boot/releases">org.springframework.boot:spring-boot-dependencies's
releases</a>.</em></p>
<blockquote>
<h2>v4.0.6</h2>
<h2>🐞 Bug Fixes</h2>
<ul>
<li>Default security is misconfigured when
spring-boot-actuator-autoconfigure is present and spring-boot-health is
not <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50188">#50188</a></li>
<li>Elasticsearch Rest5Client auto-configuration misconfigures
underlying HTTP client <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50187">#50187</a></li>
<li>ApplicationPidFileWriter does not handle symlinks correctly <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50185">#50185</a></li>
<li>RandomValuePropertySource is not suitable for secrets <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50183">#50183</a></li>
<li>Cassandra auto-configuration misconfigures CqlSessionBuilder <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50180">#50180</a></li>
<li>ApplicationTemp does not handle symlinks correctly <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50178">#50178</a></li>
<li>Remote DevTools performs comparison incorrectly <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50176">#50176</a></li>
<li>spring.rabbitmq.ssl.verify-hostname is applied inconsistently <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50174">#50174</a></li>
<li>Whole number values are ignored when configuring min and max
expected values and SLO boundaries for a distribution summary meter <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50077">#50077</a></li>
<li>Classic starters are missing several modules <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50071">#50071</a></li>
<li>Module spring-boot-resttestclient is missing from
spring-boot-starter-test-classic <a
href="https://redirect.github.com/spring-projects/spring-boot/pull/50069">#50069</a></li>
<li>Annotations like <code>@Ssl</code> don't work on <code>@Bean</code>
methods when using <code>@ServiceConnection</code> <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50064">#50064</a></li>
<li>EnversRevisionRepositoriesRegistrar should reuse
<code>@EnableEnversRepositories</code> rather than configuring the JPA
counterpart <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50039">#50039</a></li>
<li>WebFlux Cloud Foundry links endpoint includes query string from
received request in resolved links <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50017">#50017</a></li>
<li>Imports on a containing test class are ignored when a nested class
has imports <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50012">#50012</a></li>
<li>With spring.jackson.use-jackson2-defaults set to true,
FAIL_ON_UNKNOWN_PROPERTIES is enabled <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49951">#49951</a></li>
<li>500 response from env endpoint when supplied pattern is invalid <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49946">#49946</a></li>
<li>Reactive MongoDB starter has a transitive dependency on the
synchronous MongoDB driver <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49945">#49945</a></li>
<li>HTTP method is lost when configuring excludes in EndpointRequest <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49943">#49943</a></li>
<li>Honor HttpMethod for reactive additional endpoint paths <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49880">#49880</a></li>
<li>Docker Compose support doesn't work with apache/artemis image <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49869">#49869</a></li>
<li>Docker Compose support doesn't work with apache/activemq image <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49866">#49866</a></li>
<li>Spring Security's PathPatternRequestMatcher.Builder is not
auto-configured when using WebMvcTest and spring-boot-security-test <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49854">#49854</a></li>
<li>API versioning path strategy should be applied path last as it is
not meant to yield <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49800">#49800</a></li>
</ul>
<h2>📔 Documentation</h2>
<ul>
<li>Update docs to encourage Java fundamentals for beginners that prefer
to learn that way <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50146">#50146</a></li>
<li>HTTP Service Interface Clients still document that API versioning
can be configured via properties <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50126">#50126</a></li>
<li>Link to the observability section of the Lettuce documentation is
broken <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50097">#50097</a></li>
<li>Javadoc for StaticResourceLocation.FAVICON doesn't describe icons
location <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50085">#50085</a></li>
<li>MySamlRelyingPartyConfiguration is missing a Kotlin sample <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50024">#50024</a></li>
<li>Incorrect default value for
management.httpexchanges.recording.include in configuration metadata <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50019">#50019</a></li>
<li>Link to the Kubernetes documentation when discussing startup probes
<a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50015">#50015</a></li>
<li>Typo in JdbcSessionAutoConfiguration Javadoc <a
href="https://redirect.github.com/spring-projects/spring-boot/pull/49873">#49873</a></li>
<li>Clarify that configuration property default values are not available
through the Environment <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49851">#49851</a></li>
<li>Document the need for Liquibase and Flyway starters <a
href="https://redirect.github.com/spring-projects/spring-boot/pull/49839">#49839</a></li>
<li>Kafka documentation refers to deprecated JSON serializer and
deserializer classes <a
href="https://redirect.github.com/spring-projects/spring-boot/pull/49826">#49826</a></li>
</ul>
<h2>🔨 Dependency Upgrades</h2>
<ul>
<li>Upgrade to Elasticsearch Client 9.2.8 <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50027">#50027</a></li>
<li>Upgrade to Groovy 5.0.5 <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49911">#49911</a></li>
<li>Upgrade to Hibernate 7.2.12.Final <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50134">#50134</a></li>
<li>Upgrade to Jackson Bom 3.1.2 <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50051">#50051</a></li>
<li>Upgrade to <a
href="https://github.com/jaxen-xpath/jaxen/releases/tag/v2.0.1">Jaxen
2.0.1</a> <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/50104">#50104</a></li>
<li>Upgrade to <a
href="https://github.com/FirebirdSQL/jaybird/releases/tag/v6.0.5">Jaybird
6.0.5</a> <a
href="https://redirect.github.com/spring-projects/spring-boot/issues/49914">#49914</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/8821ad2cd381bb4b9615a61479e1de7305a8ba39"><code>8821ad2</code></a>
Release v4.0.6</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/9e4048a03f17adfe78057a3c4d5b4693305c0ae0"><code>9e4048a</code></a>
Merge branch '3.5.x' into 4.0.x</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/20bb11c3984802990572ddbeae8b66885a8f2462"><code>20bb11c</code></a>
Next development version (v3.5.15-SNAPSHOT)</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/98daa8ea30f39a5b0ca6768b5cbc2dc8698ef4e1"><code>98daa8e</code></a>
Merge branch '3.5.x' into 4.0.x</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/9dc5aa2863f598a15d3dfa116f4b89249daba7e7"><code>9dc5aa2</code></a>
Polish</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/874f6294b91da18367b8b5ab7b2fad3fa23cfba6"><code>874f629</code></a>
Fix default security with actuator but without health</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/e41b3bf731d1134bc18ec1f68ac01e0fe1c54923"><code>e41b3bf</code></a>
Enable hostname verification for SSL connections to Elasticsearch</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/ef8527bb0ef8f564f4f9c57a7be99a7aa96c6ab0"><code>ef8527b</code></a>
Merge branch '3.5.x' into 4.0.x</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/f533a4549c3999aac30cb5830f07dc304933e93d"><code>f533a45</code></a>
Do not follow symlinks when writing PID file</li>
<li><a
href="https://github.com/spring-projects/spring-boot/commit/4a7bd332b6d19fef1aa4cf28434985f2b03a2e0f"><code>4a7bd33</code></a>
Merge branch '3.5.x' into 4.0.x</li>
<li>Additional commits viewable in <a
href="https://github.com/spring-projects/spring-boot/compare/v4.0.5...v4.0.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.springframework.boot:spring-boot-dependencies&package-manager=gradle&previous-version=4.0.5&new-version=4.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:52:41 +01:00
Ludy f61121953e fix: replace deprecated payload too large status (#6336)
# Description of Changes

- Replaced the deprecated `HttpStatus.PAYLOAD_TOO_LARGE` usage with
`HttpStatus.CONTENT_TOO_LARGE` in `GlobalExceptionHandler`
- Updated the upload-size exception response creation and response
status to use the newer HTTP 413 enum
- Updated related unit tests to assert `HttpStatus.CONTENT_TOO_LARGE`
- The change was made to avoid using the deprecated Spring HTTP status
enum while keeping the returned status code behavior unchanged


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-05-11 10:38:19 +01:00
dependabot[bot] 8645280ee5 build(deps): bump reportlab from 4.4.10 to 4.5.0 in /testing/cucumber (#6338)
Bumps [reportlab](https://www.reportlab.com/) from 4.4.10 to 4.5.0.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=reportlab&package-manager=pip&previous-version=4.4.10&new-version=4.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:37:28 +01:00
dependabot[bot] bd50e2e59d build(deps): bump commons-io:commons-io from 2.21.0 to 2.22.0 in /app/core (#6344)
Bumps commons-io:commons-io from 2.21.0 to 2.22.0.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=commons-io:commons-io&package-manager=gradle&previous-version=2.21.0&new-version=2.22.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:37:09 +01:00
dependabot[bot] 77abaf5de5 build(deps): bump actions/cache from 5.0.4 to 5.0.5 (#6349)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to
5.0.5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/releases">actions/cache's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.5</h2>
<h2>What's Changed</h2>
<ul>
<li>Update ts-http-runtime dependency by <a
href="https://github.com/yacaovsnc"><code>@​yacaovsnc</code></a> in <a
href="https://redirect.github.com/actions/cache/pull/1747">actions/cache#1747</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/cache/compare/v5...v5.0.5">https://github.com/actions/cache/compare/v5...v5.0.5</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/cache/blob/main/RELEASES.md">actions/cache's
changelog</a>.</em></p>
<blockquote>
<h1>Releases</h1>
<h2>How to prepare a release</h2>
<blockquote>
<p>[!NOTE]<br />
Relevant for maintainers with write access only.</p>
</blockquote>
<ol>
<li>Switch to a new branch from <code>main</code>.</li>
<li>Run <code>npm test</code> to ensure all tests are passing.</li>
<li>Update the version in <a
href="https://github.com/actions/cache/blob/main/package.json"><code>https://github.com/actions/cache/blob/main/package.json</code></a>.</li>
<li>Run <code>npm run build</code> to update the compiled files.</li>
<li>Update this <a
href="https://github.com/actions/cache/blob/main/RELEASES.md"><code>https://github.com/actions/cache/blob/main/RELEASES.md</code></a>
with the new version and changes in the <code>## Changelog</code>
section.</li>
<li>Run <code>licensed cache</code> to update the license report.</li>
<li>Run <code>licensed status</code> and resolve any warnings by
updating the <a
href="https://github.com/actions/cache/blob/main/.licensed.yml"><code>https://github.com/actions/cache/blob/main/.licensed.yml</code></a>
file with the exceptions.</li>
<li>Commit your changes and push your branch upstream.</li>
<li>Open a pull request against <code>main</code> and get it reviewed
and merged.</li>
<li>Draft a new release <a
href="https://github.com/actions/cache/releases">https://github.com/actions/cache/releases</a>
use the same version number used in <code>package.json</code>
<ol>
<li>Create a new tag with the version number.</li>
<li>Auto generate release notes and update them to match the changes you
made in <code>RELEASES.md</code>.</li>
<li>Toggle the set as the latest release option.</li>
<li>Publish the release.</li>
</ol>
</li>
<li>Navigate to <a
href="https://github.com/actions/cache/actions/workflows/release-new-action-version.yml">https://github.com/actions/cache/actions/workflows/release-new-action-version.yml</a>
<ol>
<li>There should be a workflow run queued with the same version
number.</li>
<li>Approve the run to publish the new version and update the major tags
for this action.</li>
</ol>
</li>
</ol>
<h2>Changelog</h2>
<h3>5.0.4</h3>
<ul>
<li>Bump <code>minimatch</code> to v3.1.5 (fixes ReDoS via globstar
patterns)</li>
<li>Bump <code>undici</code> to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)</li>
<li>Bump <code>fast-xml-parser</code> to v5.5.6</li>
</ul>
<h3>5.0.3</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.5 (Resolves: <a
href="https://github.com/actions/cache/security/dependabot/33">https://github.com/actions/cache/security/dependabot/33</a>)</li>
<li>Bump <code>@actions/core</code> to v2.0.3</li>
</ul>
<h3>5.0.2</h3>
<ul>
<li>Bump <code>@actions/cache</code> to v5.0.3 <a
href="https://redirect.github.com/actions/cache/pull/1692">#1692</a></li>
</ul>
<h3>5.0.1</h3>
<ul>
<li>Update <code>@azure/storage-blob</code> to <code>^12.29.1</code> via
<code>@actions/cache@5.0.1</code> <a
href="https://redirect.github.com/actions/cache/pull/1685">#1685</a></li>
</ul>
<h3>5.0.0</h3>
<blockquote>
<p>[!IMPORTANT]
<code>actions/cache@v5</code> runs on the Node.js 24 runtime and
requires a minimum Actions Runner version of <code>2.327.1</code>.</p>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/cache/commit/27d5ce7f107fe9357f9df03efb73ab90386fccae"><code>27d5ce7</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/cache/issues/1747">#1747</a>
from actions/yacaovsnc/update-dependency</li>
<li><a
href="https://github.com/actions/cache/commit/f280785d7b6e1884c7d12b9136eb0f4a1574fcfd"><code>f280785</code></a>
licensed changes</li>
<li><a
href="https://github.com/actions/cache/commit/619aeb1606e195be0b36fd0ff68dcf1aff6b65a7"><code>619aeb1</code></a>
npm run build generated dist files</li>
<li><a
href="https://github.com/actions/cache/commit/bcf16c2893940a4899761e55c7ac3c1cf88a04f6"><code>bcf16c2</code></a>
Update ts-http-runtime to 0.3.5</li>
<li>See full diff in <a
href="https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/cache&package-manager=github_actions&previous-version=5.0.4&new-version=5.0.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:36:38 +01:00
dependabot[bot] 503a81be50 build(deps): bump KSXGitHub/github-actions-deploy-aur from 4.1.2 to 4.1.3 (#6347)
Bumps
[KSXGitHub/github-actions-deploy-aur](https://github.com/ksxgithub/github-actions-deploy-aur)
from 4.1.2 to 4.1.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ksxgithub/github-actions-deploy-aur/releases">KSXGitHub/github-actions-deploy-aur's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.3</h2>
<p>There is a bug in <code>runuser</code> that converts all
<code>-c</code> (even after <code>--</code>) into <code>--command</code>
which <code>bash</code> doesn't recognize. This release removes the
<code>-c</code> flag entirely, bash would execute <code>/build.sh</code>
as if it's a file. Hopefully, the behavior preserves. If not, maybe just
switch to <code>su</code> or <code>sudo</code>.</p>
<p>Relevant PR: <a
href="https://redirect.github.com/KSXGitHub/github-actions-deploy-aur/pull/51">KSXGitHub/github-actions-deploy-aur#51</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/KSXGitHub/github-actions-deploy-aur/commit/da03e160361ce01bf087e790b6ffd196d7dccff7"><code>da03e16</code></a>
fix: <code>bash: --command: invalid option</code> (<a
href="https://redirect.github.com/ksxgithub/github-actions-deploy-aur/issues/51">#51</a>)</li>
<li><a
href="https://github.com/KSXGitHub/github-actions-deploy-aur/commit/3b403c740ae5e446b747b45451ec68665428dab1"><code>3b403c7</code></a>
docs(readme): use the GitHub's note syntax</li>
<li><a
href="https://github.com/KSXGitHub/github-actions-deploy-aur/commit/e17cd797381bddd766236d808302398b090398d2"><code>e17cd79</code></a>
docs(readme): remove patreon</li>
<li>See full diff in <a
href="https://github.com/ksxgithub/github-actions-deploy-aur/compare/v4.1.2...v4.1.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=KSXGitHub/github-actions-deploy-aur&package-manager=github_actions&previous-version=4.1.2&new-version=4.1.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:36:31 +01:00
dependabot[bot] 67fc5c70b9 build(deps): bump github/codeql-action from 4.35.2 to 4.35.3 (#6343)
Bumps [github/codeql-action](https://github.com/github/codeql-action)
from 4.35.2 to 4.35.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.35.3</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.19.3 and earlier. These versions of
CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise
Server 3.15, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3837">#3837</a></li>
<li>Configurations for private registries that use Cloudsmith or GCP
OIDC are now accepted. <a
href="https://redirect.github.com/github/codeql-action/pull/3850">#3850</a></li>
<li>Best-effort connection tests for private registries now use
<code>GET</code> requests instead of <code>HEAD</code> for better
compatibility with various registry implementations. For NuGet feeds,
the test is now always performed against the service index. <a
href="https://redirect.github.com/github/codeql-action/pull/3853">#3853</a></li>
<li>Fixed a bug where two diagnostics produced within the same
millisecond could overwrite each other on disk, causing one of them to
be lost. <a
href="https://redirect.github.com/github/codeql-action/pull/3852">#3852</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.3">2.25.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3865">#3865</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.19.3 and earlier. These versions of
CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise
Server 3.15, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3837">#3837</a></li>
<li>Configurations for private registries that use Cloudsmith or GCP
OIDC are now accepted. <a
href="https://redirect.github.com/github/codeql-action/pull/3850">#3850</a></li>
<li>Best-effort connection tests for private registries now use
<code>GET</code> requests instead of <code>HEAD</code> for better
compatibility with various registry implementations. For NuGet feeds,
the test is now always performed against the service index. <a
href="https://redirect.github.com/github/codeql-action/pull/3853">#3853</a></li>
<li>Fixed a bug where two diagnostics produced within the same
millisecond could overwrite each other on disk, causing one of them to
be lost. <a
href="https://redirect.github.com/github/codeql-action/pull/3852">#3852</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.3">2.25.3</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3865">#3865</a></li>
</ul>
<h2>4.35.2 - 15 Apr 2026</h2>
<ul>
<li>The undocumented TRAP cache cleanup feature that could be enabled
using the <code>CODEQL_ACTION_CLEANUP_TRAP_CACHES</code> environment
variable is deprecated and will be removed in May 2026. If you are
affected by this, we recommend disabling TRAP caching by passing the
<code>trap-caching: false</code> input to the <code>init</code> Action.
<a
href="https://redirect.github.com/github/codeql-action/pull/3795">#3795</a></li>
<li>The Git version 2.36.0 requirement for improved incremental analysis
now only applies to repositories that contain submodules. <a
href="https://redirect.github.com/github/codeql-action/pull/3789">#3789</a></li>
<li>Python analysis on GHES no longer extracts the standard library,
relying instead on models of the standard library. This should result in
significantly faster extraction and analysis times, while the effect on
alerts should be minimal. <a
href="https://redirect.github.com/github/codeql-action/pull/3794">#3794</a></li>
<li>Fixed a bug in the validation of OIDC configurations for private
registries that was added in CodeQL Action 4.33.0 / 3.33.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3807">#3807</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.2">2.25.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3823">#3823</a></li>
</ul>
<h2>4.35.1 - 27 Mar 2026</h2>
<ul>
<li>Fix incorrect minimum required Git version for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a>: it should have been 2.36.0, not 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3781">#3781</a></li>
</ul>
<h2>4.35.0 - 27 Mar 2026</h2>
<ul>
<li>Reduced the minimum Git version required for <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> from 2.38.0 to 2.11.0. <a
href="https://redirect.github.com/github/codeql-action/pull/3767">#3767</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.1">2.25.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3773">#3773</a></li>
</ul>
<h2>4.34.1 - 20 Mar 2026</h2>
<ul>
<li>Downgrade default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.24.3">2.24.3</a>
due to issues with a small percentage of Actions and JavaScript
analyses. <a
href="https://redirect.github.com/github/codeql-action/pull/3762">#3762</a></li>
</ul>
<h2>4.34.0 - 20 Mar 2026</h2>
<ul>
<li>Added an experimental change which disables TRAP caching when <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> is enabled, since improved incremental analysis
supersedes TRAP caching. This will improve performance and reduce
Actions cache usage. We expect to roll this change out to everyone in
March. <a
href="https://redirect.github.com/github/codeql-action/pull/3569">#3569</a></li>
<li>We are rolling out improved incremental analysis to C/C++ analyses
that use build mode <code>none</code>. We expect this rollout to be
complete by the end of April 2026. <a
href="https://redirect.github.com/github/codeql-action/pull/3584">#3584</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.0">2.25.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3585">#3585</a></li>
</ul>
<h2>4.33.0 - 16 Mar 2026</h2>
<ul>
<li>Upcoming change: Starting April 2026, the CodeQL Action will skip
collecting file coverage information on pull requests to improve
analysis performance. File coverage information will still be computed
on non-PR analyses. Pull request analyses will log a warning about this
upcoming change. <a
href="https://redirect.github.com/github/codeql-action/pull/3562">#3562</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e46ed2cbd01164d986452f91f178727624ae40d7"><code>e46ed2c</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3867">#3867</a>
from github/update-v4.35.3-8c6e48dbe</li>
<li><a
href="https://github.com/github/codeql-action/commit/b73d1d163446ca5e62b96698027210ab41df6a4a"><code>b73d1d1</code></a>
Add changelog entry for <a
href="https://redirect.github.com/github/codeql-action/issues/3853">#3853</a></li>
<li><a
href="https://github.com/github/codeql-action/commit/24e0bb00a931e2a5edb703ce3b22a70f3a3e800b"><code>24e0bb0</code></a>
Reorder changelog entries</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec298daba71cf7592feacbd1c0887cddc0659f62"><code>ec298da</code></a>
Update changelog for v4.35.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/8c6e48dbe051ceb3015c19554831af1b43275f46"><code>8c6e48d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3865">#3865</a>
from github/update-bundle/codeql-bundle-v2.25.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/719098349ea5beae8aa364bf9b71ff1c8d937df2"><code>7190983</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/2bb209555a024d051f6271c8a846b402497f9445"><code>2bb2095</code></a>
Update default bundle to codeql-bundle-v2.25.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/7851e55dc3be31ec4bcc3ef98453de2cb306e698"><code>7851e55</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3850">#3850</a>
from github/mbg/private-registry/cloudsmith-gcp</li>
<li><a
href="https://github.com/github/codeql-action/commit/262a15f6cf4c7a43d6a38ad76392e5e2d4977751"><code>262a15f</code></a>
Add generic non-printable chars test for OIDC configs</li>
<li><a
href="https://github.com/github/codeql-action/commit/a6109b1c07173a53ece3d179a925ff9644d1fabd"><code>a6109b1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3853">#3853</a>
from github/mbg/start-proxy/improved-checks</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...e46ed2cbd01164d986452f91f178727624ae40d7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.35.2&new-version=4.35.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:36:16 +01:00
dependabot[bot] 1e6514a471 build(deps): bump org.projectlombok:lombok from 1.18.44 to 1.18.46 (#6342)
Bumps
[org.projectlombok:lombok](https://github.com/projectlombok/lombok) from
1.18.44 to 1.18.46.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown">org.projectlombok:lombok's
changelog</a>.</em></p>
<blockquote>
<h3>v1.18.46 (April 22nd, 2026)</h3>
<ul>
<li>PLATFORM: JDK26 support added <a
href="https://redirect.github.com/projectlombok/lombok/issues/4019">#4019</a>.</li>
<li>PLATFORM: Spring Tools Suite 5 supported <a
href="https://redirect.github.com/projectlombok/lombok/issues/3985">#3985</a>.</li>
<li>BUGFIX: <code>@Jacksonized</code> no longer stops generating
<code>@JsonProperty</code> once an explicit <code>@JsonIgnore</code>
annotations is encountered <a
href="https://redirect.github.com/projectlombok/lombok/issues/4022">#4022</a>.</li>
<li>BUGFIX: In eclipse, mixing <code>@Jacksonized</code> and
<code>fluent = true</code> no longer causes the error
<code>com.fasterxml.jackson.annotation.JsonProperty is not a repeatable
annotation interface</code>. <a
href="https://redirect.github.com/projectlombok/lombok/issues/3934">#3934</a>.</li>
<li>BUGFIX: Some finishing touches for v1.18.44's support of Jackson3 <a
href="https://redirect.github.com/projectlombok/lombok/issues/4004">#4004</a>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/projectlombok/lombok/commit/936ca59baf844fd6c0ad641974295498785d8091"><code>936ca59</code></a>
[build] lombok's launcher is still intended to be 1.4 compatible, or at
least...</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/fcdab3f29e1b48c8f4b33ef9231ec2587a43d122"><code>fcdab3f</code></a>
[version] pre-release version bump</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/1cb7d49c5d2dc98af7a66413d8119dec285d0666"><code>1cb7d49</code></a>
[changelog]<a
href="https://redirect.github.com/projectlombok/lombok/issues/4004">#4004</a>
Mention Jackson3 final touches in changelog.</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/12a15b00555ec8097eca2bf7d77c2c2124e13e0e"><code>12a15b0</code></a>
Fix: Bump EA_JDK to 27 (25 and 26 have been released)</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/2be766cfc2ef56f2d986f28f734c98535d611aee"><code>2be766c</code></a>
Merge branch 'jackson3-final-touches'</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/290fa4c8539c7e97b47f7e80033e078127050eb5"><code>290fa4c</code></a>
[trivial] constantize the warning we spit out for ambiguous jackson2/3,
and m...</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/e6567b6621f86b43033ab4a75e0273780e18e998"><code>e6567b6</code></a>
test: Add Jackson 3 test cases and version ambiguity warnings</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/45e72e241abe98dcfb66408402da825dd2b8e925"><code>45e72e2</code></a>
feat: Add Jackson 3 databind/dataformat annotations to HandlerUtil copy
lists</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/184d42363d86446a63b6270ac1eb352dc43ae76c"><code>184d423</code></a>
feat: Add Jackson 3 support to <a
href="https://github.com/Jacksonized"><code>@​Jacksonized</code></a>
handlers</li>
<li><a
href="https://github.com/projectlombok/lombok/commit/e027ad0f1515bd33d4d329d90e59dccbaf44651e"><code>e027ad0</code></a>
refactored to ShadowClassLoader use Collections::enumeration instead of
Vector</li>
<li>Additional commits viewable in <a
href="https://github.com/projectlombok/lombok/compare/v1.18.44...v1.18.46">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.projectlombok:lombok&package-manager=gradle&previous-version=1.18.44&new-version=1.18.46)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:36:04 +01:00
dependabot[bot] c4874deb5d build(deps): bump step-security/harden-runner from 2.19.0 to 2.19.1 (#6341)
Bumps
[step-security/harden-runner](https://github.com/step-security/harden-runner)
from 2.19.0 to 2.19.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/step-security/harden-runner/releases">step-security/harden-runner's
releases</a>.</em></p>
<blockquote>
<h2>v2.19.1</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: detect ubuntu-slim runners early and bail out by <a
href="https://github.com/devantler"><code>@​devantler</code></a> in <a
href="https://redirect.github.com/step-security/harden-runner/pull/657">step-security/harden-runner#657</a></li>
</ul>
<p>What the fix changes</p>
<ul>
<li>Harden-Runner will detect <code>ubuntu-slim</code> runners and exit
cleanly with an informational log message, instead of post harden runner
step failing on chown: invalid user: 'undefined'.</li>
</ul>
<p>What the fix does not do</p>
<ul>
<li>Jobs running on <code>ubuntu-slim</code> will not be monitored by
Harden-Runner. The agent relies on kernel-level features (that require
elevated capabilities).</li>
<li>Per GitHub's docs on <a
href="https://docs.github.com/en/actions/reference/runners/github-hosted-runners#single-cpu-runners">single-CPU
runners</a>: &quot;The container for ubuntu-slim runners runs in
unprivileged mode. This means that some operations requiring elevated
privileges such as mounting file systems, using Docker-in-Docker, or
accessing low-level kernel features are not supported.&quot; Those
low-level kernel features are what the agent needs, so monitoring inside
the unprivileged container is not feasible today.</li>
</ul>
<p>For StepSecurity enterprise customers
If your security posture requires that workflows are always monitored,
you can block the use of <code>ubuntu-slim</code> via workflow run
policies see the <a
href="https://docs.stepsecurity.io/workflow-run-policies/policies#runner-label-policy">Runner
Label Policy</a> docs. This lets you enforce that jobs only run on
monitored runner types.</p>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/devantler"><code>@​devantler</code></a>
made their first contribution in <a
href="https://redirect.github.com/step-security/harden-runner/pull/657">step-security/harden-runner#657</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.19.0...v2.19.1">https://github.com/step-security/harden-runner/compare/v2.19.0...v2.19.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/step-security/harden-runner/commit/a5ad31d6a139d249332a2605b85202e8c0b78450"><code>a5ad31d</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/657">#657</a>
from devantler/fix/ubuntu-slim-user-env</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/6e928567d74554b8842dd434908da31c593ba85c"><code>6e92856</code></a>
build dist and trim ubuntu-slim message</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/4e0504ee086374bdec7064e5c26d48af41ba6209"><code>4e0504e</code></a>
Merge branch 'main' into fix/ubuntu-slim-user-env</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/376d25a97f3a1640ff8cbbddaa4af25948df2cf3"><code>376d25a</code></a>
fix: detect ubuntu-slim runners early and bail out</li>
<li>See full diff in <a
href="https://github.com/step-security/harden-runner/compare/8d3c67de8e2fe68ef647c8db1e6a09f647780f40...a5ad31d6a139d249332a2605b85202e8c0b78450">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.19.0&new-version=2.19.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 10:35:44 +01:00
James Brunton 4ee47fdddd Fix Task commands in Powershell (#6330) 2026-05-08 10:47:36 +01:00
James Brunton 270af912d4 Fix frontend download links pointing to split Mac binaries (#6325) 2026-05-07 20:25:01 +01:00
Anthony Stirling efd2ed754c Enhance automate to have import and export buttons (#6326) 2026-05-07 20:23:11 +01:00
ConnorYohandJames Brunton 6730ad7cbb Desktop: persist auth token to disk when Credential Manager is restricted (#6303)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-07 16:02:48 +01:00
James Brunton 8fb9fced35 Convert to building a universal Mac app (#6302) 2026-05-07 14:26:28 +01:00
Reece BrowneandEthanHealy01 ed56490f93 Fix move button on multitool (#6291)
Hitting move button twice on multitool didn't work

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-05 20:46:47 +00:00
dependabot[bot]andAnthony Stirling c892b985e2 build(deps): bump springSecuritySamlVersion from 7.0.4 to 7.0.5 (#6255)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 19:01:06 +01:00
James Brunton 95c39b4648 Fix missing desktop save indicator on files (#6310)
# Description of Changes
Save indicator stopped showing up after #6050, which fixed the missing
truncation on filenames, but accidentally bypassed the save indicator
component at the same time. This PR puts the component back in and makes
it support truncation so we can have both.

<img width="586" height="166" alt="image"
src="https://github.com/user-attachments/assets/529c3dcb-ee00-4a6d-ae53-ef8657204369"
/>
2026-05-05 11:19:11 +00:00
dependabot[bot] 44f2d0d679 build(deps): bump the embedpdf group across 1 directory with 23 updates (#6299)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 12:15:01 +01:00
dependabot[bot]andAnthony Stirling 1a63aa045e build(deps): bump the tauri group across 1 directory with 7 updates (#6305)
Bumps the tauri group with 5 updates in the /frontend/src-tauri
directory:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.10.2` | `2.10.3` |
| [tauri-plugin-fs](https://github.com/tauri-apps/plugins-workspace) |
`2.4.5` | `2.5.0` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace)
| `2.6.0` | `2.7.0` |
| [tauri-plugin-http](https://github.com/tauri-apps/plugins-workspace) |
`2.5.7` | `2.5.8` |
|
[tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace)
| `2.4.0` | `2.4.1` |


Updates `tauri` from 2.10.2 to 2.10.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/tauri/releases">tauri's
releases</a>.</em></p>
<blockquote>
<h2>tauri v2.10.3</h2>
<!-- raw HTML omitted -->
<pre><code>Fetching advisory database from
`https://github.com/RustSec/advisory-db.git`
Loaded 941 security advisories (from /home/runner/.cargo/advisory-db)
    Updating crates.io index
    Scanning Cargo.lock for vulnerabilities (1052 crate dependencies)
Crate:     atk
Version:   0.18.2
Warning:   unmaintained
Title:     gtk-rs GTK3 bindings - no longer maintained
Date:      2024-03-04
ID:        RUSTSEC-2024-0413
URL:       https://rustsec.org/advisories/RUSTSEC-2024-0413
Dependency tree:
atk 0.18.2
└── gtk 0.18.2
    ├── wry 0.54.0
    │   └── tauri-runtime-wry 2.10.1
    │       └── tauri 2.10.3
    │           ├── tauri-utils 2.8.3
    │           │   ├── tauri-schema-generator 0.0.0
    │           │   ├── tauri-runtime-wry 2.10.1
    │           │   ├── tauri-runtime 2.10.1
    │           │   │   ├── tauri-runtime-wry 2.10.1
    │           │   │   └── tauri 2.10.3
    │           │   ├── tauri-plugin 2.5.4
    │           │   │   ├── tauri-plugin-sample 0.1.0
    │           │   │   │   └── api 0.1.0
    │           │   │   └── tauri-plugin-log 2.6.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-macros 2.5.5
    │           │   │   └── tauri 2.10.3
    │           │   ├── tauri-codegen 2.5.5
    │           │   │   ├── tauri-macros 2.5.5
    │           │   │   └── tauri-build 2.5.6
    │           │   │       ├── tauri-file-associations-demo 0.1.0
    │           │   │       ├── tauri 2.10.3
    │           │   │       ├── resources 0.1.0
    │           │   │       ├── bench_helloworld 0.1.0
    │           │   │       ├── bench_files_transfer 0.1.0
    │           │   │       ├── bench_cpu_intensive 0.1.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-cli 2.10.1
    │           │   │   └── tauri-cli-node 0.0.0
    │           │   ├── tauri-bundler 2.8.1
    │           │   │   └── tauri-cli 2.10.1
    │           │   ├── tauri-build 2.5.6
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/tauri/commit/9b17a7aeae9a83222ffe829aa4e2d8a5ba6bed8c"><code>9b17a7a</code></a>
fix(ci): bump rustsec/audit-check to v2 and ignore time audit (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15030">#15030</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/d86827980d45826bb30749a3dbc11c9326ed8bd9"><code>d868279</code></a>
apply version updates (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14897">#14897</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/3a65cc6885ea61e35dc5be23b229043ab6e92372"><code>3a65cc6</code></a>
fix(test): disable <code>resolve_resource_dir</code> on Android (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15026">#15026</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/52cf195b78e0b78f02d8aae997116c620355b095"><code>52cf195</code></a>
refactor(cli): reduce some nesting code (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14844">#14844</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/c3cbff3f7430161715f80f82128b345a6f7140c9"><code>c3cbff3</code></a>
fix: resource path handles <code>./</code> path differently (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14662">#14662</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/33754ae5e3740d022483b6164511c5c001a3c24b"><code>33754ae</code></a>
fix(cli): unusable empty password private keys (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15022">#15022</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/3935dee121e1dcb4f377c9933233c0ace186b0bb"><code>3935dee</code></a>
Add AI tool policy to contributing guide (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15002">#15002</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/33932a72b27c970dde242da5241f7a340277507d"><code>33932a7</code></a>
chore(deps-dev): bump svelte from 5.51.5 to 5.53.5 (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15015">#15015</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/7d3c7593a9d41ac1cc7f72e959263f22d32bc61d"><code>7d3c759</code></a>
chore(deps): update dependency rollup to v4.59.0 (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15001">#15001</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/f20256bca542dfd6ba4b5e2741a6a9439cc57be8"><code>f20256b</code></a>
chore: fix clippy warnings (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14999">#14999</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/tauri/compare/tauri-v2.10.2...tauri-v2.10.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-fs` from 2.4.5 to 2.5.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-fs's
releases</a>.</em></p>
<blockquote>
<h2>dialog-js v2.5.0</h2>
<h2>[2.5.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/dff6fa986a9a05ba98b6ca660fea78ae97251fc2"><code>dff6fa98</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3034">#3034</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../onehumandev"><code>@​onehumandev</code></a>)
Add <code>pickerMode</code> option to file picker (currently only used
on iOS)</li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.4.5</code></li>
</ul>
<h3>feat</h3>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c23fa03f07d5c1c220bcf0bca482364513e3f754"><code>c23fa03f</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3098">#3098</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Lepidopteran"><code>@​Lepidopteran</code></a>)
Add <code>xdg-portal</code> as an optional feature for
<code>rfd</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-dialog@2.5.0
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 3.5kB README.md
npm notice 6.4kB dist-js/index.cjs
npm notice 12.2kB dist-js/index.d.ts
npm notice 6.3kB dist-js/index.js
npm notice 11B dist-js/init.d.ts
npm notice 656B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-dialog
npm notice version: 2.5.0
npm notice filename: tauri-apps-plugin-dialog-2.5.0.tgz
npm notice package size: 5.8 kB
npm notice unpacked size: 30.0 kB
npm notice shasum: 52057077b52cc51643ac9829d48c2c590e5e1a54
npm notice integrity: sha512-I0R0ygwRd9AN8[...]lxPHD5vDcFjiA==
npm notice total files: 7
npm notice
npm notice Security Notice: Classic tokens have been revoked. Granular
tokens are now limited to 90 days and require 2FA by default. Update
your CI/CD workflows to avoid disruption. Learn more
https://gh.io/all-npm-classic-tokens-revoked
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=804721691
+ @tauri-apps/plugin-dialog@2.5.0
</code></pre>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d6a3898001a4bcc659e045f9501498751b77dbe6"><code>d6a3898</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3268">#3268</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2e5bcdf202f98ec0fb0481725dd32f0626c766c8"><code>2e5bcdf</code></a>
chore(deps): fix audits (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3373">#3373</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4374b4fc1a9a7c0506034d86707fd3288d347f9b"><code>4374b4f</code></a>
chore(notification): remove unused dev-deps (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3372">#3372</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/f75d21db3351d6f12adf585c2c797c20ece94f7f"><code>f75d21d</code></a>
chore(deps): remove used of tauri-utils build feature (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3360">#3360</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4b95f5e079593d5b2b98220c332f420dc70d18d1"><code>4b95f5e</code></a>
chore(deps): update dependency eslint to v10.1.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3357">#3357</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/99c3e37b54c115e8712f67c2b53ed1f2f0b4aae6"><code>99c3e37</code></a>
chore(deps): bump tar in /plugins/updater/tests/updater-migration/v1-app
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3352">#3352</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/eaac19a5b7e325a06d4c1c6d740e38d86c55cd89"><code>eaac19a</code></a>
chore(deps): update rust crate tar to v0.4.45 [security] (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3353">#3353</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5183e314cbabc179228e232992ad67025f30272d"><code>5183e31</code></a>
chore(deps): update dependency typescript-eslint to v8.57.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2c0883e64e9a53ea18102ed350cd5e65ba7c8f4b"><code>2c0883e</code></a>
chore(deps): update dependency vite to v8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3346">#3346</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/024ec0c29c20cf94579dab9b79d6be0da61a8daa"><code>024ec0c</code></a>
fix(deep-link): ChromeOS deep link calls filtered and ignored by plugin
(fix ...</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.5...fs-v2.5.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-dialog` from 2.6.0 to 2.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-dialog's
releases</a>.</em></p>
<blockquote>
<h2>dialog-js v2.7.0</h2>
<h2>[2.7.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/24154472a6710a690173df0a121125d1f1b871e8"><code>24154472</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3287">#3287</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Re-use <code>message</code> command in Rust side for <code>ask</code>
and <code>confirm</code> commands, <code>allow-ask</code> and
<code>allow-confirm</code> permissions are now aliases to
<code>allow-message</code></li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.5.0</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-dialog@2.7.0
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 3.5kB README.md
npm notice 6.9kB dist-js/index.cjs
npm notice 14.6kB dist-js/index.d.ts
npm notice 6.8kB dist-js/index.js
npm notice 11B dist-js/init.d.ts
npm notice 657B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-dialog
npm notice version: 2.7.0
npm notice filename: tauri-apps-plugin-dialog-2.7.0.tgz
npm notice package size: 6.7 kB
npm notice unpacked size: 33.3 kB
npm notice shasum: b510ecd42d9900725eaf51f42ec98523c40d29b4
npm notice integrity: sha512-4nS/hfGMGCXiA[...]RtrKXkANKDHvw==
npm notice total files: 7
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1235993203
+ @tauri-apps/plugin-dialog@2.7.0
</code></pre>
<!-- raw HTML omitted -->
<h2>dialog v2.7.0</h2>
<h2>[2.7.0]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/24154472a6710a690173df0a121125d1f1b871e8"><code>24154472</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3287">#3287</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Re-use <code>message</code> command in Rust side for <code>ask</code>
and <code>confirm</code> commands, <code>allow-ask</code> and
<code>allow-confirm</code> permissions are now aliases to
<code>allow-message</code></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/51b430be986ed98635c02ba504f6f30a35771c01"><code>51b430b</code></a>
ci: delete .changes/updater-new-bundle-support.md</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/fd439b143e265fc3ab4de435d6d4b641cffc66f1"><code>fd439b1</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2964">#2964</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2522b71f6bcae65c03b24415eb9295c9e7c84ffc"><code>2522b71</code></a>
fix(deep-link): revert the breaking change introduced by <a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2928">#2928</a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2970">#2970</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/9021a732474322073769c1b21f6eee2e4b0dd15e"><code>9021a73</code></a>
chore(deps): update dependency rollup to v4.50.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2966">#2966</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/625bb1c0965394b88522643731f78ccbcca84add"><code>625bb1c</code></a>
feat(log): re-export the log crate (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2965">#2965</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6215afe0239f815740a2c28150614ca4fef0b486"><code>6215afe</code></a>
chore(deps): update dependency rollup to v4.49.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2962">#2962</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/8cf8eeab02efddd4e44e3bba92b33902a8c00956"><code>8cf8eea</code></a>
feat(updater): inject bundle_type into endpoint url (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2960">#2960</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/509eba8d441c4f6ecf0af77b572cb2afd69a752d"><code>509eba8</code></a>
feat: support message dialogs with 3 buttons (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2641">#2641</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/9ac5fe84e704ef20c437456cb1017b54b101b333"><code>9ac5fe8</code></a>
feat(updater): support bundle-specific targets (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2624">#2624</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c24741031993894929a9ed00972ff9f6cd9540be"><code>c247410</code></a>
chore(deps): update dependency typescript-eslint to v8.41.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2956">#2956</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/log-v2.6.0...log-v2.7.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-http` from 2.5.7 to 2.5.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-http's
releases</a>.</em></p>
<blockquote>
<h2>http-js v2.5.8</h2>
<h2>[2.5.8]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/29712892526cfc2d35c9002e0a56925084ae1b73"><code>29712892</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3252">#3252</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../NVolcz"><code>@​NVolcz</code></a>)
Correct Response header initialization to support cloning and ensure
Set-Cookie visibility.</li>
</ul>
<h3>Dependencies</h3>
<ul>
<li>Upgraded to <code>fs-js@2.5.0</code></li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-http@2.5.8
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 2.6kB README.md
npm notice 7.1kB dist-js/index.cjs
npm notice 2.4kB dist-js/index.d.ts
npm notice 7.0kB dist-js/index.js
npm notice 655B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-http
npm notice version: 2.5.8
npm notice filename: tauri-apps-plugin-http-2.5.8.tgz
npm notice package size: 5.1 kB
npm notice unpacked size: 20.5 kB
npm notice shasum: a6e638fd0d1b17563ef0c53745373c0b1eb50260
npm notice integrity: sha512-oxd7oypzQeu8k[...]hKUbhuKWcunRw==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1235993387
+ @tauri-apps/plugin-http@2.5.8
</code></pre>
<!-- raw HTML omitted -->
<h2>http v2.5.8</h2>
<h2>[2.5.8]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/29712892526cfc2d35c9002e0a56925084ae1b73"><code>29712892</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3252">#3252</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../NVolcz"><code>@​NVolcz</code></a>)
Correct Response header initialization to support cloning and ensure
Set-Cookie visibility.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d6a3898001a4bcc659e045f9501498751b77dbe6"><code>d6a3898</code></a>
Publish New Versions (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3268">#3268</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2e5bcdf202f98ec0fb0481725dd32f0626c766c8"><code>2e5bcdf</code></a>
chore(deps): fix audits (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3373">#3373</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4374b4fc1a9a7c0506034d86707fd3288d347f9b"><code>4374b4f</code></a>
chore(notification): remove unused dev-deps (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3372">#3372</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/f75d21db3351d6f12adf585c2c797c20ece94f7f"><code>f75d21d</code></a>
chore(deps): remove used of tauri-utils build feature (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3360">#3360</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/4b95f5e079593d5b2b98220c332f420dc70d18d1"><code>4b95f5e</code></a>
chore(deps): update dependency eslint to v10.1.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3357">#3357</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/99c3e37b54c115e8712f67c2b53ed1f2f0b4aae6"><code>99c3e37</code></a>
chore(deps): bump tar in /plugins/updater/tests/updater-migration/v1-app
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3352">#3352</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/eaac19a5b7e325a06d4c1c6d740e38d86c55cd89"><code>eaac19a</code></a>
chore(deps): update rust crate tar to v0.4.45 [security] (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3353">#3353</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5183e314cbabc179228e232992ad67025f30272d"><code>5183e31</code></a>
chore(deps): update dependency typescript-eslint to v8.57.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2c0883e64e9a53ea18102ed350cd5e65ba7c8f4b"><code>2c0883e</code></a>
chore(deps): update dependency vite to v8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3346">#3346</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/024ec0c29c20cf94579dab9b79d6be0da61a8daa"><code>024ec0c</code></a>
fix(deep-link): ChromeOS deep link calls filtered and ignored by plugin
(fix ...</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/http-v2.5.7...http-v2.5.8">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-single-instance` from 2.4.0 to 2.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-single-instance's
releases</a>.</em></p>
<blockquote>
<h2>store-js v2.4.1</h2>
<h2>[2.4.1]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9"><code>93426f85</code></a>
Fixed an issue that caused docs.rs builds to fail. No user facing
changes.</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn publish npm auto-corrected some errors in your
package.json when publishing. Please run &quot;npm pkg fix&quot; to
address these errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-store@2.4.1
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 4.4kB README.md
npm notice 6.6kB dist-js/index.cjs
npm notice 8.5kB dist-js/index.d.ts
npm notice 6.4kB dist-js/index.js
npm notice 711B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-store
npm notice version: 2.4.1
npm notice filename: tauri-apps-plugin-store-2.4.1.tgz
npm notice package size: 5.5 kB
npm notice unpacked size: 27.5 kB
npm notice shasum: 5e2d3362e41861d2fa79a3f1a78c091e12963236
npm notice integrity: sha512-ckGSEzZ5Ii4Hf[...]ugpGRDOFOunkA==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=644610830
+ @tauri-apps/plugin-store@2.4.1
</code></pre>
<!-- raw HTML omitted -->
<h2>store v2.4.1</h2>
<h2>[2.4.1]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/93426f85120f49beb9f40222bff45185a32d54a9"><code>93426f85</code></a>
Fixed an issue that caused docs.rs builds to fail. No user facing
changes.</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>Updating crates.io index
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/d66aa6ff78cbbeaef1542a1fa930c33399578a25"><code>d66aa6f</code></a>
publish new versions (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2822">#2822</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6f345870df4e7b187deb869df03b79858e03b4fe"><code>6f34587</code></a>
fix(single-instance): disable dbus name replacement (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2860">#2860</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/708fa4e2b77e847ce554115e80f7eb685f4a322e"><code>708fa4e</code></a>
chore(deps): update dependency eslint-config-prettier to v10.1.8 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2858">#2858</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/b7292030594daa04e78979214478031241b6e38e"><code>b729203</code></a>
fix(upload): fix download() locks main thread on Android (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2838">#2838</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/2f9c71aae77cddd4ca2ffe9d13e9e1f23ee4f478"><code>2f9c71a</code></a>
chore(deps): update dependency rollup to v4.45.1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2850">#2850</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/80d4d8e128922b022e76049189a657b71ce95a97"><code>80d4d8e</code></a>
chore(deps): update eslint monorepo to v9.31.0 (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2839">#2839</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e7a98b0d2e60463ff86a0f031ce3c84c83a21274"><code>e7a98b0</code></a>
chore(deps): update dependency typescript-eslint to v8.37.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2848">#2848</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/44a1f659125a341191420e650608b0b6ff316a0e"><code>44a1f65</code></a>
fix(fs): <code>writeFile</code> create file by default (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2846">#2846</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/6210cd31df00bb3c55642013c74dc1da81fa00f9"><code>6210cd3</code></a>
chore(deps): update dependency rollup to v4.45.0 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2841">#2841</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/467f07b7de4103a653e40fec8badb6f24abcef39"><code>467f07b</code></a>
chore(deps): update dependency vite to v7 (v2) (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/2800">#2800</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.0...fs-v2.4.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-plugin-deep-link` from 2.4.7 to 2.4.9
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/plugins-workspace/releases">tauri-plugin-deep-link's
releases</a>.</em></p>
<blockquote>
<h2>deep-link-js v2.4.9</h2>
<h2>[2.4.9]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e6cdc9f52e2cd975b11b8e4c12879d597f1f76c3"><code>e6cdc9f5</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3396">#3396</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Fix broken iOS custom URL schemes</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>npm warn Unknown user config &quot;always-auth&quot;. This
will stop working in the next major version of npm. See `npm help npmrc`
for supported config options.
npm warn publish npm auto-corrected some errors in your package.json
when publishing. Please run &quot;npm pkg fix&quot; to address these
errors.
npm warn publish errors corrected:
npm warn publish &quot;repository&quot; was changed from a string to an
object
npm warn publish &quot;repository.url&quot; was normalized to
&quot;git+https://github.com/tauri-apps/plugins-workspace.git&quot;
npm notice
npm notice 📦  @tauri-apps/plugin-deep-link@2.4.9
npm notice Tarball Contents
npm notice 888B LICENSE.spdx
npm notice 6.2kB README.md
npm notice 3.5kB dist-js/index.cjs
npm notice 2.9kB dist-js/index.d.ts
npm notice 3.4kB dist-js/index.js
npm notice 801B package.json
npm notice Tarball Details
npm notice name: @tauri-apps/plugin-deep-link
npm notice version: 2.4.9
npm notice filename: tauri-apps-plugin-deep-link-2.4.9.tgz
npm notice package size: 4.4 kB
npm notice unpacked size: 17.7 kB
npm notice shasum: ae56d59130380f806b533b3107c3f16654e66a8d
npm notice integrity: sha512-u0SKOUHnJ1wqe[...]hIvqLBRpgHJlA==
npm notice total files: 6
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and
public access
npm notice publish Signed provenance statement with source and build
information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=1429011657
+ @tauri-apps/plugin-deep-link@2.4.9
</code></pre>
<!-- raw HTML omitted -->
<h2>deep-link v2.4.9</h2>
<h2>[2.4.9]</h2>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/e6cdc9f52e2cd975b11b8e4c12879d597f1f76c3"><code>e6cdc9f5</code></a>
(<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/pull/3396">#3396</a>
by <a
href="https://github.com/tauri-apps/plugins-workspace/../../Legend-Master"><code>@​Legend-Master</code></a>)
Fix broken iOS custom URL schemes</li>
</ul>
<!-- raw HTML omitted -->
<pre><code>&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/5c7668b6bb7c9a509f394d584568b3a922161e50"><code>5c7668b</code></a>
publish new versions (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3397">#3397</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/ec0540138bece081e9a87982091947360e61987a"><code>ec05401</code></a>
chore(deps): update rust crate toml to v1 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3323">#3323</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/b86e999beb9fb48ef54adf3d0af631f94c06a5d0"><code>b86e999</code></a>
chore(deps): update tauri packages to 2.11 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3407">#3407</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c463d8ab1422a4cf44f627a7b4e6ba9d3553f334"><code>c463d8a</code></a>
chore(deps): update rustls-webpki in lockfile, ignore core2 in audit (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3405">#3405</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/1bb7beb3076a8bf76b084223d0e4225bb2e53bc9"><code>1bb7beb</code></a>
chore(deps): bump openssl (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3402">#3402</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/3412fa2741b3b6e7a251e3548a43b21f2c26c635"><code>3412fa2</code></a>
docs(readme): fix platform support matrix (opener supports mobile)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/af81fdac9881d4ed52fbc18b9511bf10f7540ebb"><code>af81fda</code></a>
docs(readme): fix platform support matrix (mobile is supported)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/c1fd33b3a2735f2e25c1d026dc524af932db3315"><code>c1fd33b</code></a>
fix(opener): allow open network share locations (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3343">#3343</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/250857b7b72cd59825a14caa046160dc2ba1bb12"><code>250857b</code></a>
chore(deps): update dependency typescript to v6 (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3363">#3363</a>)</li>
<li><a
href="https://github.com/tauri-apps/plugins-workspace/commit/964e13f124ad1feeb93c10168b265dc4936f738c"><code>964e13f</code></a>
fix(store): dead lock trying to set while exiting (<a
href="https://redirect.github.com/tauri-apps/plugins-workspace/issues/3395">#3395</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/plugins-workspace/compare/deep-link-v2.4.7...deep-link-v2.4.9">compare
view</a></li>
</ul>
</details>
<br />

Updates `tauri-build` from 2.5.5 to 2.6.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tauri-apps/tauri/releases">tauri-build's
releases</a>.</em></p>
<blockquote>
<h2>tauri-build v2.6.0</h2>
<!-- raw HTML omitted -->
<pre><code>Fetching advisory database from
`https://github.com/RustSec/advisory-db.git`
Loaded 1060 security advisories (from /home/runner/.cargo/advisory-db)
    Updating crates.io index
    Scanning Cargo.lock for vulnerabilities (1086 crate dependencies)
Crate:     atk
Version:   0.18.2
Warning:   unmaintained
Title:     gtk-rs GTK3 bindings - no longer maintained
Date:      2024-03-04
ID:        RUSTSEC-2024-0413
URL:       https://rustsec.org/advisories/RUSTSEC-2024-0413
Dependency tree:
atk 0.18.2
└── gtk 0.18.2
    ├── wry 0.55.0
    │   └── tauri-runtime-wry 2.11.0
    │       └── tauri 2.11.0
    │           ├── tauri-utils 2.9.0
    │           │   ├── tauri-schema-generator 0.0.0
    │           │   ├── tauri-runtime-wry 2.11.0
    │           │   ├── tauri-runtime 2.11.0
    │           │   │   ├── tauri-runtime-wry 2.11.0
    │           │   │   └── tauri 2.11.0
    │           │   ├── tauri-plugin 2.6.0
    │           │   │   ├── tauri-plugin-sample 0.1.0
    │           │   │   │   └── api 0.1.0
    │           │   │   └── tauri-plugin-log 2.6.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-macros 2.6.0
    │           │   │   └── tauri 2.11.0
    │           │   ├── tauri-codegen 2.6.0
    │           │   │   ├── tauri-macros 2.6.0
    │           │   │   └── tauri-build 2.6.0
    │           │   │       ├── tauri-file-associations-demo 0.1.0
    │           │   │       ├── tauri 2.11.0
    │           │   │       ├── resources 0.1.0
    │           │   │       ├── bench_helloworld 0.1.0
    │           │   │       ├── bench_files_transfer 0.1.0
    │           │   │       ├── bench_cpu_intensive 0.1.0
    │           │   │       └── api 0.1.0
    │           │   ├── tauri-cli 2.11.0
    │           │   │   └── tauri-cli-node 0.0.0
    │           │   ├── tauri-bundler 2.9.0
    │           │   │   └── tauri-cli 2.11.0
    │           │   ├── tauri-build 2.6.0
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tauri-apps/tauri/commit/e60834fc67d87c10e2f44b2568052295cb61c325"><code>e60834f</code></a>
Apply Version Updates From Current Changes (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15041">#15041</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/df05c00563a91fc936bd15c6b10dd2825472f96b"><code>df05c00</code></a>
chore: minor bump for codegen crate</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/13bea1777fed60c75e37fe67d7ded5dde0b2997d"><code>13bea17</code></a>
chore: fmt</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/9808236ebf7755d498d674b614f3fc75eeac1ec4"><code>9808236</code></a>
fix(macOS): correct value for work_area.position.y (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14655">#14655</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/eb0312ea9e493954298ac0b3fdaae7eafb52750e"><code>eb0312e</code></a>
feat(mobile): Propagate tao::Event::Suspended and tao::Event::Resumed to
the ...</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/4ef5797f0fb27fa2df3f39f4a54e48ef319560ec"><code>4ef5797</code></a>
feat(ios): add --no-sign and --archive-only flags to ios build (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15061">#15061</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/110336c88a8c0a04476619db0a5c8f7694d969a5"><code>110336c</code></a>
fix(macOS): fix incorrect window position on multi-monitor setups (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/15250">#15250</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/c00a3dbffccd6e051d3b7332f706b6c63759865d"><code>c00a3db</code></a>
feat(macros): add support for rename command macro in tauri-macros <a
href="https://redirect.github.com/tauri-apps/tauri/issues/14173">#14173</a>
(<a
href="https://redirect.github.com/tauri-apps/tauri/issues/1">#1</a>...</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/764b9139a32de149d8a914a6b5ec6cd1937c64eb"><code>764b913</code></a>
feat(cli): restart Android emulator if it is disconnected from adb (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14313">#14313</a>)</li>
<li><a
href="https://github.com/tauri-apps/tauri/commit/1035f12eeb8b23d9780881606d442d11c786e39e"><code>1035f12</code></a>
fix(windows): tauri-bundler detect arm system (<a
href="https://redirect.github.com/tauri-apps/tauri/issues/14923">#14923</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/tauri-apps/tauri/compare/tauri-build-v2.5.5...tauri-build-v2.6.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 12:05:54 +01:00
dependabot[bot] 1ae24e18b5 build(deps): bump eclipse-temurin from a051234 to b27ca47 in /docker/base (#6292)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 11:42:36 +01:00
dependabot[bot]andAnthony Stirling 4764f1931b build(deps): bump sha2 from 0.10.9 to 0.11.0 in /frontend/src-tauri (#6180)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:58 +01:00
dependabot[bot]andAnthony Stirling 7f00441aab build(deps): bump windows from 0.58.0 to 0.61.3 in /frontend/src-tauri (#6185)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:46 +01:00
dependabot[bot]andAnthony Stirling 561d07b8b1 build(deps): bump globals from 17.4.0 to 17.5.0 in /frontend (#6285)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:40:13 +01:00
dependabot[bot]andAnthony Stirling f39db01919 build(deps): bump step-security/harden-runner from 2.15.1 to 2.19.0 (#6228)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 11:39:59 +01:00
dependabot[bot]andAnthony Stirling 4ab7d3b3ae build(deps): bump the mui group across 1 directory with 2 updates (#6301)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:53:16 +01:00
dependabot[bot]andAnthony Stirling d8519a6517 build(deps): bump com.google.guava:guava from 33.5.0-jre to 33.6.0-jre (#6283)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:39:53 +01:00
dependabot[bot]andAnthony Stirling 34c9e9bdc5 build(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#6258)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:38:42 +01:00
dependabot[bot]andAnthony Stirling 4663a9a194 build(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui from 3.0.2 to 3.0.3 in /app/common (#6286)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:35:19 +01:00
dependabot[bot]andAnthony Stirling f89f7d99ba build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#6297)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-05-05 10:34:45 +01:00
dependabot[bot] 84e30cd008 build(deps): bump actions/github-script from 7.1.0 to 9.0.0 (#6298)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:33:13 +01:00
dependabot[bot] 69236a89c8 build(deps): bump gradle/actions from 5.0.1 to 6.1.0 (#6294)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:32:53 +01:00
dependabot[bot] b66e12d15d build(deps): bump eclipse-temurin from a051234 to b27ca47 in /docker/embedded (#6293)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 10:32:29 +01:00
James BruntonandEthanHealy01 3fe8adc5cb Switch key areas to lazily import to improve Vite chunk size (#6278)
# Description of Changes
Vite currently warns that when it's bundling our code that the chunk
size is way too high because most of the imports are static so it can't
split them into smaller chunks. This PR changes a few key areas to use
lazy imports to try and make the chunks as small as possible with
minimal code changes.

Vite's warnings kick in at minified chunks being >500kB, and we've got a
little way to go still to reach that, but we can keep chipping away at
this and I'd rather get the biggest wins done now. I've also included
Lighthouse scores because there's been discussion about improving ours
recently. It's not the aim of this PR to improve it, but it's nice that
it makes it a little better.

## Current main chunks

Build split into 12 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-B6JiWDxZ.js               5,175.51 kB │ gzip: 1,495.85 kB
```

<img width="1442" height="775" alt="image"
src="https://github.com/user-attachments/assets/b0e8a3fa-4ef3-4ccd-8c1d-bfed2d99bd27"
/>

Lighthouse score:

<img width="423" height="146" alt="before"
src="https://github.com/user-attachments/assets/c62056e8-2e77-49a6-a1ae-f08ec8021fb3"
/>

## This PR's chunks

Build split into 176 chunks. Largest chunk in build is:

```
[frontend:build] dist/assets/index-qCgeCY4B.js                              2,878.54 kB │ gzip:   861.03 kB
```

<img width="1447" height="776" alt="image"
src="https://github.com/user-attachments/assets/8d0c3cf0-cc25-41c3-b114-4940d3e99349"
/>

Lighthouse score:

<img width="402" height="145" alt="after"
src="https://github.com/user-attachments/assets/99a26eb3-bd15-4b92-bf22-82b58b458f52"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-01 15:21:06 +00:00
James BruntonandEthanHealy01 51f5345151 Inform AI engine which endpoints are disabled on the backend (#6251)
# Description of Changes
Have the Java send a list of enabled endpoints to the AI engine so it
can intelligently respond to the user that the tool does exist but is
disabled on the server so it can't acutally run the operation, instead
of the current behaviour where it sends the API call back and then 503
errors because the execution fails when the URL is disabled.

<img width="380" height="208" alt="image"
src="https://github.com/user-attachments/assets/5842fb2e-2e55-45a5-8205-25515636daae"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-05-01 14:59:53 +00:00
James Brunton 5541dd666c Flesh out RAG system (#6197)
# Description of Changes
Flesh out the RAG system and connect it to the PDF Question Agent so it
can respond to questions about PDFs of an extremely large size.

I'd expect lots more work will need to be done to finish off the RAG
system to really be what we need, but this should be a reasonable start
which will let us connect it to tools and have the ingestion mostly
handled automatically. I'm leaving file deletion and proper file ID
management to be done in a future PR. We also need to consider whether
all tools should retrieve content exclusively via RAG, or whether it's
beneficial to have tools sometimes fetch the direct content and other
times fetch it from RAG.

A diagram of the expected interaction is as follows:

```mermaid
sequenceDiagram
    autonumber
    actor U as User
    participant FE as Frontend<br/>(ChatPanel)
    participant J as Java<br/>(AiWorkflowService)
    participant O as Engine:<br/>OrchestratorAgent
    participant QA as Engine:<br/>PdfQuestionAgent
    participant RAG as Engine:<br/>RagService + SqliteVecStore
    participant V as VoyageAI<br/>(embeddings)
    participant L as LLM<br/>(Claude / etc.)

    U->>FE: types "Summarise this PDF"<br/>(PDF already uploaded)
    FE->>J: POST /api/v1/ai/orchestrate/stream<br/>multipart: fileInputs[], userMessage
    Note over J: ByteHashFileIdStrategy<br/>id = sha256(bytes)[:16]
    J->>O: POST /api/v1/orchestrator<br/>{ files:[{id,name}], userMessage }

    O->>L: route via fast model
    L-->>O: delegate_pdf_question
    O->>QA: PdfQuestionRequest

    loop for each file
        QA->>RAG: has_collection(file.id)
        RAG-->>QA: false
    end
    QA-->>O: NeedIngestResponse(files_to_ingest)
    O-->>J: { outcome:"need_ingest", filesToIngest:[...] }

    Note over J: onNeedIngest
    loop per file
        J->>J: PDFBox: extract page text
        J->>O: POST /api/v1/rag/documents<br/>(long-running timeout)
        O->>RAG: chunk + stage documents
        O->>V: embed_documents (batches of 256)
        V-->>O: embeddings
        O->>RAG: add_documents
        O-->>J: { chunks_indexed: N }
    end

    Note over J: retry with resumeWith=pdf_question
    J->>O: POST /api/v1/orchestrator
    Note over O: fast-path to PdfQuestionAgent

    O->>QA: PdfQuestionRequest
    Note over QA: build RagCapability<br/>pinned to file IDs
    QA->>L: run(prompt) with search_knowledge tool

    loop up to max_searches
        L->>QA: search_knowledge(query)
        QA->>V: embed_query
        V-->>QA: query vector
        QA->>RAG: search(vector, collections=[file.id])
        RAG-->>QA: top-k chunks
        QA-->>L: formatted chunks
    end

    Note over QA: once budget spent,<br/>prepare() hides the tool
    L-->>QA: PdfQuestionAnswerResponse
    QA-->>O: answer
    O-->>J: { outcome:"answer", answer, evidence }
    J-->>FE: SSE "result"
    FE->>U: assistant bubble
```
2026-05-01 14:11:54 +01:00
Anthony Stirling 5605062153 Add Dependabot groups for frontend npm + cargo deps (#6287) 2026-05-01 11:37:07 +01:00
ConnorYohandJames Brunton 86774d556e Pdf comment agent (#6196)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-05-01 10:19:38 +01:00
707 changed files with 50817 additions and 10379 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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')
+93
View File
@@ -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
+7 -7
View File
@@ -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: |
+2 -2
View File
@@ -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: |
+3 -3
View File
@@ -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 -->';
+2 -2
View File
@@ -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 }}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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: |
+15 -7
View File
@@ -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/
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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: |
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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/
+11 -43
View File
@@ -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/
+9 -5
View File
@@ -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: |
+24 -4
View File
@@ -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/
+1 -1
View File
@@ -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
+49 -23
View File
@@ -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
+5 -5
View File
@@ -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/
+20 -24
View File
@@ -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:
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+168 -12
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+48 -27
View File
@@ -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
+59 -4
View File
@@ -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
+6 -6
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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}}
+204
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
# ============================================================
+11 -11
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
@@ -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 &gt;
* 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
* &gt;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();
}
}
@@ -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);
}
}
@@ -75,8 +75,8 @@ public @interface AutoJobPostMapping {
boolean queueable() default false;
/**
* Relative resource weight (1100) used by the scheduler to prioritise / throttle jobs. Values
* below 1 are clamped to&nbsp;1, values above 100 to&nbsp;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;
}
@@ -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(
@@ -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) {}
@@ -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) {}
@@ -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;
}
@@ -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;
}
}
/**
@@ -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);
@@ -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"));
}
}
@@ -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");
}
}
}
@@ -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<>();
@@ -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;
}
}
@@ -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<>();
@@ -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
View File
@@ -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;
@@ -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",
@@ -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 =
@@ -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) {}
}
@@ -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);
}
}
}
@@ -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)) {
@@ -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",
@@ -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