mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b173a030e | ||
|
|
c5525d8676 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa | ||
|
|
1e72416d55 | ||
|
|
959d14f075 | ||
|
|
8f6fcee428 | ||
|
|
651f17f1c6 | ||
|
|
fde449e738 | ||
|
|
85e9121745 | ||
|
|
12f1fd485e | ||
|
|
b49e8a2355 | ||
|
|
d908bc6785 | ||
|
|
4ae79d92ae | ||
|
|
85d9b5b83d | ||
|
|
058a81d554 | ||
|
|
e4c6ce5836 | ||
|
|
250979e271 | ||
|
|
731743b618 | ||
|
|
04c4aec0d8 | ||
|
|
f63df148ad | ||
|
|
6f0be94bd6 | ||
|
|
e47ed13be8 | ||
|
|
a62c8b54cf | ||
|
|
1e34038b1e | ||
|
|
d4765938a8 | ||
|
|
476e1505ee | ||
|
|
d3491ecccf | ||
|
|
cddd5e7d15 | ||
|
|
f4c9becce2 | ||
|
|
7629d89356 | ||
|
|
9fdb5295cb | ||
|
|
21657a3fa6 | ||
|
|
bc11875081 | ||
|
|
31b3219169 | ||
|
|
d8a99fcb07 | ||
|
|
8016d271aa | ||
|
|
8f1bef7f46 | ||
|
|
e6db57e031 | ||
|
|
daf749e6be | ||
|
|
53d167eda5 | ||
|
|
d74856f675 | ||
|
|
f8386843d4 | ||
|
|
2277a94c91 | ||
|
|
3b8b539efc | ||
|
|
7253b9fa6d | ||
|
|
9a1f89486d | ||
|
|
a6614e1bfb | ||
|
|
ae5b1a4b02 | ||
|
|
9fcaace8ed | ||
|
|
2ab7945130 | ||
|
|
a8db2fda18 | ||
|
|
80f2980755 | ||
|
|
2d8b0ff08c | ||
|
|
991e03970e | ||
|
|
64d343b765 | ||
|
|
2534c532b7 | ||
|
|
8757a80fda | ||
|
|
d599a07a66 | ||
|
|
861e4394df | ||
|
|
5d18184e46 | ||
|
|
050408639b | ||
|
|
b0397da19e | ||
|
|
d42065e338 |
@@ -306,8 +306,10 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Test Installers Build
|
||||
name: Multi-OS Tauri Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,7 +6,23 @@ on:
|
||||
test_mode:
|
||||
description: "Run in test mode (skip release step)"
|
||||
required: false
|
||||
default: "false"
|
||||
default: "true"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -14,288 +30,500 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
read_versions:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# ✅ Get version from Gradle
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
echo "Running gradlew printVersion..."
|
||||
./gradlew printVersion --quiet
|
||||
VERSION=$(./gradlew printVersion --quiet | tail -1)
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# ✅ Get Mac-specific version from Gradle
|
||||
- name: Get version number mac
|
||||
id: versionNumberMac
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
|
||||
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
|
||||
|
||||
build-portable:
|
||||
needs: read_versions
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mkdir ./binaries
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling${{ matrix.file_suffix }}-binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify-portable:
|
||||
needs: [build-portable, read_versions]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "with-login-"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: stirling-${{ matrix.file_suffix }}binaries
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling-${{ matrix.file_suffix }}signed
|
||||
path: |
|
||||
./*
|
||||
!cosign.*
|
||||
|
||||
build-installers:
|
||||
needs: read_versions
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# Install Windows dependencies
|
||||
- name: Install WiX Toolset
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
|
||||
.\wix.exe /install /quiet
|
||||
|
||||
# Build installer
|
||||
- name: Build Installer
|
||||
run: ./gradlew build jpackage -x test --info
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
BROWSER_OPEN: true
|
||||
|
||||
- name: Set up JDK (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
|
||||
mkdir -p zulu17
|
||||
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
|
||||
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
|
||||
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify JDK architecture
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: file $JAVA_HOME/bin/java
|
||||
|
||||
- name: Build project and run jpackage (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: arch -x86_64 ./gradlew jpackageMacX64
|
||||
|
||||
# Rename and collect artifacts based on OS
|
||||
- name: Prepare artifacts
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
ls -lah ./build/jpackage/
|
||||
mkdir ./binaries
|
||||
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
|
||||
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
|
||||
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
|
||||
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
|
||||
;;
|
||||
"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
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"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"},{"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
|
||||
;;
|
||||
esac
|
||||
else
|
||||
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
|
||||
# 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
|
||||
fi
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./binaries
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify:
|
||||
needs: [read_versions, build-installers]
|
||||
build-jars:
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ubuntu-latest
|
||||
variant:
|
||||
- name: "default"
|
||||
disable_security: true
|
||||
build_frontend: true
|
||||
file_suffix: ""
|
||||
- name: "with-login"
|
||||
disable_security: false
|
||||
build_frontend: true
|
||||
file_suffix: "-with-login"
|
||||
- name: "server-only"
|
||||
disable_security: true
|
||||
build_frontend: false
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
name: ${{ matrix.platform }}binaries
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: cosign generate-key-pair
|
||||
- name: Build JAR
|
||||
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Sign and generate attestations
|
||||
if: matrix.os == 'windows-latest'
|
||||
- name: Rename JAR
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
|
||||
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
|
||||
ls -la app/core/build/libs/
|
||||
mkdir -p ./jar-dist
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,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.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
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"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
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"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; 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
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}signed
|
||||
path: |
|
||||
./Stirling-PDF-${{ matrix.platform }}installer.*
|
||||
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
|
||||
!cosign.*
|
||||
|
||||
create-release:
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
|
||||
needs: [read_versions, sign_verify, sign_verify-portable]
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -305,14 +533,43 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./*signed/*
|
||||
./artifacts/**/*.jar
|
||||
./artifacts/**/*.msi
|
||||
./artifacts/**/*.dmg
|
||||
./artifacts/**/*.deb
|
||||
./artifacts/**/*.AppImage
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V2-demo branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V2-demo branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-demo branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -2,85 +2,445 @@ name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [created]
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename binaries
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
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
|
||||
;;
|
||||
"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
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"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"},{"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
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push 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
|
||||
fi
|
||||
|
||||
- name: Debug build artifacts
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
echo "Current Directory: $(pwd)"
|
||||
ls -R ./build/libs
|
||||
ls -R ./build/launch4j
|
||||
sudo apt-get update
|
||||
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@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,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.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
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"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
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"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; 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
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
path: |
|
||||
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -90,7 +450,8 @@ jobs:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
@@ -101,80 +462,74 @@ jobs:
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [build, sign_verify]
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./libs/Stirling-PDF*
|
||||
./launch4j/Stirling-PDF-Server*
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -92,8 +92,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
@@ -180,7 +178,7 @@ jobs:
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
@@ -190,7 +188,7 @@ jobs:
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
@@ -225,7 +223,7 @@ jobs:
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
@@ -314,7 +312,8 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
@@ -323,7 +322,7 @@ jobs:
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
@@ -514,7 +513,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest'
|
||||
if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
+31
-2
@@ -17,8 +17,8 @@ local.properties
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/finishedFolders/
|
||||
pipeline/
|
||||
!pipeline/.gitkeep
|
||||
customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
@@ -31,8 +31,23 @@ exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
# Frontend build artifacts copied to backend static resources
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
.gradle-home
|
||||
.lock
|
||||
|
||||
# External tool builders
|
||||
@@ -213,3 +228,17 @@ node_modules/
|
||||
test_batch.json
|
||||
*.backup.*.json
|
||||
frontend/public/locales/*/translation.backup*.json
|
||||
|
||||
# Development/build artifacts
|
||||
.gradle-cache/
|
||||
scripts/pdf-collection/
|
||||
**/tmp/
|
||||
*.backup
|
||||
|
||||
# Type3 development data
|
||||
docs/type3/signatures/
|
||||
|
||||
|
||||
# Type3 sample PDFs (development only)
|
||||
**/type3/samples/
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ 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 "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -37,6 +37,8 @@ dependencies {
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
|
||||
+4
@@ -336,6 +336,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
addEndpointToGroup("Convert", "pdf-to-json");
|
||||
addEndpointToGroup("Convert", "json-to-pdf");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -471,6 +473,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
addEndpointToGroup("Java", "cbz-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Analysis",
|
||||
description =
|
||||
"""
|
||||
Document analysis and information extraction services for content intelligence and insights.
|
||||
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details.
|
||||
Use these endpoints to understand what's inside a document without changing it.
|
||||
|
||||
This endpoint group provides analytical capabilities to understand document structure,
|
||||
extract information, and generate insights from PDF content for automated processing.
|
||||
|
||||
Common use cases:
|
||||
• Document inventory management and content audit for compliance verification
|
||||
• Quality assurance workflows and business intelligence analytics
|
||||
• Migration planning, accessibility evaluation, and document forensics
|
||||
|
||||
Business applications:
|
||||
• Legal discovery, financial document review, and healthcare records analysis
|
||||
• Academic research, government processing, and publishing optimization
|
||||
|
||||
Operational scenarios:
|
||||
• Large-scale profiling, migration assessment, and performance optimization
|
||||
• Automated quality control and content strategy development
|
||||
|
||||
Target users: Data analysts, QA teams, administrators, and business intelligence
|
||||
professionals requiring detailed document insights.
|
||||
Typical uses:
|
||||
• Get page counts and dimensions for layout or print rules
|
||||
• List fonts and annotations to spot compatibility issues
|
||||
• Inspect form fields before deciding how to fill or modify them
|
||||
• Pull metadata and security settings for audits or reports
|
||||
""")
|
||||
public @interface AnalysisApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Convert",
|
||||
description =
|
||||
"""
|
||||
Document format transformation services for cross-platform compatibility and workflow integration.
|
||||
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.).
|
||||
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
|
||||
|
||||
This endpoint group enables transformation between various formats, supporting
|
||||
diverse business workflows and system integrations for mixed document ecosystems.
|
||||
|
||||
Common use cases:
|
||||
• Legacy system integration, document migration, and cross-platform sharing
|
||||
• Archive standardization, publishing preparation, and content adaptation
|
||||
• Accessibility compliance and mobile-friendly document preparation
|
||||
|
||||
Business applications:
|
||||
• Enterprise content management, digital publishing, and educational platforms
|
||||
• Legal document processing, healthcare interoperability, and government standardization
|
||||
|
||||
Integration scenarios:
|
||||
• API-driven pipelines, automated workflow preparation, and batch conversions
|
||||
• Real-time format adaptation for user requests
|
||||
|
||||
Target users: System integrators, content managers, digital archivists, and
|
||||
organizations requiring flexible document format interoperability.
|
||||
Typical uses:
|
||||
• Turn PDFs into Word or text for editing
|
||||
• Convert office files, images, HTML, or email (EML) into PDFs
|
||||
• Create PDF/A for long-term archiving
|
||||
• Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse
|
||||
""")
|
||||
public @interface ConvertApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Filter",
|
||||
description =
|
||||
"""
|
||||
Document content filtering and search operations for information discovery and organization.
|
||||
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images.
|
||||
Use these endpoints as a "pre-check" step to decide what to do with a file next.
|
||||
|
||||
This endpoint group enables intelligent content discovery and organization within
|
||||
document collections for content-based processing and information extraction.
|
||||
|
||||
Common use cases:
|
||||
• Legal discovery, research organization, and compliance auditing
|
||||
• Content moderation, academic research, and business intelligence
|
||||
• Quality assurance and content validation workflows
|
||||
|
||||
Business applications:
|
||||
• Contract analysis, financial review, and healthcare records organization
|
||||
• Government processing, educational curation, and IP protection
|
||||
|
||||
Workflow scenarios:
|
||||
• Large-scale processing, automated classification, and information extraction
|
||||
• Document preparation for further processing or analysis
|
||||
|
||||
Target users: Legal professionals, researchers, compliance officers, and
|
||||
organizations requiring intelligent document content discovery and organization.
|
||||
Typical uses:
|
||||
• Reject files that are too big or too small
|
||||
• Detect image-only PDFs that should go through OCR
|
||||
• Ensure a document has enough pages before it enters a workflow
|
||||
• Check orientation of pages before printing or merging
|
||||
""")
|
||||
public @interface FilterApi {}
|
||||
|
||||
@@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "General",
|
||||
description =
|
||||
"""
|
||||
Core PDF processing operations for fundamental document manipulation workflows.
|
||||
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages.
|
||||
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
|
||||
|
||||
This endpoint group provides essential PDF functionality that forms the foundation
|
||||
of most document processing workflows across various industries.
|
||||
|
||||
Common use cases:
|
||||
• Document preparation for archival systems and content organization
|
||||
• File preparation for distribution, accessibility compliance, and batch processing
|
||||
• Document consolidation for reporting and legal compliance workflows
|
||||
|
||||
Typical applications:
|
||||
• Content management, publishing workflows, and educational content distribution
|
||||
• Business process automation and archive management
|
||||
|
||||
Target users: Content managers, document processors, and organizations requiring
|
||||
reliable foundational PDF manipulation capabilities.
|
||||
Typical uses:
|
||||
• Split a large PDF into smaller files (by pages, chapters, or size)
|
||||
• Merge several PDFs into one report or pack
|
||||
• Rotate or reorder pages before sending or archiving
|
||||
• Turn a multi-page document into one long scrolling page
|
||||
""")
|
||||
public @interface GeneralApi {}
|
||||
|
||||
@@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Misc",
|
||||
description =
|
||||
"""
|
||||
Specialized utilities and supplementary tools for enhanced document processing workflows.
|
||||
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more.
|
||||
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
|
||||
|
||||
This endpoint group provides utility operations that support core document processing
|
||||
tasks and address specific workflow needs in real-world scenarios.
|
||||
|
||||
Common use cases:
|
||||
• Document optimization for bandwidth-limited environments and storage cost management
|
||||
• Document repair, content extraction, and validation for quality assurance
|
||||
• Accessibility improvement and custom processing for specialized needs
|
||||
|
||||
Business applications:
|
||||
• Web publishing optimization, email attachment management, and archive efficiency
|
||||
• Mobile compatibility, print production, and legacy document recovery
|
||||
|
||||
Operational scenarios:
|
||||
• Batch processing, quality control, and performance optimization
|
||||
• Troubleshooting and recovery of problematic documents
|
||||
|
||||
Target users: System administrators, document specialists, and organizations requiring
|
||||
specialized document processing and optimization tools.
|
||||
Typical uses:
|
||||
• Repair a damaged PDF or remove blank pages
|
||||
• Run OCR on scanned PDFs so they become searchable
|
||||
• Compress large PDFs for email or web download
|
||||
• Extract embedded images or scans
|
||||
• Add page numbers, stamps, or overlay an image (e.g. logo, seal)
|
||||
• Update PDF metadata (title, author, etc.)
|
||||
""")
|
||||
public @interface MiscApi {}
|
||||
|
||||
@@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Pipeline",
|
||||
description =
|
||||
"""
|
||||
Automated document processing workflows for complex multi-stage business operations.
|
||||
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself.
|
||||
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
|
||||
|
||||
This endpoint group enables organizations to create sophisticated document processing
|
||||
workflows that combine multiple operations into streamlined, repeatable processes.
|
||||
|
||||
Common use cases:
|
||||
• Invoice processing, legal document review, and healthcare records standardization
|
||||
• Government processing, educational content preparation, and publishing automation
|
||||
• Contract lifecycle management and approval processes
|
||||
|
||||
Business applications:
|
||||
• Automated compliance reporting, large-scale migration, and quality assurance
|
||||
• Archive preparation, content delivery, and document approval workflows
|
||||
|
||||
Operational scenarios:
|
||||
• Scheduled batch processing and event-driven document processing
|
||||
• Multi-department coordination and business system integration
|
||||
|
||||
Target users: Business process managers, IT automation specialists, and organizations
|
||||
requiring consistent, repeatable document processing workflows.
|
||||
Typical uses:
|
||||
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.)
|
||||
• Normalise documents before they enter an archive
|
||||
• Wrap a complex document flow behind a single API call for your own apps
|
||||
""")
|
||||
public @interface PipelineApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Security",
|
||||
description =
|
||||
"""
|
||||
Document security and protection services for confidential and sensitive content.
|
||||
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing.
|
||||
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
|
||||
|
||||
This endpoint group provides essential security operations for organizations handling
|
||||
sensitive documents and materials requiring controlled access.
|
||||
|
||||
Common use cases:
|
||||
• Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
|
||||
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
|
||||
• Contract security for business transactions
|
||||
|
||||
Business applications:
|
||||
• Document authentication, confidential sharing, and secure archiving
|
||||
• Content watermarking, access control, and privacy protection through redaction
|
||||
|
||||
Industry scenarios:
|
||||
• Legal discovery, medical records exchange, financial audit documentation
|
||||
• Enterprise policy enforcement and data governance
|
||||
|
||||
Target users: Legal professionals, healthcare administrators, compliance officers,
|
||||
government agencies, and enterprises handling sensitive content.
|
||||
Typical uses:
|
||||
• Add or remove a password on a PDF
|
||||
• Redact personal or confidential information (manually or automatically)
|
||||
• Validate or remove digital signatures
|
||||
• Sanitize a PDF to strip scripts and embedded content
|
||||
""")
|
||||
public @interface SecurityApi {}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ public class CustomPDFDocumentFactory {
|
||||
|
||||
// Memory thresholds and limits
|
||||
|
||||
private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
||||
public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
||||
// Files smaller than this threshold are loaded entirely in memory for better performance.
|
||||
// These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
|
||||
// No temp files are created for document data, reducing I/O operations but consuming more
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -37,6 +38,9 @@ public class JobExecutorService {
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
private final long effectiveTimeoutMs;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
public JobExecutorService(
|
||||
TaskManager taskManager,
|
||||
FileStorage fileStorage,
|
||||
@@ -98,11 +102,17 @@ public class JobExecutorService {
|
||||
long customTimeoutMs,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
// Generate base UUID
|
||||
String baseJobId = UUID.randomUUID().toString();
|
||||
|
||||
// Store the job ID in the request for potential use by other components
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
|
||||
|
||||
// Store the scoped job ID in the request for potential use by other components
|
||||
if (request != null) {
|
||||
request.setAttribute("jobId", jobId);
|
||||
request.setAttribute("jobId", scopedJobKey);
|
||||
|
||||
// Also track this job ID in the user's session for authorization purposes
|
||||
// This ensures users can only cancel their own jobs
|
||||
@@ -116,11 +126,13 @@ public class JobExecutorService {
|
||||
request.getSession().setAttribute("userJobIds", userJobIds);
|
||||
}
|
||||
|
||||
userJobIds.add(jobId);
|
||||
log.debug("Added job ID {} to user session", jobId);
|
||||
userJobIds.add(scopedJobKey);
|
||||
log.debug("Added scoped job ID {} to user session", scopedJobKey);
|
||||
}
|
||||
}
|
||||
|
||||
String jobId = scopedJobKey;
|
||||
|
||||
// Determine which timeout to use
|
||||
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
|
||||
|
||||
@@ -149,17 +161,31 @@ public class JobExecutorService {
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Create a specialized wrapper that updates the TaskManager
|
||||
final String capturedJobIdForQueue = jobId;
|
||||
Supplier<Object> wrappedWork =
|
||||
() -> {
|
||||
try {
|
||||
// Set jobId in ThreadLocal context for the queued job
|
||||
stirling.software.common.util.JobContext.setJobId(
|
||||
capturedJobIdForQueue);
|
||||
log.debug(
|
||||
"Set jobId {} in JobContext for queued job execution",
|
||||
capturedJobIdForQueue);
|
||||
|
||||
Object result = work.get();
|
||||
processJobResult(jobId, result);
|
||||
processJobResult(capturedJobIdForQueue, result);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
"Error executing queued job {}: {}",
|
||||
capturedJobIdForQueue,
|
||||
e.getMessage(),
|
||||
e);
|
||||
taskManager.setError(capturedJobIdForQueue, e.getMessage());
|
||||
throw e;
|
||||
} finally {
|
||||
// Clean up ThreadLocal to avoid memory leaks
|
||||
stirling.software.common.util.JobContext.clear();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,21 +197,36 @@ public class JobExecutorService {
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else if (async) {
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Capture the jobId for the async thread
|
||||
final String capturedJobId = jobId;
|
||||
|
||||
executor.execute(
|
||||
() -> {
|
||||
try {
|
||||
log.debug(
|
||||
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
|
||||
"Running async job {} with timeout {} ms",
|
||||
capturedJobId,
|
||||
timeoutToUse);
|
||||
|
||||
// Set jobId in ThreadLocal context for the async thread
|
||||
stirling.software.common.util.JobContext.setJobId(capturedJobId);
|
||||
log.debug(
|
||||
"Set jobId {} in JobContext for async execution",
|
||||
capturedJobId);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
processJobResult(jobId, result);
|
||||
processJobResult(capturedJobId, result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
|
||||
taskManager.setError(jobId, "Job timed out");
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
} finally {
|
||||
// Clean up ThreadLocal to avoid memory leaks
|
||||
stirling.software.common.util.JobContext.clear();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,6 +235,10 @@ public class JobExecutorService {
|
||||
try {
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
|
||||
// Make jobId available to downstream components on the worker thread
|
||||
stirling.software.common.util.JobContext.setJobId(jobId);
|
||||
log.debug("Set jobId {} in JobContext for sync execution", jobId);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
|
||||
@@ -213,6 +258,8 @@ public class JobExecutorService {
|
||||
// Construct a JSON error response
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
} finally {
|
||||
stirling.software.common.util.JobContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,8 +513,23 @@ public class JobExecutorService {
|
||||
throws TimeoutException, Exception {
|
||||
// Use the same executor as other async jobs for consistency
|
||||
// This ensures all operations run on the same thread pool
|
||||
String currentJobId = stirling.software.common.util.JobContext.getJobId();
|
||||
|
||||
java.util.concurrent.CompletableFuture<T> future =
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
if (currentJobId != null) {
|
||||
stirling.software.common.util.JobContext.setJobId(currentJobId);
|
||||
}
|
||||
try {
|
||||
return supplier.get();
|
||||
} finally {
|
||||
if (currentJobId != null) {
|
||||
stirling.software.common.util.JobContext.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
executor);
|
||||
|
||||
try {
|
||||
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
@@ -483,4 +545,18 @@ public class JobExecutorService {
|
||||
throw new Exception("Execution was interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param baseJobId the base job identifier
|
||||
* @return scoped job key, or just baseJobId if no ownership service available
|
||||
*/
|
||||
private String getScopedJobKey(String baseJobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
return jobOwnershipService.createScopedJobKey(baseJobId);
|
||||
}
|
||||
// Security disabled, return unsecured job key
|
||||
return baseJobId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service interface for managing job ownership and access control. Implementations can provide
|
||||
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
|
||||
*/
|
||||
public interface JobOwnershipService {
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's identifier.
|
||||
*
|
||||
* @return Optional containing user identifier, or empty if not authenticated
|
||||
*/
|
||||
Optional<String> getCurrentUserId();
|
||||
|
||||
/**
|
||||
* Create a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param jobId the base job identifier
|
||||
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
|
||||
*/
|
||||
String createScopedJobKey(String jobId);
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key to validate
|
||||
* @return true if current user owns the job or no authentication is required
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
boolean validateJobAccess(String scopedJobKey);
|
||||
|
||||
/**
|
||||
* Extract the base job ID from a scoped job key.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key
|
||||
* @return the base job ID without user prefix
|
||||
*/
|
||||
String extractJobId(String scopedJobKey);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
|
||||
* module to provide authenticated users access to their personal signatures.
|
||||
*/
|
||||
public interface PersonalSignatureServiceInterface {
|
||||
|
||||
/**
|
||||
* Get a personal signature from the user's folder. Only checks personal folder, not shared
|
||||
* folder.
|
||||
*
|
||||
* @param username Username of the signature owner
|
||||
* @param fileName Signature filename
|
||||
* @return Personal signature image bytes
|
||||
* @throws IOException If file not found or read error
|
||||
*/
|
||||
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
/** Thread-local context for passing job ID across async boundaries */
|
||||
public class JobContext {
|
||||
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
|
||||
|
||||
public static void setJobId(String jobId) {
|
||||
CURRENT_JOB_ID.set(jobId);
|
||||
}
|
||||
|
||||
public static String getJobId() {
|
||||
return CURRENT_JOB_ID.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CURRENT_JOB_ID.remove();
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getOcrMyPdfSessionLimit();
|
||||
case CFF_CONVERTER -> 1;
|
||||
};
|
||||
|
||||
long timeoutMinutes =
|
||||
@@ -150,6 +151,7 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getOcrMyPdfTimeoutMinutes();
|
||||
case CFF_CONVERTER -> 5L;
|
||||
};
|
||||
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
|
||||
});
|
||||
@@ -302,7 +304,8 @@ public class ProcessExecutor {
|
||||
TESSERACT,
|
||||
QPDF,
|
||||
GHOSTSCRIPT,
|
||||
OCR_MY_PDF
|
||||
OCR_MY_PDF,
|
||||
CFF_CONVERTER
|
||||
}
|
||||
|
||||
@Setter
|
||||
|
||||
@@ -7,23 +7,103 @@ public class RequestUriUtils {
|
||||
}
|
||||
|
||||
public static boolean isStaticResource(String contextPath, String requestURI) {
|
||||
return requestURI.startsWith(contextPath + "/css/")
|
||||
|| requestURI.startsWith(contextPath + "/fonts/")
|
||||
|| requestURI.startsWith(contextPath + "/js/")
|
||||
|| requestURI.endsWith(contextPath + "robots.txt")
|
||||
|| requestURI.startsWith(contextPath + "/images/")
|
||||
|| requestURI.startsWith(contextPath + "/public/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|
||||
|| requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/error")
|
||||
|| requestURI.startsWith(contextPath + "/favicon")
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".ico")
|
||||
|| requestURI.endsWith(".txt")
|
||||
|| requestURI.endsWith(".webmanifest")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// API routes are never static except for the public status endpoint
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return normalizedUri.startsWith("/api/v1/info/status");
|
||||
}
|
||||
|
||||
// Well-known static asset directories (backend + React build artifacts)
|
||||
if (normalizedUri.startsWith("/css/")
|
||||
|| normalizedUri.startsWith("/fonts/")
|
||||
|| normalizedUri.startsWith("/js/")
|
||||
|| normalizedUri.startsWith("/images/")
|
||||
|| normalizedUri.startsWith("/public/")
|
||||
|| normalizedUri.startsWith("/pdfjs/")
|
||||
|| normalizedUri.startsWith("/pdfjs-legacy/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
|| normalizedUri.startsWith("/og_images/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Login/error pages remain public
|
||||
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|| normalizedUri.endsWith(".ico")
|
||||
|| normalizedUri.endsWith(".txt")
|
||||
|| normalizedUri.endsWith(".webmanifest")
|
||||
|| normalizedUri.endsWith(".js")
|
||||
|| normalizedUri.endsWith(".css")
|
||||
|| normalizedUri.endsWith(".mjs")
|
||||
|| normalizedUri.endsWith(".html")
|
||||
|| normalizedUri.endsWith(".toml");
|
||||
}
|
||||
|
||||
public static boolean isFrontendRoute(String contextPath, String requestURI) {
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// APIs are never treated as frontend routes
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blocklist of backend/non-frontend paths that should still go through filters
|
||||
String[] backendOnlyPrefixes = {
|
||||
"/register",
|
||||
"/invite",
|
||||
"/pipeline",
|
||||
"/pdfjs",
|
||||
"/pdfjs-legacy",
|
||||
"/fonts",
|
||||
"/images",
|
||||
"/files",
|
||||
"/css",
|
||||
"/js",
|
||||
"/swagger",
|
||||
"/v1/api-docs",
|
||||
"/actuator"
|
||||
};
|
||||
|
||||
for (String prefix : backendOnlyPrefixes) {
|
||||
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedUri.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow root and any extensionless path (React Router will handle these)
|
||||
return !normalizedUri.contains(".");
|
||||
}
|
||||
|
||||
public static boolean isTrackableResource(String requestURI) {
|
||||
@@ -43,6 +123,7 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith("popularity.txt")
|
||||
|| requestURI.endsWith(".js")
|
||||
|| requestURI.endsWith(".toml")
|
||||
|| requestURI.contains("swagger")
|
||||
|| requestURI.startsWith("/api/v1/info")
|
||||
|| requestURI.startsWith("/site.webmanifest")
|
||||
@@ -78,9 +159,18 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.contains("/v1/api-docs");
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
|
||||
return requestURI.substring(contextPath.length());
|
||||
}
|
||||
return requestURI;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,23 @@ class JobExecutorServiceTest {
|
||||
verify(request).setAttribute(eq("jobId"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeJobIdInJobContextDuringSyncExecution() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = stirling.software.common.util.JobContext::getJobId;
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
|
||||
var requestJobIdCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(request).setAttribute(eq("jobId"), requestJobIdCaptor.capture());
|
||||
assertEquals(requestJobIdCaptor.getValue(), response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunAsyncJobSuccessfully() throws Exception {
|
||||
// Given
|
||||
|
||||
@@ -49,6 +49,27 @@ public class RequestUriUtilsTest {
|
||||
"API products should not be static");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
|
||||
"API routes should not be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/register"),
|
||||
"Register should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
|
||||
"Pipeline should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/download"),
|
||||
"Files path should not be treated as a frontend route");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
@@ -83,6 +104,7 @@ public class RequestUriUtilsTest {
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
apply plugin: 'org.springframework.boot'
|
||||
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
||||
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
|
||||
@@ -15,6 +17,7 @@ configurations {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
@@ -25,12 +28,14 @@ spotless {
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -157,5 +162,125 @@ springBoot {
|
||||
mainClass = 'stirling.software.SPDF.SPDFApplication'
|
||||
}
|
||||
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def frontendDir = file('../../frontend')
|
||||
def frontendDistDir = file('../../frontend/dist')
|
||||
def resourcesStaticDir = file('src/main/resources/static')
|
||||
def generatedFrontendPaths = [
|
||||
'assets',
|
||||
'index.html',
|
||||
'locales',
|
||||
'Login',
|
||||
'classic-logo',
|
||||
'modern-logo',
|
||||
'og_images',
|
||||
'samples',
|
||||
'manifest-classic.json'
|
||||
]
|
||||
|
||||
tasks.register('npmInstall', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Install frontend dependencies'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
inputs.file(new File(frontendDir, 'package-lock.json'))
|
||||
outputs.dir(new File(frontendDir, 'node_modules'))
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Skip if node_modules exists and is up-to-date
|
||||
onlyIf {
|
||||
def nodeModules = new File(frontendDir, 'node_modules')
|
||||
if (!nodeModules.exists()) {
|
||||
println "node_modules not found, will install..."
|
||||
return true
|
||||
}
|
||||
def packageJson = new File(frontendDir, 'package.json')
|
||||
def packageLock = new File(frontendDir, 'package-lock.json')
|
||||
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
|
||||
nodeModules.lastModified() < packageLock.lastModified()
|
||||
if (isOutdated) {
|
||||
println "package.json or package-lock.json changed, will reinstall..."
|
||||
} else {
|
||||
println "node_modules is up-to-date, skipping npm install"
|
||||
}
|
||||
return isOutdated
|
||||
}
|
||||
|
||||
doFirst {
|
||||
println "Installing npm dependencies in ${frontendDir}..."
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('npmBuild', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
|
||||
dependsOn npmInstall
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
outputs.dir(frontendDistDir)
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
|
||||
doFirst {
|
||||
println "Building frontend application for production (VITE_API_BASE_URL=/)"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('copyFrontendAssets', Copy) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Copy frontend build to static resources'
|
||||
dependsOn npmBuild
|
||||
from(frontendDistDir) {
|
||||
// Exclude files that conflict with backend static resources
|
||||
exclude 'robots.txt' // Backend already has this
|
||||
exclude 'favicon.ico' // Backend already has this
|
||||
}
|
||||
into resourcesStaticDir
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
|
||||
doFirst {
|
||||
println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..."
|
||||
println "Backend static resources will be preserved"
|
||||
}
|
||||
doLast {
|
||||
println "Frontend assets copied successfully!"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
}
|
||||
|
||||
if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
|
||||
@@ -25,6 +25,7 @@ public class ExternalAppDepConfig {
|
||||
private final String weasyprintPath;
|
||||
private final String unoconvPath;
|
||||
private final Map<String, List<String>> commandToGroupMapping;
|
||||
private volatile boolean dependenciesChecked = false;
|
||||
|
||||
public ExternalAppDepConfig(
|
||||
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
|
||||
@@ -111,6 +112,10 @@ public class ExternalAppDepConfig {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDependenciesChecked() {
|
||||
return dependenciesChecked;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void checkDependencies() {
|
||||
// Check core dependencies
|
||||
@@ -162,5 +167,7 @@ public class ExternalAppDepConfig {
|
||||
}
|
||||
}
|
||||
endpointConfiguration.logDisabledEndpointsSummary();
|
||||
dependenciesChecked = true;
|
||||
log.info("Dependency checks completed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ public class SpringDocConfig {
|
||||
"/api/v1/user/**",
|
||||
"/api/v1/settings/**",
|
||||
"/api/v1/team/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/invite/**",
|
||||
"/api/v1/audit/**",
|
||||
"/api/v1/ui-data/**",
|
||||
"/api/v1/proprietary/ui-data/**",
|
||||
"/api/v1/info/**",
|
||||
@@ -33,7 +36,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Processing API")
|
||||
.description(
|
||||
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
|
||||
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -47,14 +50,17 @@ public class SpringDocConfig {
|
||||
"/api/v1/admin/**",
|
||||
"/api/v1/user/**",
|
||||
"/api/v1/settings/**",
|
||||
"/api/v1/team/**")
|
||||
"/api/v1/team/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/invite/**",
|
||||
"/api/v1/audit/**")
|
||||
.addOpenApiCustomizer(
|
||||
openApi -> {
|
||||
openApi.info(
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Admin API")
|
||||
.title("Stirling PDF - Management API")
|
||||
.description(
|
||||
"API documentation for administrative functions, user management, and system configuration."));
|
||||
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -76,7 +82,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - System API")
|
||||
.description(
|
||||
"API documentation for system information, UI data, and utility endpoints."));
|
||||
"System information, UI metadata, job status, and file management endpoints."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
|
||||
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
.allowedOriginPatterns(
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
|
||||
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.Dependency;
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.annotations.api.UiDataApi;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
|
||||
public class UIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public UIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
@@ -58,6 +58,21 @@ public class UIDataController {
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
@GetMapping("/footer-info")
|
||||
@Operation(summary = "Get public footer configuration data")
|
||||
public ResponseEntity<FooterData> getFooterData() {
|
||||
FooterData data = new FooterData();
|
||||
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
|
||||
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
|
||||
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
|
||||
data.setAccessibilityStatement(
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
|
||||
data.setImpressum(applicationProperties.getLegal().getImpressum());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/home")
|
||||
@Operation(summary = "Get home page data")
|
||||
public ResponseEntity<HomeData> getHomeData() {
|
||||
@@ -237,6 +252,16 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
// Data classes
|
||||
@Data
|
||||
public static class FooterData {
|
||||
private Boolean analyticsEnabled;
|
||||
private String termsAndConditions;
|
||||
private String privacyPolicy;
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HomeData {
|
||||
private boolean showSurveyFromDocker;
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
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.json.PdfJsonDocument;
|
||||
import stirling.software.SPDF.model.json.PdfJsonMetadata;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@Slf4j
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text Editor Format",
|
||||
description =
|
||||
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<byte[]> convertPdfToJson(
|
||||
@ModelAttribute PDFFile request,
|
||||
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("fileInput");
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
: "document";
|
||||
String docName = baseName + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/text-editor/pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Convert Text Editor Format to PDF",
|
||||
description =
|
||||
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> convertJsonToPdf(@ModelAttribute GeneralFile request)
|
||||
throws Exception {
|
||||
MultipartFile jsonFile = request.getFileInput();
|
||||
if (jsonFile == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("fileInput");
|
||||
}
|
||||
|
||||
byte[] pdfBytes = pdfJsonConversionService.convertJsonToPdf(jsonFile);
|
||||
String originalName = jsonFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
: "document";
|
||||
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
|
||||
@Operation(
|
||||
summary = "Extract PDF metadata for text editor lazy loading",
|
||||
description =
|
||||
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
|
||||
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
|
||||
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<byte[]> extractPdfMetadata(@ModelAttribute PDFFile request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("fileInput");
|
||||
}
|
||||
|
||||
// Generate server-side UUID for job
|
||||
String baseJobId = UUID.randomUUID().toString();
|
||||
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
|
||||
byte[] jsonBytes =
|
||||
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
: "document";
|
||||
String docName = baseName + "_metadata.json";
|
||||
|
||||
// Return jobId in response header for client
|
||||
return ResponseEntity.ok()
|
||||
.header("X-Job-Id", scopedJobKey)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(jsonBytes);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/pdf/text-editor/partial/{jobId}",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Apply incremental edits from text editor to a cached PDF",
|
||||
description =
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
|
||||
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
|
||||
+ " The jobId must be obtained from the metadata extraction endpoint.")
|
||||
public ResponseEntity<byte[]> exportPartialPdf(
|
||||
@PathVariable String jobId,
|
||||
@RequestBody PdfJsonDocument document,
|
||||
@RequestParam(value = "filename", required = false) String filename)
|
||||
throws Exception {
|
||||
if (document == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("document");
|
||||
}
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
|
||||
|
||||
String baseName =
|
||||
(filename != null && !filename.isBlank())
|
||||
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
|
||||
: Optional.ofNullable(document.getMetadata())
|
||||
.map(PdfJsonMetadata::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
.orElse("document");
|
||||
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/pdf/text-editor/page/{jobId}/{pageNumber}")
|
||||
@Operation(
|
||||
summary = "Extract single page from cached PDF for text editor",
|
||||
description =
|
||||
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user. Output:JSON")
|
||||
public ResponseEntity<byte[]> extractSinglePage(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
|
||||
String docName = "page_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
"Manually clears a cached PDF document used by the text editor to free up server resources."
|
||||
+ " Called automatically after 30 minutes. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
pdfJsonConversionService.clearCachedDocument(jobId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param baseJobId the base job identifier
|
||||
* @return scoped job key, or just baseJobId if no ownership service available
|
||||
*/
|
||||
private String getScopedJobKey(String baseJobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
return jobOwnershipService.createScopedJobKey(baseJobId);
|
||||
}
|
||||
// Security disabled, return unsecured job key
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param jobId the job identifier to validate
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
private void validateJobAccess(String jobId) {
|
||||
if (jobOwnershipService != null) {
|
||||
jobOwnershipService.validateJobAccess(jobId);
|
||||
}
|
||||
// If jobOwnershipService is null (security disabled), allow all access
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -35,6 +35,7 @@ public class ConfigController {
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -43,12 +44,14 @@ public class ConfigController {
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService) {
|
||||
UserServiceInterface userService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
@@ -56,6 +59,9 @@ public class ConfigController {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
// Add dependency check status
|
||||
configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked());
|
||||
|
||||
// Get AppConfig bean
|
||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||
|
||||
|
||||
+3
-3
@@ -196,9 +196,9 @@ public class StampController {
|
||||
resourceDir =
|
||||
switch (alphabet) {
|
||||
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
|
||||
case "japanese" -> "static/fonts/Meiryo.ttf";
|
||||
case "korean" -> "static/fonts/malgun.ttf";
|
||||
case "chinese" -> "static/fonts/SimSun.ttf";
|
||||
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
|
||||
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
|
||||
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
|
||||
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
|
||||
case "roman" -> "static/fonts/NotoSans-Regular.ttf";
|
||||
default -> "static/fonts/NotoSans-Regular.ttf";
|
||||
|
||||
+3
-3
@@ -171,9 +171,9 @@ public class WatermarkController {
|
||||
resourceDir =
|
||||
switch (alphabet) {
|
||||
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
|
||||
case "japanese" -> "static/fonts/Meiryo.ttf";
|
||||
case "korean" -> "static/fonts/malgun.ttf";
|
||||
case "chinese" -> "static/fonts/SimSun.ttf";
|
||||
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
|
||||
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
|
||||
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
|
||||
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
|
||||
default -> "static/fonts/NotoSans-Regular.ttf";
|
||||
};
|
||||
|
||||
+3
-3
@@ -25,7 +25,7 @@ import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
|
||||
@Slf4j
|
||||
public class GeneralWebController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public GeneralWebController(
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
|
||||
+6
-3
@@ -1,16 +1,19 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@RequestMapping("/api/v1/general")
|
||||
public class SignatureController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public SignatureController(
|
||||
SignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.signatureService = signatureService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/sign/{fileName}")
|
||||
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
|
||||
throws IOException {
|
||||
String username = "NON_SECURITY_USER";
|
||||
if (userService != null) {
|
||||
username = userService.getCurrentUsername();
|
||||
}
|
||||
// Verify access permission
|
||||
if (!signatureService.hasAccessToFile(username, fileName)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
|
||||
return ResponseEntity.ok()
|
||||
.contentType( // Adjust based on file type
|
||||
MediaType.IMAGE_JPEG)
|
||||
.body(imageBytes);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.service.PersonalSignatureServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Unified signature image controller that works for both authenticated and unauthenticated users.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
|
||||
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
|
||||
* authenticated signature management (save/delete), see proprietary SignatureController.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
public class SignatureImageController {
|
||||
|
||||
private final SharedSignatureService sharedSignatureService;
|
||||
private final PersonalSignatureServiceInterface personalSignatureService;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public SignatureImageController(
|
||||
SharedSignatureService sharedSignatureService,
|
||||
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.sharedSignatureService = sharedSignatureService;
|
||||
this.personalSignatureService = personalSignatureService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signature image (works for both authenticated and unauthenticated users). -
|
||||
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
|
||||
* community: tries shared only
|
||||
*/
|
||||
@GetMapping("/signatures/{fileName}")
|
||||
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
|
||||
try {
|
||||
byte[] imageBytes = null;
|
||||
|
||||
// If proprietary service available and user authenticated, try personal folder first
|
||||
if (personalSignatureService != null && userService != null) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
imageBytes =
|
||||
personalSignatureService.getPersonalSignatureBytes(username, fileName);
|
||||
} catch (Exception e) {
|
||||
// Not found in personal folder or not authenticated, will try shared
|
||||
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in personal (or no personal service), try shared
|
||||
if (imageBytes == null) {
|
||||
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
|
||||
}
|
||||
|
||||
// Determine content type from file extension
|
||||
MediaType contentType = MediaType.IMAGE_PNG; // Default
|
||||
String lowerFileName = fileName.toLowerCase();
|
||||
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
|
||||
contentType = MediaType.IMAGE_JPEG;
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
|
||||
} catch (IOException e) {
|
||||
log.debug("Signature not found: {}", fileName);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.model.api;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PdfJsonConversionProgress {
|
||||
private int percent;
|
||||
private String stage;
|
||||
private String message;
|
||||
private boolean complete;
|
||||
private Integer current; // Current item being processed (e.g., page number)
|
||||
private Integer total; // Total items to process (e.g., total pages)
|
||||
|
||||
public static PdfJsonConversionProgress of(int percent, String stage, String message) {
|
||||
return PdfJsonConversionProgress.builder()
|
||||
.percent(percent)
|
||||
.stage(stage)
|
||||
.message(message)
|
||||
.complete(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static PdfJsonConversionProgress of(
|
||||
int percent, String stage, String message, int current, int total) {
|
||||
return PdfJsonConversionProgress.builder()
|
||||
.percent(percent)
|
||||
.stage(stage)
|
||||
.message(message)
|
||||
.current(current)
|
||||
.total(total)
|
||||
.complete(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static PdfJsonConversionProgress complete() {
|
||||
return PdfJsonConversionProgress.builder()
|
||||
.percent(100)
|
||||
.stage("complete")
|
||||
.message("Conversion complete")
|
||||
.complete(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.SPDF.model.api.signature;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class SavedSignatureRequest {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.SPDF.model.api.signature;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SavedSignatureResponse {
|
||||
private String id;
|
||||
private String label;
|
||||
private String type; // "canvas", "image", "text"
|
||||
private String scope; // "personal", "shared"
|
||||
private String dataUrl; // For canvas and image types (or URL to fetch image)
|
||||
private String signerName; // For text type
|
||||
private String fontFamily; // For text type
|
||||
private Integer fontSize; // For text type
|
||||
private String textColor; // For text type
|
||||
private Long createdAt;
|
||||
private Long updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Represents a PDF annotation (comments, highlights, stamps, etc.). Annotations often contain OCR
|
||||
* text layers or other metadata not visible in content streams.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonAnnotation {
|
||||
|
||||
/** Annotation subtype (Text, Highlight, Link, Stamp, Widget, etc.) */
|
||||
private String subtype;
|
||||
|
||||
/** Human-readable text content of the annotation */
|
||||
private String contents;
|
||||
|
||||
/** Annotation rectangle [x1, y1, x2, y2] */
|
||||
private List<Float> rect;
|
||||
|
||||
/** Annotation appearance characteristics */
|
||||
private String appearanceState;
|
||||
|
||||
/** Color components (e.g., [r, g, b] for RGB) */
|
||||
private List<Float> color;
|
||||
|
||||
/** Annotation flags (print, hidden, etc.) */
|
||||
private Integer flags;
|
||||
|
||||
/** For link annotations: destination or action */
|
||||
private String destination;
|
||||
|
||||
/** For text annotations: icon name */
|
||||
private String iconName;
|
||||
|
||||
/** Subject/title of the annotation */
|
||||
private String subject;
|
||||
|
||||
/** Author of the annotation */
|
||||
private String author;
|
||||
|
||||
/** Creation date (ISO 8601 format) */
|
||||
private String creationDate;
|
||||
|
||||
/** Modification date (ISO 8601 format) */
|
||||
private String modificationDate;
|
||||
|
||||
/** Full annotation dictionary for lossless round-tripping */
|
||||
private PdfJsonCosValue rawData;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonCosValue {
|
||||
|
||||
public enum Type {
|
||||
NULL,
|
||||
BOOLEAN,
|
||||
INTEGER,
|
||||
FLOAT,
|
||||
NAME,
|
||||
STRING,
|
||||
ARRAY,
|
||||
DICTIONARY,
|
||||
STREAM
|
||||
}
|
||||
|
||||
private Type type;
|
||||
|
||||
/**
|
||||
* Holds the decoded value for primitives (boolean, integer, float, name, string). For name
|
||||
* values the stored value is the PDF name literal. For string values the content is Base64
|
||||
* encoded to safely transport arbitrary binaries.
|
||||
*/
|
||||
private Object value;
|
||||
|
||||
/** Reference to nested values for arrays. */
|
||||
private List<PdfJsonCosValue> items;
|
||||
|
||||
/** Reference to nested values for dictionaries. */
|
||||
private Map<String, PdfJsonCosValue> entries;
|
||||
|
||||
/** Stream payload when {@code type == STREAM}. */
|
||||
private PdfJsonStream stream;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonDocument {
|
||||
|
||||
private PdfJsonMetadata metadata;
|
||||
|
||||
/** Optional XMP metadata packet stored as Base64. */
|
||||
private String xmpMetadata;
|
||||
|
||||
/** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */
|
||||
private Boolean lazyImages;
|
||||
|
||||
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
|
||||
|
||||
@Builder.Default private List<PdfJsonPage> pages = new ArrayList<>();
|
||||
|
||||
/** Form fields (AcroForm) at document level */
|
||||
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonDocumentMetadata {
|
||||
|
||||
private PdfJsonMetadata metadata;
|
||||
|
||||
/** Optional XMP metadata packet stored as Base64. */
|
||||
private String xmpMetadata;
|
||||
|
||||
/** Indicates that images should be requested lazily via the page endpoint. */
|
||||
private Boolean lazyImages;
|
||||
|
||||
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
|
||||
|
||||
@Builder.Default private List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
|
||||
|
||||
/** Form fields (AcroForm) at document level */
|
||||
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonFont {
|
||||
|
||||
/** PDF resource name (e.g. F1) used as the primary identifier. */
|
||||
private String id;
|
||||
|
||||
/** Logical page number that owns this font resource. */
|
||||
private Integer pageNumber;
|
||||
|
||||
/** Stable UID combining page number and resource for diagnostics. */
|
||||
private String uid;
|
||||
|
||||
/** Reported PostScript/Base font name. */
|
||||
private String baseName;
|
||||
|
||||
/** Declared subtype in the COS dictionary. */
|
||||
private String subtype;
|
||||
|
||||
/** Encoding dictionary or name. */
|
||||
private String encoding;
|
||||
|
||||
/** CID system info for Type0 fonts. */
|
||||
private PdfJsonFontCidSystemInfo cidSystemInfo;
|
||||
|
||||
/** True when the original PDF embedded the font program. */
|
||||
private Boolean embedded;
|
||||
|
||||
/** Font program bytes (TTF/OTF/CFF/PFB) encoded as Base64. */
|
||||
private String program;
|
||||
|
||||
/** Hint describing the font program type (ttf, otf, cff, pfb, etc.). */
|
||||
private String programFormat;
|
||||
|
||||
/** Web-optimized font program (e.g. converted TrueType) encoded as Base64. */
|
||||
private String webProgram;
|
||||
|
||||
/** Format hint for the webProgram payload. */
|
||||
private String webProgramFormat;
|
||||
|
||||
/** PDF-friendly font program (e.g. converted TrueType) encoded as Base64. */
|
||||
private String pdfProgram;
|
||||
|
||||
/** Format hint for the pdfProgram payload. */
|
||||
private String pdfProgramFormat;
|
||||
|
||||
/** Glyph metadata for Type3 fonts to enable precise text rewrites. */
|
||||
private List<PdfJsonFontType3Glyph> type3Glyphs;
|
||||
|
||||
/** Per-strategy synthesized font payloads for Type3 normalization. */
|
||||
private List<PdfJsonFontConversionCandidate> conversionCandidates;
|
||||
|
||||
/** ToUnicode stream encoded as Base64 when present. */
|
||||
private String toUnicode;
|
||||
|
||||
/** Mapped Standard 14 font name when available. */
|
||||
private String standard14Name;
|
||||
|
||||
/** Font descriptor flags copied from the source document. */
|
||||
private Integer fontDescriptorFlags;
|
||||
|
||||
/** Font ascent in glyph units (typically 1/1000). */
|
||||
private Float ascent;
|
||||
|
||||
/** Font descent in glyph units (typically negative). */
|
||||
private Float descent;
|
||||
|
||||
/** Capital height when available. */
|
||||
private Float capHeight;
|
||||
|
||||
/** x-height when available. */
|
||||
private Float xHeight;
|
||||
|
||||
/** Italic angle reported by the font descriptor. */
|
||||
private Float italicAngle;
|
||||
|
||||
/** Units per em extracted from the font matrix. */
|
||||
private Integer unitsPerEm;
|
||||
|
||||
/** Serialized COS dictionary describing the original font resource. */
|
||||
private PdfJsonCosValue cosDictionary;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonFontCidSystemInfo {
|
||||
|
||||
private String registry;
|
||||
private String ordering;
|
||||
private Integer supplement;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonFontConversionCandidate {
|
||||
|
||||
/** Stable identifier for the strategy that produced this candidate. */
|
||||
private String strategyId;
|
||||
|
||||
/** Human-readable label for diagnostics and UI toggles. */
|
||||
private String strategyLabel;
|
||||
|
||||
/** Outcome of the conversion attempt. */
|
||||
private PdfJsonFontConversionStatus status;
|
||||
|
||||
/** Summary diagnostics or error details. */
|
||||
private String message;
|
||||
|
||||
/** Count of glyphs successfully synthesized. */
|
||||
private Integer synthesizedGlyphs;
|
||||
|
||||
/** Count of glyphs that could not be reproduced accurately. */
|
||||
private Integer missingGlyphs;
|
||||
|
||||
/** Approximate width delta (in glyph units) across the test sample. */
|
||||
private Double widthDelta;
|
||||
|
||||
/** Approximate bounding box delta (in glyph units). */
|
||||
private Double bboxDelta;
|
||||
|
||||
/** Base64-encoded font program (typically TTF/OTF) produced by the strategy. */
|
||||
private String program;
|
||||
|
||||
/** Format hint for {@link #program}. */
|
||||
private String programFormat;
|
||||
|
||||
/** Web-optimized payload (e.g. TTF) for browser preview. */
|
||||
private String webProgram;
|
||||
|
||||
/** Format for the web payload. */
|
||||
private String webProgramFormat;
|
||||
|
||||
/** PDF-friendly payload for re-embedding during export. */
|
||||
private String pdfProgram;
|
||||
|
||||
/** Format for the PDF payload. */
|
||||
private String pdfProgramFormat;
|
||||
|
||||
/** Optional PNG preview of rendered glyphs (Base64). */
|
||||
private String previewImage;
|
||||
|
||||
/** Additional structured diagnostics (JSON string). */
|
||||
private String diagnostics;
|
||||
|
||||
/** Known unicode/codepoint coverage derived from the conversion strategy. */
|
||||
private List<Integer> glyphCoverage;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
public enum PdfJsonFontConversionStatus {
|
||||
SUCCESS,
|
||||
WARNING,
|
||||
FAILURE,
|
||||
SKIPPED,
|
||||
UNSUPPORTED
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonFontType3Glyph {
|
||||
/** Character code used in the content stream to reference this glyph. */
|
||||
private Integer charCode;
|
||||
|
||||
/** PostScript glyph name, when available. */
|
||||
private String glyphName;
|
||||
|
||||
/** Unicode code point represented by this glyph, if it can be resolved. */
|
||||
private Integer unicode;
|
||||
|
||||
/** Raw char code used in the Type3 font encoding (0-255). */
|
||||
private Integer charCodeRaw;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Represents a PDF form field (AcroForm). */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonFormField {
|
||||
|
||||
/** Fully qualified field name (e.g., "form1.textfield1") */
|
||||
private String name;
|
||||
|
||||
/** Partial field name (last component) */
|
||||
private String partialName;
|
||||
|
||||
/** Field type (Tx=text, Btn=button, Ch=choice, Sig=signature) */
|
||||
private String fieldType;
|
||||
|
||||
/** Field value as string */
|
||||
private String value;
|
||||
|
||||
/** Default value */
|
||||
private String defaultValue;
|
||||
|
||||
/** Field flags (readonly, required, multiline, etc.) */
|
||||
private Integer flags;
|
||||
|
||||
/** Alternative field name (for accessibility) */
|
||||
private String alternateFieldName;
|
||||
|
||||
/** Mapping name (for export) */
|
||||
private String mappingName;
|
||||
|
||||
/** Page number where field appears (1-indexed) */
|
||||
private Integer pageNumber;
|
||||
|
||||
/** Field rectangle [x1, y1, x2, y2] on the page */
|
||||
private List<Float> rect;
|
||||
|
||||
/** For choice fields: list of options */
|
||||
private List<String> options;
|
||||
|
||||
/** For choice fields: selected indices */
|
||||
private List<Integer> selectedIndices;
|
||||
|
||||
/** For button fields: whether it's checked */
|
||||
private Boolean checked;
|
||||
|
||||
/** Font information for text fields */
|
||||
private String fontName;
|
||||
|
||||
private Float fontSize;
|
||||
|
||||
/** Full field dictionary for lossless round-tripping */
|
||||
private PdfJsonCosValue rawData;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonImageElement {
|
||||
|
||||
private String id;
|
||||
private String objectName;
|
||||
private Boolean inlineImage;
|
||||
private Integer nativeWidth;
|
||||
private Integer nativeHeight;
|
||||
private Float x;
|
||||
private Float y;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private Float left;
|
||||
private Float right;
|
||||
private Float top;
|
||||
private Float bottom;
|
||||
@Builder.Default private List<Float> transform = new ArrayList<>();
|
||||
private Integer zOrder;
|
||||
private String imageData;
|
||||
private String imageFormat;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonMetadata {
|
||||
|
||||
private String title;
|
||||
private String author;
|
||||
private String subject;
|
||||
private String keywords;
|
||||
private String creator;
|
||||
private String producer;
|
||||
private String creationDate;
|
||||
private String modificationDate;
|
||||
private String trapped;
|
||||
private Integer numberOfPages;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonPage {
|
||||
|
||||
private Integer pageNumber;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private Integer rotation;
|
||||
|
||||
@Builder.Default private List<PdfJsonTextElement> textElements = new ArrayList<>();
|
||||
@Builder.Default private List<PdfJsonImageElement> imageElements = new ArrayList<>();
|
||||
@Builder.Default private List<PdfJsonAnnotation> annotations = new ArrayList<>();
|
||||
|
||||
/** Serialized representation of the page resources dictionary. */
|
||||
private PdfJsonCosValue resources;
|
||||
|
||||
/** Raw content streams associated with the page, preserved for lossless round-tripping. */
|
||||
@Builder.Default private List<PdfJsonStream> contentStreams = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonPageDimension {
|
||||
private Integer pageNumber;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private Integer rotation;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonStream {
|
||||
|
||||
/**
|
||||
* A dictionary of entries that describe the stream metadata (Filter, DecodeParms, etc). Each
|
||||
* entry is represented using {@link PdfJsonCosValue} so nested structures are supported.
|
||||
*/
|
||||
private Map<String, PdfJsonCosValue> dictionary;
|
||||
|
||||
/** Raw stream bytes in Base64 form. Data is stored exactly as it appeared in the source PDF. */
|
||||
private String rawData;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonTextColor {
|
||||
|
||||
private String colorSpace;
|
||||
private List<Float> components;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PdfJsonTextElement {
|
||||
|
||||
private String text;
|
||||
private String fontId;
|
||||
private Float fontSize;
|
||||
private Float fontMatrixSize;
|
||||
private Float fontSizeInPt;
|
||||
private Float characterSpacing;
|
||||
private Float wordSpacing;
|
||||
private Float spaceWidth;
|
||||
private Integer zOrder;
|
||||
private Float horizontalScaling;
|
||||
private Float leading;
|
||||
private Float rise;
|
||||
private Float x;
|
||||
private Float y;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private List<Float> textMatrix;
|
||||
private PdfJsonTextColor fillColor;
|
||||
private PdfJsonTextColor strokeColor;
|
||||
private Integer renderingMode;
|
||||
private Boolean fallbackUsed;
|
||||
private List<Integer> charCodes;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSBoolean;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSInteger;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSNull;
|
||||
import org.apache.pdfbox.cos.COSObject;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonCosValue;
|
||||
import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PdfJsonCosMapper {
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(
|
||||
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
|
||||
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
}
|
||||
|
||||
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
|
||||
throws IOException {
|
||||
if (value == null || value.getType() == null) {
|
||||
return null;
|
||||
}
|
||||
switch (value.getType()) {
|
||||
case NULL:
|
||||
return COSNull.NULL;
|
||||
case BOOLEAN:
|
||||
if (value.getValue() instanceof Boolean bool) {
|
||||
return COSBoolean.getBoolean(bool);
|
||||
}
|
||||
return null;
|
||||
case INTEGER:
|
||||
if (value.getValue() instanceof Number number) {
|
||||
return COSInteger.get(number.longValue());
|
||||
}
|
||||
return null;
|
||||
case FLOAT:
|
||||
if (value.getValue() instanceof Number number) {
|
||||
return new COSFloat(number.floatValue());
|
||||
}
|
||||
return null;
|
||||
case NAME:
|
||||
if (value.getValue() instanceof String name) {
|
||||
return COSName.getPDFName(name);
|
||||
}
|
||||
return null;
|
||||
case STRING:
|
||||
if (value.getValue() instanceof String encoded) {
|
||||
try {
|
||||
byte[] bytes = Base64.getDecoder().decode(encoded);
|
||||
return new COSString(bytes);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.debug("Failed to decode COSString value: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case ARRAY:
|
||||
COSArray array = new COSArray();
|
||||
if (value.getItems() != null) {
|
||||
for (PdfJsonCosValue item : value.getItems()) {
|
||||
COSBase entry = deserializeCosValue(item, document);
|
||||
if (entry != null) {
|
||||
array.add(entry);
|
||||
} else {
|
||||
array.add(COSNull.NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
return array;
|
||||
case DICTIONARY:
|
||||
COSDictionary dictionary = new COSDictionary();
|
||||
if (value.getEntries() != null) {
|
||||
for (Map.Entry<String, PdfJsonCosValue> entry : value.getEntries().entrySet()) {
|
||||
COSName key = COSName.getPDFName(entry.getKey());
|
||||
COSBase entryValue = deserializeCosValue(entry.getValue(), document);
|
||||
if (entryValue != null) {
|
||||
dictionary.setItem(key, entryValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dictionary;
|
||||
case STREAM:
|
||||
if (value.getStream() != null) {
|
||||
return buildStreamFromModel(value.getStream(), document);
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public COSStream buildStreamFromModel(PdfJsonStream streamModel, PDDocument document)
|
||||
throws IOException {
|
||||
if (streamModel == null) {
|
||||
return null;
|
||||
}
|
||||
COSStream cosStream = document.getDocument().createCOSStream();
|
||||
if (streamModel.getDictionary() != null) {
|
||||
for (Map.Entry<String, PdfJsonCosValue> entry :
|
||||
streamModel.getDictionary().entrySet()) {
|
||||
COSName key = COSName.getPDFName(entry.getKey());
|
||||
COSBase value = deserializeCosValue(entry.getValue(), document);
|
||||
if (value != null) {
|
||||
cosStream.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String rawData = streamModel.getRawData();
|
||||
if (rawData != null && !rawData.isBlank()) {
|
||||
byte[] data;
|
||||
try {
|
||||
data = Base64.getDecoder().decode(rawData);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.debug("Invalid base64 content stream data: {}", ex.getMessage());
|
||||
data = new byte[0];
|
||||
}
|
||||
try (OutputStream outputStream = cosStream.createRawOutputStream()) {
|
||||
outputStream.write(data);
|
||||
}
|
||||
cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length));
|
||||
} else {
|
||||
cosStream.setItem(COSName.LENGTH, COSInteger.get(0));
|
||||
}
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
if (base instanceof COSObject cosObject) {
|
||||
base = cosObject.getObject();
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean complex =
|
||||
base instanceof COSDictionary
|
||||
|| base instanceof COSArray
|
||||
|| base instanceof COSStream;
|
||||
if (complex) {
|
||||
if (!visited.add(base)) {
|
||||
return PdfJsonCosValue.builder()
|
||||
.type(PdfJsonCosValue.Type.NAME)
|
||||
.value("__circular__")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
PdfJsonCosValue.PdfJsonCosValueBuilder builder = PdfJsonCosValue.builder();
|
||||
if (base instanceof COSNull) {
|
||||
builder.type(PdfJsonCosValue.Type.NULL);
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSBoolean booleanValue) {
|
||||
builder.type(PdfJsonCosValue.Type.BOOLEAN).value(booleanValue.getValue());
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSInteger integer) {
|
||||
builder.type(PdfJsonCosValue.Type.INTEGER).value(integer.longValue());
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSFloat floatValue) {
|
||||
builder.type(PdfJsonCosValue.Type.FLOAT).value(floatValue.floatValue());
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSName name) {
|
||||
builder.type(PdfJsonCosValue.Type.NAME).value(name.getName());
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSString cosString) {
|
||||
builder.type(PdfJsonCosValue.Type.STRING)
|
||||
.value(Base64.getEncoder().encodeToString(cosString.getBytes()));
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSArray array) {
|
||||
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
|
||||
for (COSBase item : array) {
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited);
|
||||
items.add(serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSStream stream) {
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSDictionary dictionary) {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
for (COSName key : dictionary.keySet()) {
|
||||
PdfJsonCosValue serialized =
|
||||
serializeCosValue(dictionary.getDictionaryObject(key), visited);
|
||||
entries.put(key.getName(), serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
|
||||
return builder.build();
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (complex) {
|
||||
visited.remove(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
|
||||
for (COSName key : cosStream.keySet()) {
|
||||
COSBase value = cosStream.getDictionaryObject(key);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited);
|
||||
if (serialized != null) {
|
||||
dictionary.put(key.getName(), serialized);
|
||||
}
|
||||
}
|
||||
String rawData = null;
|
||||
try (InputStream inputStream = cosStream.createRawInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
if (inputStream != null) {
|
||||
inputStream.transferTo(baos);
|
||||
}
|
||||
byte[] data = baos.toByteArray();
|
||||
if (data.length > 0) {
|
||||
rawData = Base64.getEncoder().encodeToString(data);
|
||||
}
|
||||
}
|
||||
return PdfJsonStream.builder().dictionary(dictionary).rawData(rawData).build();
|
||||
}
|
||||
}
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonFont;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PdfJsonFallbackFontService {
|
||||
|
||||
public static final String FALLBACK_FONT_ID = "fallback-noto-sans";
|
||||
public static final String DEFAULT_FALLBACK_FONT_LOCATION =
|
||||
"classpath:/static/fonts/NotoSans-Regular.ttf";
|
||||
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
|
||||
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
|
||||
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
|
||||
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
|
||||
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
|
||||
|
||||
// Font name aliases map PDF font names to available fallback fonts
|
||||
// This provides better visual consistency when editing PDFs
|
||||
private static final Map<String, String> FONT_NAME_ALIASES =
|
||||
Map.ofEntries(
|
||||
// Liberation fonts are metric-compatible with Microsoft core fonts
|
||||
Map.entry("arial", "fallback-liberation-sans"),
|
||||
Map.entry("helvetica", "fallback-liberation-sans"),
|
||||
Map.entry("arimo", "fallback-liberation-sans"),
|
||||
Map.entry("liberationsans", "fallback-liberation-sans"),
|
||||
Map.entry("times", "fallback-liberation-serif"),
|
||||
Map.entry("timesnewroman", "fallback-liberation-serif"),
|
||||
Map.entry("tinos", "fallback-liberation-serif"),
|
||||
Map.entry("liberationserif", "fallback-liberation-serif"),
|
||||
Map.entry("courier", "fallback-liberation-mono"),
|
||||
Map.entry("couriernew", "fallback-liberation-mono"),
|
||||
Map.entry("cousine", "fallback-liberation-mono"),
|
||||
Map.entry("liberationmono", "fallback-liberation-mono"),
|
||||
// DejaVu fonts - widely used open source fonts
|
||||
Map.entry("dejavu", "fallback-dejavu-sans"),
|
||||
Map.entry("dejavusans", "fallback-dejavu-sans"),
|
||||
Map.entry("dejavuserif", "fallback-dejavu-serif"),
|
||||
Map.entry("dejavumono", "fallback-dejavu-mono"),
|
||||
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
|
||||
// Noto Sans - Google's universal font (use as last resort generic fallback)
|
||||
Map.entry("noto", "fallback-noto-sans"),
|
||||
Map.entry("notosans", "fallback-noto-sans"));
|
||||
|
||||
private static final Map<String, FallbackFontSpec> BUILT_IN_FALLBACK_FONTS =
|
||||
Map.ofEntries(
|
||||
Map.entry(
|
||||
FALLBACK_FONT_CJK_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansSC-Regular.ttf",
|
||||
"NotoSansSC-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_JP_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansJP-Regular.ttf",
|
||||
"NotoSansJP-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_KR_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
|
||||
"NotoSansKR-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_AR_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansArabic-Regular.ttf",
|
||||
"NotoSansArabic-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_TH_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
|
||||
"NotoSansThai-Regular",
|
||||
"ttf")),
|
||||
// Liberation Sans family
|
||||
Map.entry(
|
||||
"fallback-liberation-sans",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSans-Regular.ttf",
|
||||
"LiberationSans-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-sans-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSans-Bold.ttf",
|
||||
"LiberationSans-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-sans-italic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSans-Italic.ttf",
|
||||
"LiberationSans-Italic",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-sans-bolditalic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSans-BoldItalic.ttf",
|
||||
"LiberationSans-BoldItalic",
|
||||
"ttf")),
|
||||
// Liberation Serif family
|
||||
Map.entry(
|
||||
"fallback-liberation-serif",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSerif-Regular.ttf",
|
||||
"LiberationSerif-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-serif-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSerif-Bold.ttf",
|
||||
"LiberationSerif-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-serif-italic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSerif-Italic.ttf",
|
||||
"LiberationSerif-Italic",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-serif-bolditalic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationSerif-BoldItalic.ttf",
|
||||
"LiberationSerif-BoldItalic",
|
||||
"ttf")),
|
||||
// Liberation Mono family
|
||||
Map.entry(
|
||||
"fallback-liberation-mono",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationMono-Regular.ttf",
|
||||
"LiberationMono-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-mono-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationMono-Bold.ttf",
|
||||
"LiberationMono-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-mono-italic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationMono-Italic.ttf",
|
||||
"LiberationMono-Italic",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-liberation-mono-bolditalic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/LiberationMono-BoldItalic.ttf",
|
||||
"LiberationMono-BoldItalic",
|
||||
"ttf")),
|
||||
// Noto Sans family (enhanced with weight variants)
|
||||
Map.entry(
|
||||
FALLBACK_FONT_ID,
|
||||
new FallbackFontSpec(
|
||||
DEFAULT_FALLBACK_FONT_LOCATION, "NotoSans-Regular", "ttf")),
|
||||
Map.entry(
|
||||
"fallback-noto-sans-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSans-Bold.ttf",
|
||||
"NotoSans-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-noto-sans-italic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSans-Italic.ttf",
|
||||
"NotoSans-Italic",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-noto-sans-bolditalic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSans-BoldItalic.ttf",
|
||||
"NotoSans-BoldItalic",
|
||||
"ttf")),
|
||||
// DejaVu Sans family
|
||||
Map.entry(
|
||||
"fallback-dejavu-sans",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSans.ttf", "DejaVuSans", "ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-sans-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSans-Bold.ttf",
|
||||
"DejaVuSans-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-sans-oblique",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSans-Oblique.ttf",
|
||||
"DejaVuSans-Oblique",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-sans-boldoblique",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSans-BoldOblique.ttf",
|
||||
"DejaVuSans-BoldOblique",
|
||||
"ttf")),
|
||||
// DejaVu Serif family
|
||||
Map.entry(
|
||||
"fallback-dejavu-serif",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSerif.ttf",
|
||||
"DejaVuSerif",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-serif-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSerif-Bold.ttf",
|
||||
"DejaVuSerif-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-serif-italic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSerif-Italic.ttf",
|
||||
"DejaVuSerif-Italic",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-serif-bolditalic",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSerif-BoldItalic.ttf",
|
||||
"DejaVuSerif-BoldItalic",
|
||||
"ttf")),
|
||||
// DejaVu Mono family
|
||||
Map.entry(
|
||||
"fallback-dejavu-mono",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSansMono.ttf",
|
||||
"DejaVuSansMono",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-mono-bold",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSansMono-Bold.ttf",
|
||||
"DejaVuSansMono-Bold",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-mono-oblique",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSansMono-Oblique.ttf",
|
||||
"DejaVuSansMono-Oblique",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
"fallback-dejavu-mono-boldoblique",
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
|
||||
"DejaVuSansMono-BoldOblique",
|
||||
"ttf")));
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
|
||||
private String fallbackFontLocation;
|
||||
|
||||
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
|
||||
|
||||
public PdfJsonFont buildFallbackFontModel() throws IOException {
|
||||
return buildFallbackFontModel(FALLBACK_FONT_ID);
|
||||
}
|
||||
|
||||
public PdfJsonFont buildFallbackFontModel(String fallbackId) throws IOException {
|
||||
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
|
||||
if (spec == null) {
|
||||
throw new IOException("Unknown fallback font id " + fallbackId);
|
||||
}
|
||||
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
|
||||
String base64 = java.util.Base64.getEncoder().encodeToString(bytes);
|
||||
return PdfJsonFont.builder()
|
||||
.id(fallbackId)
|
||||
.uid(fallbackId)
|
||||
.baseName(spec.baseName())
|
||||
.subtype("TrueType")
|
||||
.embedded(true)
|
||||
.program(base64)
|
||||
.programFormat(spec.format())
|
||||
.build();
|
||||
}
|
||||
|
||||
public PDFont loadFallbackPdfFont(PDDocument document) throws IOException {
|
||||
return loadFallbackPdfFont(document, FALLBACK_FONT_ID);
|
||||
}
|
||||
|
||||
public PDFont loadFallbackPdfFont(PDDocument document, String fallbackId) throws IOException {
|
||||
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
|
||||
if (spec == null) {
|
||||
throw new IOException("Unknown fallback font id " + fallbackId);
|
||||
}
|
||||
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
|
||||
try (InputStream stream = new ByteArrayInputStream(bytes)) {
|
||||
// Load with embedSubset=false to ensure full glyph coverage
|
||||
// Fallback fonts need all glyphs available for substituting missing characters
|
||||
return PDType0Font.load(document, stream, false);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canEncodeFully(PDFont font, String text) {
|
||||
return canEncode(font, text);
|
||||
}
|
||||
|
||||
public boolean canEncode(PDFont font, int codePoint) {
|
||||
return canEncode(font, new String(Character.toChars(codePoint)));
|
||||
}
|
||||
|
||||
public boolean canEncode(PDFont font, String text) {
|
||||
if (font == null || text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (font instanceof PDType3Font) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
font.encode(text);
|
||||
return true;
|
||||
} catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) {
|
||||
// Only log at debug level to reduce verbosity - summary is logged elsewhere
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Font {} cannot encode text '{}' ({}): {}",
|
||||
font != null ? font.getName() : "null",
|
||||
text,
|
||||
font != null ? font.getClass().getSimpleName() : "null",
|
||||
ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback font ID based on the original font name and code point. Attempts to match
|
||||
* font family and weight/style for visual consistency.
|
||||
*
|
||||
* @param originalFontName the name of the original font (may be null)
|
||||
* @param codePoint the Unicode code point that needs to be rendered
|
||||
* @return fallback font ID
|
||||
*/
|
||||
public String resolveFallbackFontId(String originalFontName, int codePoint) {
|
||||
// First try to match based on original font name for visual consistency
|
||||
if (originalFontName != null && !originalFontName.isEmpty()) {
|
||||
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
|
||||
// remove spaces
|
||||
String normalized =
|
||||
originalFontName
|
||||
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
|
||||
.toLowerCase()
|
||||
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
// Extract base name without weight/style suffixes
|
||||
// Split on common delimiters: hyphen, underscore, comma, plus
|
||||
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
|
||||
String baseName = normalized.split("[-_,+]")[0];
|
||||
|
||||
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
|
||||
if (aliasedFontId != null) {
|
||||
// Detect weight and style from the normalized font name
|
||||
boolean isBold = detectBold(normalized);
|
||||
boolean isItalic = detectItalic(normalized);
|
||||
|
||||
// Apply weight/style suffix to fallback font ID
|
||||
String styledFontId = applyWeightStyle(aliasedFontId, isBold, isItalic);
|
||||
|
||||
log.debug(
|
||||
"Matched font '{}' (normalized: '{}', base: '{}', bold: {}, italic: {}) to fallback '{}'",
|
||||
originalFontName,
|
||||
normalized,
|
||||
baseName,
|
||||
isBold,
|
||||
isItalic,
|
||||
styledFontId);
|
||||
return styledFontId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Unicode-based selection
|
||||
return resolveFallbackFontId(codePoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if font name indicates bold weight.
|
||||
*
|
||||
* @param normalizedFontName lowercase font name without subset prefix or spaces
|
||||
* @return true if bold weight is detected
|
||||
*/
|
||||
private boolean detectBold(String normalizedFontName) {
|
||||
// Check for explicit bold indicators
|
||||
if (normalizedFontName.contains("bold")
|
||||
|| normalizedFontName.contains("heavy")
|
||||
|| normalizedFontName.contains("black")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for numeric weight indicators (600-900 = bold)
|
||||
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
|
||||
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if font name indicates italic/oblique style.
|
||||
*
|
||||
* @param normalizedFontName lowercase font name without subset prefix or spaces
|
||||
* @return true if italic style is detected
|
||||
*/
|
||||
private boolean detectItalic(String normalizedFontName) {
|
||||
return normalizedFontName.contains("italic") || normalizedFontName.contains("oblique");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply weight/style suffix to fallback font ID.
|
||||
*
|
||||
* <p>Weight/style variants are only applied to font families where we have the actual font
|
||||
* files available. Currently supported: - Liberation Sans: Regular, Bold, Italic, BoldItalic
|
||||
* (full support) - Liberation Serif: Regular, Bold, Italic, BoldItalic (full support) -
|
||||
* Liberation Mono: Regular, Bold, Italic, BoldItalic (full support) - Noto Sans: Regular, Bold,
|
||||
* Italic, BoldItalic (full support) - DejaVu Sans: Regular, Bold, Oblique, BoldOblique (full
|
||||
* support) - DejaVu Serif: Regular, Bold, Italic, BoldItalic (full support) - DejaVu Mono:
|
||||
* Regular, Bold, Oblique, BoldOblique (full support)
|
||||
*
|
||||
* <p>To add weight/style support for additional font families: 1. Download the font files
|
||||
* (Bold, Italic, BoldItalic) to: app/core/src/main/resources/static/fonts/ 2. Register the
|
||||
* variants in BUILT_IN_FALLBACK_FONTS map (see lines 63-267) 3. Update the check below to
|
||||
* include the font family prefix
|
||||
*
|
||||
* @param baseFontId base fallback font ID (e.g., "fallback-liberation-sans")
|
||||
* @param isBold true if bold weight needed
|
||||
* @param isItalic true if italic style needed
|
||||
* @return styled font ID (e.g., "fallback-liberation-sans-bold"), or base ID if variants not
|
||||
* available
|
||||
*/
|
||||
private String applyWeightStyle(String baseFontId, boolean isBold, boolean isItalic) {
|
||||
// Only apply weight/style to font families where we have the font files available
|
||||
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
|
||||
boolean isSupported =
|
||||
baseFontId.startsWith("fallback-liberation-")
|
||||
|| baseFontId.equals("fallback-noto-sans")
|
||||
|| baseFontId.startsWith("fallback-dejavu-");
|
||||
|
||||
if (!isSupported) {
|
||||
return baseFontId;
|
||||
}
|
||||
|
||||
// DejaVu Sans and Mono use "oblique" instead of "italic"
|
||||
boolean useOblique =
|
||||
baseFontId.equals("fallback-dejavu-sans")
|
||||
|| baseFontId.equals("fallback-dejavu-mono");
|
||||
|
||||
if (isBold && isItalic) {
|
||||
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
|
||||
} else if (isBold) {
|
||||
return baseFontId + "-bold";
|
||||
} else if (isItalic) {
|
||||
return baseFontId + (useOblique ? "-oblique" : "-italic");
|
||||
}
|
||||
|
||||
return baseFontId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback font ID based on Unicode code point properties.
|
||||
*
|
||||
* @param codePoint the Unicode code point
|
||||
* @return fallback font ID
|
||||
*/
|
||||
public String resolveFallbackFontId(int codePoint) {
|
||||
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
|
||||
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|
||||
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|
||||
|| block == Character.UnicodeBlock.BOPOMOFO
|
||||
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|
||||
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
|
||||
return FALLBACK_FONT_CJK_ID;
|
||||
}
|
||||
|
||||
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
|
||||
return switch (script) {
|
||||
case HAN -> FALLBACK_FONT_CJK_ID;
|
||||
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
|
||||
case HANGUL -> FALLBACK_FONT_KR_ID;
|
||||
case ARABIC -> FALLBACK_FONT_AR_ID;
|
||||
case THAI -> FALLBACK_FONT_TH_ID;
|
||||
default -> FALLBACK_FONT_ID;
|
||||
};
|
||||
}
|
||||
|
||||
public String mapUnsupportedGlyph(int codePoint) {
|
||||
return switch (codePoint) {
|
||||
case 0x276E -> "<";
|
||||
case 0x276F -> ">";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private FallbackFontSpec getFallbackFontSpec(String fallbackId) {
|
||||
if (FALLBACK_FONT_ID.equals(fallbackId)) {
|
||||
String baseName = inferBaseName(fallbackFontLocation, "NotoSans-Regular");
|
||||
String format = inferFormat(fallbackFontLocation, "ttf");
|
||||
return new FallbackFontSpec(fallbackFontLocation, baseName, format);
|
||||
}
|
||||
return BUILT_IN_FALLBACK_FONTS.get(fallbackId);
|
||||
}
|
||||
|
||||
private byte[] loadFallbackFontBytes(String fallbackId, FallbackFontSpec spec)
|
||||
throws IOException {
|
||||
if (spec == null) {
|
||||
throw new IOException("No fallback font specification for " + fallbackId);
|
||||
}
|
||||
byte[] cached = fallbackFontCache.get(fallbackId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Resource resource = resourceLoader.getResource(spec.resourceLocation());
|
||||
if (!resource.exists()) {
|
||||
throw new IOException("Fallback font resource not found at " + spec.resourceLocation());
|
||||
}
|
||||
try (InputStream inputStream = resource.getInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
inputStream.transferTo(baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
fallbackFontCache.put(fallbackId, bytes);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private String inferBaseName(String location, String defaultName) {
|
||||
if (location == null || location.isBlank()) {
|
||||
return defaultName;
|
||||
}
|
||||
int slash = location.lastIndexOf('/');
|
||||
String fileName = slash >= 0 ? location.substring(slash + 1) : location;
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
fileName = fileName.substring(0, dot);
|
||||
}
|
||||
return fileName.isEmpty() ? defaultName : fileName;
|
||||
}
|
||||
|
||||
private String inferFormat(String location, String defaultFormat) {
|
||||
if (location == null || location.isBlank()) {
|
||||
return defaultFormat;
|
||||
}
|
||||
int dot = location.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < location.length() - 1) {
|
||||
return location.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
return defaultFormat;
|
||||
}
|
||||
|
||||
private record FallbackFontSpec(String resourceLocation, String baseName, String format) {}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SharedSignatureService {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SharedSignatureService() {
|
||||
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public boolean hasAccessToFile(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// Check if file exists in user's personal folder or ALL_USERS folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
|
||||
return Files.exists(userPath) || Files.exists(allUsersPath);
|
||||
}
|
||||
|
||||
public List<SignatureFile> getAvailableSignatures(String username) {
|
||||
List<SignatureFile> signatures = new ArrayList<>();
|
||||
|
||||
// Get signatures from user's personal folder
|
||||
if (StringUtils.hasText(username)) {
|
||||
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(userFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading user signatures folder", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get signatures from ALL_USERS folder
|
||||
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(allUsersFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading shared signatures folder", e);
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
|
||||
throws IOException {
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
return stream.filter(this::isImageFile)
|
||||
.map(path -> new SignatureFile(path.getFileName().toString(), category))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
|
||||
* authenticated and unauthenticated users.
|
||||
*/
|
||||
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
if (!Files.exists(allUsersPath)) {
|
||||
throw new FileNotFoundException("Shared signature file not found");
|
||||
}
|
||||
return Files.readAllBytes(allUsersPath);
|
||||
}
|
||||
|
||||
private boolean isImageFile(Path path) {
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
|
||||
}
|
||||
|
||||
private void validateFileName(String fileName) {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
// Whitelist only safe image extensions
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
}
|
||||
|
||||
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
|
||||
throws IOException {
|
||||
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
|
||||
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
|
||||
if (!canonicalResolved.startsWith(canonicalTarget)) {
|
||||
throw new IOException("Resolved path is outside the target directory");
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a signature as image file */
|
||||
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
|
||||
throws IOException {
|
||||
validateFileName(request.getId());
|
||||
|
||||
// Determine folder based on scope
|
||||
String scope = request.getScope();
|
||||
if (scope == null || scope.isEmpty()) {
|
||||
scope = "personal"; // Default to personal
|
||||
}
|
||||
|
||||
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
|
||||
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
|
||||
Files.createDirectories(targetFolder);
|
||||
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
SavedSignatureResponse response = new SavedSignatureResponse();
|
||||
response.setId(request.getId());
|
||||
response.setLabel(request.getLabel());
|
||||
response.setType(request.getType());
|
||||
response.setScope(scope);
|
||||
response.setCreatedAt(timestamp);
|
||||
response.setUpdatedAt(timestamp);
|
||||
|
||||
// Extract and save image data
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
|
||||
String extension = validateAndNormalizeExtension(rawExtension);
|
||||
|
||||
// Save image file only
|
||||
String imageFileName = request.getId() + "." + extension;
|
||||
Path imagePath = targetFolder.resolve(imageFileName);
|
||||
|
||||
// Verify path is within target directory
|
||||
verifyPathWithinDirectory(imagePath, targetFolder);
|
||||
|
||||
Files.write(
|
||||
imagePath,
|
||||
imageBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
// Store reference to image file
|
||||
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
|
||||
}
|
||||
|
||||
log.info("Saved signature {} for user {}", request.getId(), username);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get all saved signatures for a user */
|
||||
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
|
||||
List<SavedSignatureResponse> signatures = new ArrayList<>();
|
||||
|
||||
// Load personal signatures
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(personalFolder)) {
|
||||
try (Stream<Path> stream = Files.list(personalFolder)) {
|
||||
stream.filter(this::isImageFile)
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id =
|
||||
fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("personal");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading signature file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load shared signatures
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(sharedFolder)) {
|
||||
try (Stream<Path> stream = Files.list(sharedFolder)) {
|
||||
stream.filter(this::isImageFile)
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id =
|
||||
fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("shared");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading signature file: " + path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
/** Delete a saved signature */
|
||||
public void deleteSignature(String username, String signatureId) throws IOException {
|
||||
validateFileName(signatureId);
|
||||
|
||||
// Try to find and delete image file in personal folder
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
boolean deleted = false;
|
||||
|
||||
if (Files.exists(personalFolder)) {
|
||||
try (Stream<Path> stream = Files.list(personalFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try shared folder if not found in personal
|
||||
if (!deleted) {
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(sharedFolder)) {
|
||||
try (Stream<Path> stream = Files.list(sharedFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new FileNotFoundException("Signature not found");
|
||||
}
|
||||
|
||||
log.info("Deleted signature {} for user {}", signatureId, username);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SignatureService {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
|
||||
public SignatureService() {
|
||||
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||
}
|
||||
|
||||
public boolean hasAccessToFile(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// Check if file exists in user's personal folder or ALL_USERS folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
|
||||
return Files.exists(userPath) || Files.exists(allUsersPath);
|
||||
}
|
||||
|
||||
public List<SignatureFile> getAvailableSignatures(String username) {
|
||||
List<SignatureFile> signatures = new ArrayList<>();
|
||||
|
||||
// Get signatures from user's personal folder
|
||||
if (StringUtils.hasText(username)) {
|
||||
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
if (Files.exists(userFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading user signatures folder", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get signatures from ALL_USERS folder
|
||||
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
if (Files.exists(allUsersFolder)) {
|
||||
try {
|
||||
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading shared signatures folder", e);
|
||||
}
|
||||
}
|
||||
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
|
||||
throws IOException {
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
return stream.filter(this::isImageFile)
|
||||
.map(path -> new SignatureFile(path.getFileName().toString(), category))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
|
||||
validateFileName(fileName);
|
||||
// First try user's personal folder
|
||||
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||
if (Files.exists(userPath)) {
|
||||
return Files.readAllBytes(userPath);
|
||||
}
|
||||
|
||||
// Then try ALL_USERS folder
|
||||
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
|
||||
if (Files.exists(allUsersPath)) {
|
||||
return Files.readAllBytes(allUsersPath);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Signature file not found");
|
||||
}
|
||||
|
||||
private boolean isImageFile(Path path) {
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
return fileName.endsWith(".jpg")
|
||||
|| fileName.endsWith(".jpeg")
|
||||
|| fileName.endsWith(".png")
|
||||
|| fileName.endsWith(".gif");
|
||||
}
|
||||
|
||||
private void validateFileName(String fileName) {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Service to manage job ownership and access control for PDF JSON operations. When security is
|
||||
* enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally
|
||||
* accessible.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false)
|
||||
public class JobOwnershipServiceImpl
|
||||
implements stirling.software.common.service.JobOwnershipService {
|
||||
|
||||
@Autowired(required = false)
|
||||
private UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's identifier. Returns empty if no user is authenticated.
|
||||
*
|
||||
* @return Optional containing user identifier, or empty if not authenticated
|
||||
*/
|
||||
public Optional<String> getCurrentUserId() {
|
||||
if (userService == null) {
|
||||
log.debug("UserService not available");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null && !username.isEmpty() && !"anonymousUser".equals(username)) {
|
||||
log.debug("Current authenticated user: {}", username);
|
||||
return Optional.of(username);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get current username from UserService: {}", e.getMessage());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scoped job key that includes user ownership when security is enabled.
|
||||
*
|
||||
* @param jobId the base job identifier
|
||||
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
|
||||
*/
|
||||
public String createScopedJobKey(String jobId) {
|
||||
Optional<String> userId = getCurrentUserId();
|
||||
if (userId.isPresent()) {
|
||||
String scopedKey = userId.get() + ":" + jobId;
|
||||
log.debug("Created scoped job key: {}", scopedKey);
|
||||
return scopedKey;
|
||||
}
|
||||
log.debug("No user authenticated, using unsecured job key: {}", jobId);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key to validate
|
||||
* @return true if current user owns the job or no authentication is required
|
||||
* @throws SecurityException if current user does not own the job
|
||||
*/
|
||||
public boolean validateJobAccess(String scopedJobKey) {
|
||||
Optional<String> userId = getCurrentUserId();
|
||||
|
||||
// If no user authenticated, allow access (backwards compatibility)
|
||||
if (userId.isEmpty()) {
|
||||
log.debug("No authentication required, allowing access to job: {}", scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if job key starts with current user's ID
|
||||
String userPrefix = userId.get() + ":";
|
||||
if (!scopedJobKey.startsWith(userPrefix)) {
|
||||
log.warn(
|
||||
"Access denied: User {} attempted to access job key {} which they don't own",
|
||||
userId.get(),
|
||||
scopedJobKey);
|
||||
throw new SecurityException(
|
||||
"Access denied: You do not have permission to access this job");
|
||||
}
|
||||
|
||||
log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base job ID from a scoped job key.
|
||||
*
|
||||
* @param scopedJobKey the scoped job key
|
||||
* @return the base job ID without user prefix
|
||||
*/
|
||||
public String extractJobId(String scopedJobKey) {
|
||||
int colonIndex = scopedJobKey.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return scopedJobKey.substring(colonIndex + 1);
|
||||
}
|
||||
return scopedJobKey;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* No-op implementation of job ownership service when security is disabled. All jobs are globally
|
||||
* accessible without authentication.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true)
|
||||
public class NoOpJobOwnershipService
|
||||
implements stirling.software.common.service.JobOwnershipService {
|
||||
|
||||
@Override
|
||||
public Optional<String> getCurrentUserId() {
|
||||
// No authentication when security is disabled
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createScopedJobKey(String jobId) {
|
||||
// Jobs are not scoped to users when security is disabled
|
||||
return jobId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validateJobAccess(String scopedJobKey) {
|
||||
// All jobs are accessible when security is disabled
|
||||
log.trace("Security disabled, allowing access to job: {}", scopedJobKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractJobId(String scopedJobKey) {
|
||||
// No user prefix when security is disabled
|
||||
return scopedJobKey;
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PdfJsonFontService {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@Getter
|
||||
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
|
||||
private boolean cffConversionEnabled;
|
||||
|
||||
@Getter
|
||||
@Value("${stirling.pdf.json.cff-converter.method:python}")
|
||||
private String cffConverterMethod;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
|
||||
private String pythonCommand;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
|
||||
private String pythonScript;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
|
||||
private String fontforgeCommand;
|
||||
|
||||
private volatile boolean pythonCffConverterAvailable;
|
||||
private volatile boolean fontForgeCffConverterAvailable;
|
||||
|
||||
@PostConstruct
|
||||
private void initialiseCffConverterAvailability() {
|
||||
if (!cffConversionEnabled) {
|
||||
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
|
||||
pythonCffConverterAvailable = false;
|
||||
fontForgeCffConverterAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[FONT-DEBUG] CFF conversion enabled, checking tool availability...");
|
||||
pythonCffConverterAvailable = isCommandAvailable(pythonCommand);
|
||||
if (!pythonCffConverterAvailable) {
|
||||
log.warn(
|
||||
"[FONT-DEBUG] Python command '{}' not found; Python CFF conversion disabled",
|
||||
pythonCommand);
|
||||
} else {
|
||||
log.info("[FONT-DEBUG] Python command '{}' is available", pythonCommand);
|
||||
}
|
||||
|
||||
fontForgeCffConverterAvailable = isCommandAvailable(fontforgeCommand);
|
||||
if (!fontForgeCffConverterAvailable) {
|
||||
log.warn(
|
||||
"[FONT-DEBUG] FontForge command '{}' not found; FontForge CFF conversion disabled",
|
||||
fontforgeCommand);
|
||||
} else {
|
||||
log.info("[FONT-DEBUG] FontForge command '{}' is available", fontforgeCommand);
|
||||
}
|
||||
|
||||
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
|
||||
}
|
||||
|
||||
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
|
||||
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
|
||||
log.warn(
|
||||
"[FONT-DEBUG] CFF conversion skipped: enabled={}, bytes={}",
|
||||
cffConversionEnabled,
|
||||
fontBytes == null ? "null" : fontBytes.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info(
|
||||
"[FONT-DEBUG] Converting CFF font: {} bytes, method: {}",
|
||||
fontBytes.length,
|
||||
cffConverterMethod);
|
||||
|
||||
if ("python".equalsIgnoreCase(cffConverterMethod)) {
|
||||
if (!pythonCffConverterAvailable) {
|
||||
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
|
||||
return null;
|
||||
}
|
||||
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Python conversion result: {}",
|
||||
result == null ? "null" : result.length + " bytes");
|
||||
return result;
|
||||
} else if ("fontforge".equalsIgnoreCase(cffConverterMethod)) {
|
||||
if (!fontForgeCffConverterAvailable) {
|
||||
log.debug(
|
||||
"[FONT-DEBUG] FontForge CFF converter not available, skipping conversion");
|
||||
return null;
|
||||
}
|
||||
byte[] result = convertCffUsingFontForge(fontBytes);
|
||||
log.debug(
|
||||
"[FONT-DEBUG] FontForge conversion result: {}",
|
||||
result == null ? "null" : result.length + " bytes");
|
||||
return result;
|
||||
} else {
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Unknown CFF converter method: {}, falling back to Python",
|
||||
cffConverterMethod);
|
||||
if (!pythonCffConverterAvailable) {
|
||||
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
|
||||
return null;
|
||||
}
|
||||
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Python conversion result: {}",
|
||||
result == null ? "null" : result.length + " bytes");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public String detectFontFlavor(byte[] fontBytes) {
|
||||
if (fontBytes == null || fontBytes.length < 4) {
|
||||
return null;
|
||||
}
|
||||
int signature =
|
||||
((fontBytes[0] & 0xFF) << 24)
|
||||
| ((fontBytes[1] & 0xFF) << 16)
|
||||
| ((fontBytes[2] & 0xFF) << 8)
|
||||
| (fontBytes[3] & 0xFF);
|
||||
if (signature == 0x00010000 || signature == 0x74727565) {
|
||||
return "ttf";
|
||||
}
|
||||
if (signature == 0x4F54544F) {
|
||||
return "otf";
|
||||
}
|
||||
if (signature == 0x74746366) {
|
||||
return "cff";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String detectTrueTypeFormat(byte[] data) {
|
||||
if (data == null || data.length < 4) {
|
||||
return null;
|
||||
}
|
||||
int signature =
|
||||
((data[0] & 0xFF) << 24)
|
||||
| ((data[1] & 0xFF) << 16)
|
||||
| ((data[2] & 0xFF) << 8)
|
||||
| (data[3] & 0xFF);
|
||||
if (signature == 0x00010000) {
|
||||
return "ttf";
|
||||
}
|
||||
if (signature == 0x4F54544F) {
|
||||
return "otf";
|
||||
}
|
||||
if (signature == 0x74746366) {
|
||||
return "cff";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String validateFontTables(byte[] fontBytes) {
|
||||
if (fontBytes == null || fontBytes.length < 12) {
|
||||
return "Font program too small";
|
||||
}
|
||||
int numTables = ((fontBytes[4] & 0xFF) << 8) | (fontBytes[5] & 0xFF);
|
||||
if (numTables <= 0 || numTables > 512) {
|
||||
return "Invalid numTables: " + numTables;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private byte[] convertCffUsingPython(byte[] fontBytes, String toUnicode) {
|
||||
if (!pythonCffConverterAvailable) {
|
||||
log.debug("[FONT-DEBUG] Python CFF converter not available");
|
||||
return null;
|
||||
}
|
||||
if (pythonCommand == null
|
||||
|| pythonCommand.isBlank()
|
||||
|| pythonScript == null
|
||||
|| pythonScript.isBlank()) {
|
||||
log.debug("[FONT-DEBUG] Python converter not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Running Python CFF converter: command={}, script={}",
|
||||
pythonCommand,
|
||||
pythonScript);
|
||||
|
||||
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
|
||||
TempFile outputFile = new TempFile(tempFileManager, ".otf");
|
||||
TempFile toUnicodeFile =
|
||||
toUnicode != null ? new TempFile(tempFileManager, ".tounicode") : null) {
|
||||
Files.write(inputFile.getPath(), fontBytes);
|
||||
if (toUnicodeFile != null) {
|
||||
try {
|
||||
byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicode);
|
||||
Files.write(toUnicodeFile.getPath(), toUnicodeBytes);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.debug(
|
||||
"[FONT-DEBUG] Failed to decode ToUnicode data for CFF conversion: {}",
|
||||
ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String[] command =
|
||||
buildPythonCommand(
|
||||
inputFile.getAbsolutePath(),
|
||||
outputFile.getAbsolutePath(),
|
||||
toUnicodeFile != null ? toUnicodeFile.getAbsolutePath() : null);
|
||||
log.debug("[FONT-DEBUG] Executing: {}", String.join(" ", command));
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
|
||||
.runCommandWithOutputHandling(java.util.Arrays.asList(command));
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"[FONT-DEBUG] Python CFF conversion failed with exit code: {}",
|
||||
result.getRc());
|
||||
log.error("[FONT-DEBUG] Stdout: {}", result.getMessages());
|
||||
return null;
|
||||
}
|
||||
if (!Files.exists(outputFile.getPath())) {
|
||||
log.error("[FONT-DEBUG] Python CFF conversion produced no output file");
|
||||
return null;
|
||||
}
|
||||
byte[] data = Files.readAllBytes(outputFile.getPath());
|
||||
if (data.length == 0) {
|
||||
log.error("[FONT-DEBUG] Python CFF conversion returned empty output");
|
||||
return null;
|
||||
}
|
||||
log.info(
|
||||
"[FONT-DEBUG] Python CFF conversion succeeded: {} bytes -> {} bytes",
|
||||
fontBytes.length,
|
||||
data.length);
|
||||
return data;
|
||||
} catch (IOException | InterruptedException ex) {
|
||||
if (ex instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
log.error("[FONT-DEBUG] Python CFF conversion exception: {}", ex.getMessage(), ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertCffUsingFontForge(byte[] fontBytes) {
|
||||
if (!fontForgeCffConverterAvailable) {
|
||||
log.debug("FontForge CFF converter not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
|
||||
TempFile outputFile = new TempFile(tempFileManager, ".ttf")) {
|
||||
Files.write(inputFile.getPath(), fontBytes);
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
|
||||
.runCommandWithOutputHandling(
|
||||
java.util.Arrays.asList(
|
||||
fontforgeCommand,
|
||||
"-lang=ff",
|
||||
"-c",
|
||||
"Open($1); "
|
||||
+ "ScaleToEm(1000); "
|
||||
+ "SelectWorthOutputting(); "
|
||||
+ "SetFontOrder(2); "
|
||||
+ "Reencode(\"unicode\"); "
|
||||
+ "RoundToInt(); "
|
||||
+ "RemoveOverlap(); "
|
||||
+ "Simplify(); "
|
||||
+ "CorrectDirection(); "
|
||||
+ "Generate($2, \"\", 4+16+32); "
|
||||
+ "Close(); "
|
||||
+ "Quit()",
|
||||
inputFile.getAbsolutePath(),
|
||||
outputFile.getAbsolutePath()));
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn("FontForge CFF conversion failed: {}", result.getRc());
|
||||
return null;
|
||||
}
|
||||
if (!Files.exists(outputFile.getPath())) {
|
||||
log.warn("FontForge CFF conversion produced no output");
|
||||
return null;
|
||||
}
|
||||
byte[] data = Files.readAllBytes(outputFile.getPath());
|
||||
if (data.length == 0) {
|
||||
log.warn("FontForge CFF conversion returned empty output");
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (IOException | InterruptedException ex) {
|
||||
if (ex instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
log.warn("FontForge CFF conversion failed: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCommandAvailable(String command) {
|
||||
if (command == null || command.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder();
|
||||
if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
|
||||
processBuilder.command("where", command);
|
||||
} else {
|
||||
processBuilder.command("which", command);
|
||||
}
|
||||
Process process = processBuilder.start();
|
||||
int exitCode = process.waitFor();
|
||||
return exitCode == 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking for command {}: {}", command, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String[] buildPythonCommand(String input, String output, String toUnicode) {
|
||||
if (toUnicode != null) {
|
||||
return new String[] {
|
||||
pythonCommand,
|
||||
pythonScript,
|
||||
"--input",
|
||||
input,
|
||||
"--output",
|
||||
output,
|
||||
"--to-unicode",
|
||||
toUnicode
|
||||
};
|
||||
}
|
||||
return new String[] {pythonCommand, pythonScript, "--input", input, "--output", output};
|
||||
}
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.contentstream.operator.OperatorName;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
|
||||
import stirling.software.SPDF.model.json.PdfJsonImageElement;
|
||||
|
||||
/**
|
||||
* Service for handling PDF image operations for JSON conversion (extraction, encoding, rendering).
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PdfJsonImageService {
|
||||
|
||||
private record EncodedImage(String base64, String format) {}
|
||||
|
||||
private record Bounds(float left, float right, float bottom, float top) {
|
||||
float width() {
|
||||
return Math.max(0f, right - left);
|
||||
}
|
||||
|
||||
float height() {
|
||||
return Math.max(0f, top - bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects images from all pages in a PDF document.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @param totalPages Total number of pages
|
||||
* @param progress Progress callback
|
||||
* @return Map of page number to list of image elements
|
||||
* @throws IOException If image extraction fails
|
||||
*/
|
||||
public Map<Integer, List<PdfJsonImageElement>> collectImages(
|
||||
PDDocument document, int totalPages, Consumer<PdfJsonConversionProgress> progress)
|
||||
throws IOException {
|
||||
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
|
||||
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
|
||||
int pageNumber = 1;
|
||||
for (PDPage page : document.getPages()) {
|
||||
ImageCollectingEngine engine =
|
||||
new ImageCollectingEngine(page, pageNumber, imagesByPage, imageCache);
|
||||
engine.processPage(page);
|
||||
|
||||
// Update progress for image extraction (70-80%)
|
||||
int imageProgress = 70 + (int) ((pageNumber / (double) totalPages) * 10);
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(
|
||||
imageProgress, "images", "Extracting images", pageNumber, totalPages));
|
||||
pageNumber++;
|
||||
}
|
||||
return imagesByPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts images from a single PDF page (for on-demand lazy loading).
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @param page The specific page to extract images from
|
||||
* @param pageNumber The page number (1-indexed)
|
||||
* @return List of image elements for this page
|
||||
* @throws IOException If image extraction fails
|
||||
*/
|
||||
public List<PdfJsonImageElement> extractImagesForPage(
|
||||
PDDocument document, PDPage page, int pageNumber) throws IOException {
|
||||
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
|
||||
ImageCollectingEngine engine =
|
||||
new ImageCollectingEngine(page, pageNumber, imagesByPage, new IdentityHashMap<>());
|
||||
engine.processPage(page);
|
||||
return imagesByPage.getOrDefault(pageNumber, new ArrayList<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws an image element on a PDF page content stream.
|
||||
*
|
||||
* @param contentStream The content stream to draw on
|
||||
* @param document The PDF document
|
||||
* @param element The image element to draw
|
||||
* @param cache Cache of previously created image XObjects
|
||||
* @throws IOException If drawing fails
|
||||
*/
|
||||
public void drawImageElement(
|
||||
PDPageContentStream contentStream,
|
||||
PDDocument document,
|
||||
PdfJsonImageElement element,
|
||||
Map<String, PDImageXObject> cache)
|
||||
throws IOException {
|
||||
if (element == null || element.getImageData() == null || element.getImageData().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey =
|
||||
element.getId() != null && !element.getId().isBlank()
|
||||
? element.getId()
|
||||
: Integer.toHexString(System.identityHashCode(element));
|
||||
PDImageXObject image = cache.get(cacheKey);
|
||||
if (image == null) {
|
||||
image = createImageXObject(document, element);
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
cache.put(cacheKey, image);
|
||||
}
|
||||
|
||||
List<Float> transform = element.getTransform();
|
||||
if (transform != null && transform.size() == 6) {
|
||||
Matrix matrix =
|
||||
new Matrix(
|
||||
safeFloat(transform.get(0), 1f),
|
||||
safeFloat(transform.get(1), 0f),
|
||||
safeFloat(transform.get(2), 0f),
|
||||
safeFloat(transform.get(3), 1f),
|
||||
safeFloat(transform.get(4), 0f),
|
||||
safeFloat(transform.get(5), 0f));
|
||||
contentStream.drawImage(image, matrix);
|
||||
return;
|
||||
}
|
||||
|
||||
float width = safeFloat(element.getWidth(), fallbackWidth(element));
|
||||
float height = safeFloat(element.getHeight(), fallbackHeight(element));
|
||||
if (width <= 0f) {
|
||||
width = Math.max(1f, fallbackWidth(element));
|
||||
}
|
||||
if (height <= 0f) {
|
||||
height = Math.max(1f, fallbackHeight(element));
|
||||
}
|
||||
float left = resolveLeft(element, width);
|
||||
float bottom = resolveBottom(element, height);
|
||||
|
||||
contentStream.drawImage(image, left, bottom, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PDImageXObject from a PdfJsonImageElement.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @param element The image element with base64 data
|
||||
* @return The created image XObject
|
||||
* @throws IOException If image creation fails
|
||||
*/
|
||||
public PDImageXObject createImageXObject(PDDocument document, PdfJsonImageElement element)
|
||||
throws IOException {
|
||||
byte[] data;
|
||||
try {
|
||||
data = Base64.getDecoder().decode(element.getImageData());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.debug("Failed to decode image element: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
String name = element.getId() != null ? element.getId() : UUID.randomUUID().toString();
|
||||
return PDImageXObject.createFromByteArray(document, data, name);
|
||||
}
|
||||
|
||||
private EncodedImage encodeImage(PDImage image) {
|
||||
try {
|
||||
BufferedImage bufferedImage = image.getImage();
|
||||
if (bufferedImage == null) {
|
||||
return null;
|
||||
}
|
||||
String format = resolveImageFormat(image);
|
||||
if (format == null || format.isBlank()) {
|
||||
format = "png";
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
boolean written = ImageIO.write(bufferedImage, format, baos);
|
||||
if (!written) {
|
||||
if (!"png".equalsIgnoreCase(format)) {
|
||||
baos.reset();
|
||||
if (!ImageIO.write(bufferedImage, "png", baos)) {
|
||||
return null;
|
||||
}
|
||||
format = "png";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return new EncodedImage(Base64.getEncoder().encodeToString(baos.toByteArray()), format);
|
||||
} catch (IOException ex) {
|
||||
log.debug("Failed to encode image: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveImageFormat(PDImage image) {
|
||||
if (image instanceof PDImageXObject xObject) {
|
||||
String suffix = xObject.getSuffix();
|
||||
if (suffix != null && !suffix.isBlank()) {
|
||||
return suffix.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
return "png";
|
||||
}
|
||||
|
||||
private float fallbackWidth(PdfJsonImageElement element) {
|
||||
if (element.getRight() != null && element.getLeft() != null) {
|
||||
return Math.max(0f, element.getRight() - element.getLeft());
|
||||
}
|
||||
if (element.getNativeWidth() != null) {
|
||||
return element.getNativeWidth();
|
||||
}
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private float fallbackHeight(PdfJsonImageElement element) {
|
||||
if (element.getTop() != null && element.getBottom() != null) {
|
||||
return Math.max(0f, element.getTop() - element.getBottom());
|
||||
}
|
||||
if (element.getNativeHeight() != null) {
|
||||
return element.getNativeHeight();
|
||||
}
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private float resolveLeft(PdfJsonImageElement element, float width) {
|
||||
if (element.getLeft() != null) {
|
||||
return element.getLeft();
|
||||
}
|
||||
if (element.getX() != null) {
|
||||
return element.getX();
|
||||
}
|
||||
if (element.getRight() != null) {
|
||||
return element.getRight() - width;
|
||||
}
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private float resolveBottom(PdfJsonImageElement element, float height) {
|
||||
if (element.getBottom() != null) {
|
||||
return element.getBottom();
|
||||
}
|
||||
if (element.getY() != null) {
|
||||
return element.getY();
|
||||
}
|
||||
if (element.getTop() != null) {
|
||||
return element.getTop() - height;
|
||||
}
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private List<Float> toMatrixValues(Matrix matrix) {
|
||||
List<Float> values = new ArrayList<>(6);
|
||||
values.add(matrix.getValue(0, 0));
|
||||
values.add(matrix.getValue(0, 1));
|
||||
values.add(matrix.getValue(1, 0));
|
||||
values.add(matrix.getValue(1, 1));
|
||||
values.add(matrix.getValue(2, 0));
|
||||
values.add(matrix.getValue(2, 1));
|
||||
return values;
|
||||
}
|
||||
|
||||
private float safeFloat(Float value, float defaultValue) {
|
||||
if (value == null || Float.isNaN(value) || Float.isInfinite(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner engine that extends PDFGraphicsStreamEngine to collect images from PDF content streams.
|
||||
*/
|
||||
private class ImageCollectingEngine extends PDFGraphicsStreamEngine {
|
||||
|
||||
private final int pageNumber;
|
||||
private final Map<Integer, List<PdfJsonImageElement>> imagesByPage;
|
||||
private final Map<COSBase, EncodedImage> imageCache;
|
||||
|
||||
private COSName currentXObjectName;
|
||||
private int imageCounter = 0;
|
||||
|
||||
protected ImageCollectingEngine(
|
||||
PDPage page,
|
||||
int pageNumber,
|
||||
Map<Integer, List<PdfJsonImageElement>> imagesByPage,
|
||||
Map<COSBase, EncodedImage> imageCache)
|
||||
throws IOException {
|
||||
super(page);
|
||||
this.pageNumber = pageNumber;
|
||||
this.imagesByPage = imagesByPage;
|
||||
this.imageCache = imageCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processPage(PDPage page) throws IOException {
|
||||
super.processPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawImage(PDImage pdImage) throws IOException {
|
||||
EncodedImage encoded = getOrEncodeImage(pdImage);
|
||||
if (encoded == null) {
|
||||
return;
|
||||
}
|
||||
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
|
||||
Bounds bounds = computeBounds(ctm);
|
||||
List<Float> matrixValues = toMatrixValues(ctm);
|
||||
|
||||
PdfJsonImageElement element =
|
||||
PdfJsonImageElement.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.objectName(
|
||||
currentXObjectName != null
|
||||
? currentXObjectName.getName()
|
||||
: null)
|
||||
.inlineImage(!(pdImage instanceof PDImageXObject))
|
||||
.nativeWidth(pdImage.getWidth())
|
||||
.nativeHeight(pdImage.getHeight())
|
||||
.x(bounds.left)
|
||||
.y(bounds.bottom)
|
||||
.width(bounds.width())
|
||||
.height(bounds.height())
|
||||
.left(bounds.left)
|
||||
.right(bounds.right)
|
||||
.top(bounds.top)
|
||||
.bottom(bounds.bottom)
|
||||
.transform(matrixValues)
|
||||
.zOrder(-1_000_000 + imageCounter)
|
||||
.imageData(encoded.base64())
|
||||
.imageFormat(encoded.format())
|
||||
.build();
|
||||
imageCounter++;
|
||||
imagesByPage.computeIfAbsent(pageNumber, key -> new ArrayList<>()).add(element);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3)
|
||||
throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clip(int windingRule) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveTo(float x, float y) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void lineTo(float x, float y) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
|
||||
throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public Point2D getCurrentPoint() throws IOException {
|
||||
return new Point2D.Float();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closePath() throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endPath() throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shadingFill(COSName shadingName) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillAndStrokePath(int windingRule) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillPath(int windingRule) throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
public void strokePath() throws IOException {
|
||||
// Not needed for image extraction
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processOperator(Operator operator, List<COSBase> operands)
|
||||
throws IOException {
|
||||
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
|
||||
&& !operands.isEmpty()
|
||||
&& operands.get(0) instanceof COSName name) {
|
||||
currentXObjectName = name;
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
currentXObjectName = null;
|
||||
}
|
||||
|
||||
private EncodedImage getOrEncodeImage(PDImage pdImage) {
|
||||
if (pdImage == null) {
|
||||
return null;
|
||||
}
|
||||
if (pdImage instanceof PDImageXObject xObject) {
|
||||
if (xObject.isStencil()) {
|
||||
return encodeImage(pdImage);
|
||||
}
|
||||
COSBase key = xObject.getCOSObject();
|
||||
EncodedImage cached = imageCache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
EncodedImage encoded = encodeImage(pdImage);
|
||||
if (encoded != null) {
|
||||
imageCache.put(key, encoded);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
return encodeImage(pdImage);
|
||||
}
|
||||
|
||||
private Bounds computeBounds(Matrix ctm) {
|
||||
AffineTransform transform = ctm.createAffineTransform();
|
||||
Point2D.Float p0 = new Point2D.Float(0, 0);
|
||||
Point2D.Float p1 = new Point2D.Float(1, 0);
|
||||
Point2D.Float p2 = new Point2D.Float(0, 1);
|
||||
Point2D.Float p3 = new Point2D.Float(1, 1);
|
||||
transform.transform(p0, p0);
|
||||
transform.transform(p1, p1);
|
||||
transform.transform(p2, p2);
|
||||
transform.transform(p3, p3);
|
||||
|
||||
float minX = Math.min(Math.min(p0.x, p1.x), Math.min(p2.x, p3.x));
|
||||
float maxX = Math.max(Math.max(p0.x, p1.x), Math.max(p2.x, p3.x));
|
||||
float minY = Math.min(Math.min(p0.y, p1.y), Math.min(p2.y, p3.y));
|
||||
float maxY = Math.max(Math.max(p0.y, p1.y), Math.max(p2.y, p3.y));
|
||||
|
||||
if (!Float.isFinite(minX) || !Float.isFinite(minY)) {
|
||||
return new Bounds(0f, 0f, 0f, 0f);
|
||||
}
|
||||
return new Bounds(minX, maxX, minY, maxY);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Base64;
|
||||
import java.util.Calendar;
|
||||
import java.util.Optional;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonMetadata;
|
||||
|
||||
/** Service for extracting and applying PDF metadata (document info and XMP) for JSON conversion. */
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PdfJsonMetadataService {
|
||||
|
||||
/**
|
||||
* Extracts document information metadata from a PDF.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @return Metadata model with document info
|
||||
*/
|
||||
public PdfJsonMetadata extractMetadata(PDDocument document) {
|
||||
PdfJsonMetadata metadata = new PdfJsonMetadata();
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
if (info != null) {
|
||||
metadata.setTitle(info.getTitle());
|
||||
metadata.setAuthor(info.getAuthor());
|
||||
metadata.setSubject(info.getSubject());
|
||||
metadata.setKeywords(info.getKeywords());
|
||||
metadata.setCreator(info.getCreator());
|
||||
metadata.setProducer(info.getProducer());
|
||||
metadata.setCreationDate(formatCalendar(info.getCreationDate()));
|
||||
metadata.setModificationDate(formatCalendar(info.getModificationDate()));
|
||||
metadata.setTrapped(info.getTrapped());
|
||||
}
|
||||
metadata.setNumberOfPages(document.getNumberOfPages());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts XMP metadata from a PDF as base64-encoded string.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @return Base64-encoded XMP metadata, or null if not present
|
||||
*/
|
||||
public String extractXmpMetadata(PDDocument document) {
|
||||
if (document.getDocumentCatalog() == null) {
|
||||
return null;
|
||||
}
|
||||
PDMetadata metadata = document.getDocumentCatalog().getMetadata();
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream inputStream = metadata.createInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
inputStream.transferTo(baos);
|
||||
byte[] data = baos.toByteArray();
|
||||
if (data.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(data);
|
||||
} catch (IOException ex) {
|
||||
log.debug("Failed to extract XMP metadata: {}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies metadata to a PDF document.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @param metadata The metadata to apply
|
||||
*/
|
||||
public void applyMetadata(PDDocument document, PdfJsonMetadata metadata) {
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
info.setTitle(metadata.getTitle());
|
||||
info.setAuthor(metadata.getAuthor());
|
||||
info.setSubject(metadata.getSubject());
|
||||
info.setKeywords(metadata.getKeywords());
|
||||
info.setCreator(metadata.getCreator());
|
||||
info.setProducer(metadata.getProducer());
|
||||
if (metadata.getCreationDate() != null) {
|
||||
parseInstant(metadata.getCreationDate())
|
||||
.ifPresent(instant -> info.setCreationDate(toCalendar(instant)));
|
||||
}
|
||||
if (metadata.getModificationDate() != null) {
|
||||
parseInstant(metadata.getModificationDate())
|
||||
.ifPresent(instant -> info.setModificationDate(toCalendar(instant)));
|
||||
}
|
||||
info.setTrapped(metadata.getTrapped());
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies XMP metadata to a PDF document from base64-encoded string.
|
||||
*
|
||||
* @param document The PDF document
|
||||
* @param base64 Base64-encoded XMP metadata
|
||||
*/
|
||||
public void applyXmpMetadata(PDDocument document, String base64) {
|
||||
if (base64 == null || base64.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try (InputStream inputStream =
|
||||
new ByteArrayInputStream(Base64.getDecoder().decode(base64))) {
|
||||
PDMetadata metadata = new PDMetadata(document, inputStream);
|
||||
document.getDocumentCatalog().setMetadata(metadata);
|
||||
} catch (IllegalArgumentException | IOException ex) {
|
||||
log.debug("Failed to apply XMP metadata: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String formatCalendar(Calendar calendar) {
|
||||
if (calendar == null) {
|
||||
return null;
|
||||
}
|
||||
return calendar.toInstant().toString();
|
||||
}
|
||||
|
||||
private Optional<Instant> parseInstant(String value) {
|
||||
try {
|
||||
return Optional.of(Instant.parse(value));
|
||||
} catch (DateTimeParseException ex) {
|
||||
log.warn("Failed to parse instant '{}': {}", value, ex.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Calendar toCalendar(Instant instant) {
|
||||
Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
|
||||
calendar.setTimeInMillis(instant.toEpochMilli());
|
||||
return calendar;
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
package stirling.software.SPDF.service.pdfjson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
|
||||
import stirling.software.SPDF.model.json.PdfJsonAnnotation;
|
||||
import stirling.software.SPDF.model.json.PdfJsonCosValue;
|
||||
import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata;
|
||||
import stirling.software.SPDF.model.json.PdfJsonFont;
|
||||
import stirling.software.SPDF.model.json.PdfJsonImageElement;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPage;
|
||||
import stirling.software.SPDF.model.json.PdfJsonPageDimension;
|
||||
import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce
|
||||
* memory usage for large PDFs.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PdfLazyLoadingService {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskManager taskManager;
|
||||
private final PdfJsonMetadataService metadataService;
|
||||
private final PdfJsonImageService imageService;
|
||||
|
||||
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
|
||||
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Stores PDF file bytes for lazy page loading. Each page is extracted on-demand by re-loading
|
||||
* the PDF from bytes.
|
||||
*/
|
||||
@Data
|
||||
private static class CachedPdfDocument {
|
||||
private final byte[] pdfBytes;
|
||||
private final PdfJsonDocumentMetadata metadata;
|
||||
private final long timestamp;
|
||||
|
||||
public CachedPdfDocument(byte[] pdfBytes, PdfJsonDocumentMetadata metadata) {
|
||||
this.pdfBytes = pdfBytes;
|
||||
this.metadata = metadata;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts document metadata, fonts, and page dimensions without page content. Caches the PDF
|
||||
* bytes for subsequent page requests.
|
||||
*
|
||||
* @param file The uploaded PDF file
|
||||
* @param jobId The job ID for caching
|
||||
* @param fonts Font map (will be populated)
|
||||
* @param pageFontResources Page font resources map (will be populated)
|
||||
* @return Serialized metadata JSON
|
||||
* @throws IOException If extraction fails
|
||||
*/
|
||||
public byte[] extractDocumentMetadata(
|
||||
MultipartFile file,
|
||||
String jobId,
|
||||
Map<String, PdfJsonFont> fonts,
|
||||
Map<Integer, Map<PDFont, String>> pageFontResources)
|
||||
throws IOException {
|
||||
if (file == null) {
|
||||
throw ExceptionUtils.createNullArgumentException("fileInput");
|
||||
}
|
||||
|
||||
Consumer<PdfJsonConversionProgress> progress =
|
||||
jobId != null
|
||||
? (p) -> {
|
||||
log.info(
|
||||
"Progress: [{}%] {} - {}{}",
|
||||
p.getPercent(),
|
||||
p.getStage(),
|
||||
p.getMessage(),
|
||||
(p.getCurrent() != null && p.getTotal() != null)
|
||||
? String.format(
|
||||
" (%d/%d)", p.getCurrent(), p.getTotal())
|
||||
: "");
|
||||
reportProgressToTaskManager(jobId, p);
|
||||
}
|
||||
: (p) -> {};
|
||||
|
||||
// Read PDF bytes once for processing and caching
|
||||
byte[] pdfBytes = file.getBytes();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes, true)) {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
// Build metadata response
|
||||
progress.accept(PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
|
||||
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
|
||||
docMetadata.setMetadata(metadataService.extractMetadata(document));
|
||||
docMetadata.setXmpMetadata(metadataService.extractXmpMetadata(document));
|
||||
docMetadata.setLazyImages(Boolean.TRUE);
|
||||
|
||||
List<PdfJsonFont> serializedFonts = new ArrayList<>(fonts.values());
|
||||
serializedFonts.sort(
|
||||
Comparator.comparing(
|
||||
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
docMetadata.setFonts(serializedFonts);
|
||||
|
||||
// Extract page dimensions
|
||||
List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
|
||||
int pageIndex = 0;
|
||||
for (PDPage page : document.getPages()) {
|
||||
PdfJsonPageDimension dim = new PdfJsonPageDimension();
|
||||
dim.setPageNumber(pageIndex + 1);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
dim.setWidth(mediaBox.getWidth());
|
||||
dim.setHeight(mediaBox.getHeight());
|
||||
dim.setRotation(page.getRotation());
|
||||
pageDimensions.add(dim);
|
||||
pageIndex++;
|
||||
}
|
||||
docMetadata.setPageDimensions(pageDimensions);
|
||||
|
||||
// Cache PDF bytes and metadata for lazy page loading
|
||||
if (jobId != null) {
|
||||
CachedPdfDocument cached = new CachedPdfDocument(pdfBytes, docMetadata);
|
||||
documentCache.put(jobId, cached);
|
||||
log.info(
|
||||
"Cached PDF bytes ({} bytes) for lazy loading, jobId: {}",
|
||||
pdfBytes.length,
|
||||
jobId);
|
||||
|
||||
// Schedule cleanup after 30 minutes
|
||||
scheduleDocumentCleanup(jobId);
|
||||
}
|
||||
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(100, "complete", "Metadata extraction complete"));
|
||||
|
||||
return objectMapper.writeValueAsBytes(docMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a single page from cached PDF bytes. Re-loads the PDF for each request.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param pageNumber The page number (1-indexed)
|
||||
* @param serializeCosValue Function to serialize COS values
|
||||
* @param extractContentStreams Function to extract content streams
|
||||
* @param filterImageXObjectsFromResources Function to filter image XObjects
|
||||
* @param extractText Function to extract text elements for the page
|
||||
* @param extractAnnotations Function to extract annotations for the page
|
||||
* @return Serialized page JSON
|
||||
* @throws IOException If extraction fails
|
||||
*/
|
||||
public byte[] extractSinglePage(
|
||||
String jobId,
|
||||
int pageNumber,
|
||||
java.util.function.Function<COSBase, PdfJsonCosValue> serializeCosValue,
|
||||
java.util.function.Function<PDPage, List<PdfJsonStream>> extractContentStreams,
|
||||
java.util.function.Function<COSBase, COSBase> filterImageXObjectsFromResources,
|
||||
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonTextElement>>
|
||||
extractText,
|
||||
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonAnnotation>>
|
||||
extractAnnotations)
|
||||
throws IOException {
|
||||
CachedPdfDocument cached = documentCache.get(jobId);
|
||||
if (cached == null) {
|
||||
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
|
||||
}
|
||||
|
||||
int pageIndex = pageNumber - 1;
|
||||
int totalPages = cached.getMetadata().getPageDimensions().size();
|
||||
|
||||
if (pageIndex < 0 || pageIndex >= totalPages) {
|
||||
throw new IllegalArgumentException(
|
||||
"Page number " + pageNumber + " out of range (1-" + totalPages + ")");
|
||||
}
|
||||
|
||||
log.debug("Loading PDF from bytes to extract page {} (jobId: {})", pageNumber, jobId);
|
||||
|
||||
// Re-load PDF from cached bytes and extract the single page
|
||||
try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) {
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PdfJsonPage pageModel = new PdfJsonPage();
|
||||
pageModel.setPageNumber(pageNumber);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
pageModel.setWidth(mediaBox.getWidth());
|
||||
pageModel.setHeight(mediaBox.getHeight());
|
||||
pageModel.setRotation(page.getRotation());
|
||||
|
||||
// Extract text on-demand
|
||||
pageModel.setTextElements(extractText.apply(document, pageNumber));
|
||||
|
||||
// Extract annotations on-demand
|
||||
pageModel.setAnnotations(extractAnnotations.apply(document, pageNumber));
|
||||
|
||||
// Extract images on-demand
|
||||
List<PdfJsonImageElement> images =
|
||||
imageService.extractImagesForPage(document, page, pageNumber);
|
||||
pageModel.setImageElements(images);
|
||||
|
||||
// Extract resources and content streams
|
||||
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
|
||||
COSBase filteredResources = filterImageXObjectsFromResources.apply(resourcesBase);
|
||||
pageModel.setResources(serializeCosValue.apply(filteredResources));
|
||||
pageModel.setContentStreams(extractContentStreams.apply(page));
|
||||
|
||||
log.debug(
|
||||
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
|
||||
pageNumber,
|
||||
pageModel.getTextElements().size(),
|
||||
images.size(),
|
||||
pageModel.getAnnotations().size(),
|
||||
jobId);
|
||||
|
||||
return objectMapper.writeValueAsBytes(pageModel);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears a cached document. */
|
||||
public void clearCachedDocument(String jobId) {
|
||||
CachedPdfDocument cached = documentCache.remove(jobId);
|
||||
if (cached != null) {
|
||||
log.info(
|
||||
"Removed cached PDF bytes ({} bytes) for jobId: {}",
|
||||
cached.getPdfBytes().length,
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedules automatic cleanup of cached documents after 30 minutes. */
|
||||
private void scheduleDocumentCleanup(String jobId) {
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(TimeUnit.MINUTES.toMillis(30));
|
||||
clearCachedDocument(jobId);
|
||||
log.info("Auto-cleaned cached document for jobId: {}", jobId);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report progress to TaskManager for async jobs
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param progress The progress update
|
||||
*/
|
||||
private void reportProgressToTaskManager(String jobId, PdfJsonConversionProgress progress) {
|
||||
try {
|
||||
log.info(
|
||||
"Reporting progress for job {}: {}% - {}",
|
||||
jobId, progress.getPercent(), progress.getStage());
|
||||
String note;
|
||||
if (progress.getCurrent() != null && progress.getTotal() != null) {
|
||||
note =
|
||||
String.format(
|
||||
"[%d%%] %s: %s (%d/%d)",
|
||||
progress.getPercent(),
|
||||
progress.getStage(),
|
||||
progress.getMessage(),
|
||||
progress.getCurrent(),
|
||||
progress.getTotal());
|
||||
} else {
|
||||
note =
|
||||
String.format(
|
||||
"[%d%%] %s: %s",
|
||||
progress.getPercent(), progress.getStage(), progress.getMessage());
|
||||
}
|
||||
boolean added = taskManager.addNote(jobId, note);
|
||||
if (!added) {
|
||||
log.warn("Failed to add note - job {} not found in TaskManager", jobId);
|
||||
} else {
|
||||
log.info("Successfully added progress note for job {}: {}", jobId, note);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Exception reporting progress for job {}: {}", jobId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class Type3ConversionRequest {
|
||||
private final PDDocument document;
|
||||
private final PDType3Font font;
|
||||
private final String fontId;
|
||||
private final int pageNumber;
|
||||
private final String fontUid;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
|
||||
|
||||
public interface Type3ConversionStrategy {
|
||||
|
||||
/** Unique identifier used when reporting results. */
|
||||
String getId();
|
||||
|
||||
/** Human-readable label for UI toggles or logs. */
|
||||
String getLabel();
|
||||
|
||||
/** True when the underlying tooling is usable on this host. */
|
||||
boolean isAvailable();
|
||||
|
||||
/** Quick predicate to avoid running on unsupported Type3 shapes. */
|
||||
default boolean supports(Type3ConversionRequest request, Type3GlyphContext context)
|
||||
throws IOException {
|
||||
return request != null && request.getFont() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to synthesise a font program for the supplied Type3 font.
|
||||
*
|
||||
* @param request contextual information for the conversion attempt
|
||||
* @return a candidate describing the outcome, never {@code null}
|
||||
*/
|
||||
PdfJsonFontConversionCandidate convert(
|
||||
Type3ConversionRequest request, Type3GlyphContext context) throws IOException;
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
|
||||
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class Type3FontConversionService {
|
||||
|
||||
private final List<Type3ConversionStrategy> strategies;
|
||||
private final Type3GlyphExtractor glyphExtractor;
|
||||
|
||||
public List<PdfJsonFontConversionCandidate> synthesize(Type3ConversionRequest request) {
|
||||
if (request == null || request.getFont() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (strategies == null || strategies.isEmpty()) {
|
||||
log.debug(
|
||||
"[TYPE3] No conversion strategies registered for font {}", request.getFontId());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<PdfJsonFontConversionCandidate> candidates = new ArrayList<>();
|
||||
Type3GlyphContext glyphContext = null;
|
||||
for (Type3ConversionStrategy strategy : strategies) {
|
||||
if (strategy == null) {
|
||||
continue;
|
||||
}
|
||||
PdfJsonFontConversionCandidate candidate =
|
||||
runStrategy(
|
||||
strategy,
|
||||
request,
|
||||
glyphContext == null
|
||||
? (glyphContext =
|
||||
new Type3GlyphContext(request, glyphExtractor))
|
||||
: glyphContext);
|
||||
if (candidate != null) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private PdfJsonFontConversionCandidate runStrategy(
|
||||
Type3ConversionStrategy strategy,
|
||||
Type3ConversionRequest request,
|
||||
Type3GlyphContext glyphContext) {
|
||||
if (!strategy.isAvailable()) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(strategy.getId())
|
||||
.strategyLabel(strategy.getLabel())
|
||||
.status(PdfJsonFontConversionStatus.SKIPPED)
|
||||
.message("Strategy unavailable on current host")
|
||||
.build();
|
||||
}
|
||||
try {
|
||||
if (!strategy.supports(request, glyphContext)) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(strategy.getId())
|
||||
.strategyLabel(strategy.getLabel())
|
||||
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
|
||||
.message("Font not supported by strategy")
|
||||
.build();
|
||||
}
|
||||
} catch (IOException supportCheckException) {
|
||||
log.warn(
|
||||
"[TYPE3] Strategy {} support check failed for font {}: {}",
|
||||
strategy.getId(),
|
||||
request.getFontUid(),
|
||||
supportCheckException.getMessage(),
|
||||
supportCheckException);
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(strategy.getId())
|
||||
.strategyLabel(strategy.getLabel())
|
||||
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
|
||||
.message("Support check failed: " + supportCheckException.getMessage())
|
||||
.build();
|
||||
}
|
||||
|
||||
try {
|
||||
PdfJsonFontConversionCandidate result = strategy.convert(request, glyphContext);
|
||||
if (result == null) {
|
||||
log.info(
|
||||
"[TYPE3] Strategy {} returned null result for font {}",
|
||||
strategy.getId(),
|
||||
request.getFontUid());
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(strategy.getId())
|
||||
.strategyLabel(strategy.getLabel())
|
||||
.status(PdfJsonFontConversionStatus.FAILURE)
|
||||
.message("Strategy returned null result")
|
||||
.build();
|
||||
}
|
||||
if (result.getStrategyId() == null) {
|
||||
result.setStrategyId(strategy.getId());
|
||||
}
|
||||
if (result.getStrategyLabel() == null) {
|
||||
result.setStrategyLabel(strategy.getLabel());
|
||||
}
|
||||
log.debug(
|
||||
"[TYPE3] Strategy {} finished with status {} (message: {}) for font {}",
|
||||
strategy.getId(),
|
||||
result.getStatus(),
|
||||
result.getMessage(),
|
||||
request.getFontUid());
|
||||
return result;
|
||||
} catch (IOException ex) {
|
||||
log.warn(
|
||||
"[TYPE3] Strategy {} failed for font {}: {}",
|
||||
strategy.getId(),
|
||||
request.getFontUid(),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(strategy.getId())
|
||||
.strategyLabel(strategy.getLabel())
|
||||
.status(PdfJsonFontConversionStatus.FAILURE)
|
||||
.message(ex.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSNumber;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.apache.pdfbox.pdmodel.font.encoding.Encoding;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
|
||||
/**
|
||||
* Computes a reproducible hash for Type3 fonts so we can match them against a pre-built library of
|
||||
* converted programs. The signature intentionally combines multiple aspects of the font (encoding,
|
||||
* CharProc streams, glyph widths, font metrics) to minimise collisions between unrelated fonts that
|
||||
* coincidentally share glyph names.
|
||||
*/
|
||||
public final class Type3FontSignatureCalculator {
|
||||
|
||||
private Type3FontSignatureCalculator() {}
|
||||
|
||||
public static String computeSignature(PDType3Font font) throws IOException {
|
||||
if (font == null) {
|
||||
return null;
|
||||
}
|
||||
MessageDigest digest = newDigest();
|
||||
updateMatrix(digest, font.getFontMatrix());
|
||||
updateRectangle(digest, font.getFontBBox());
|
||||
updateEncoding(digest, font.getEncoding());
|
||||
updateCharProcs(digest, font);
|
||||
byte[] hash = digest.digest();
|
||||
return "sha256:" + toHex(hash);
|
||||
}
|
||||
|
||||
private static void updateEncoding(MessageDigest digest, Encoding encoding) {
|
||||
if (encoding == null) {
|
||||
updateInt(digest, -1);
|
||||
return;
|
||||
}
|
||||
for (int code = 0; code <= 0xFF; code++) {
|
||||
String name = encoding.getName(code);
|
||||
if (name != null) {
|
||||
updateInt(digest, code);
|
||||
updateString(digest, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateCharProcs(MessageDigest digest, PDType3Font font) throws IOException {
|
||||
COSDictionary charProcs =
|
||||
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
|
||||
if (charProcs == null || charProcs.size() == 0) {
|
||||
updateInt(digest, 0);
|
||||
return;
|
||||
}
|
||||
List<COSName> glyphNames = new ArrayList<>(charProcs.keySet());
|
||||
glyphNames.sort(Comparator.comparing(COSName::getName, String.CASE_INSENSITIVE_ORDER));
|
||||
for (COSName glyphName : glyphNames) {
|
||||
updateString(digest, glyphName.getName());
|
||||
int code = resolveCharCode(font, glyphName.getName());
|
||||
updateInt(digest, code);
|
||||
if (code >= 0) {
|
||||
try {
|
||||
updateFloat(digest, font.getWidthFromFont(code));
|
||||
} catch (IOException ignored) {
|
||||
updateFloat(digest, 0f);
|
||||
}
|
||||
} else {
|
||||
updateFloat(digest, 0f);
|
||||
}
|
||||
|
||||
COSStream stream =
|
||||
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
|
||||
? cosStream
|
||||
: null;
|
||||
if (stream != null) {
|
||||
byte[] payload = readAllBytes(stream);
|
||||
updateInt(digest, payload.length);
|
||||
digest.update(payload);
|
||||
PDType3CharProc charProc = new PDType3CharProc(font, stream);
|
||||
updateRectangle(digest, extractGlyphBoundingBox(font, charProc));
|
||||
} else {
|
||||
updateInt(digest, -1);
|
||||
}
|
||||
}
|
||||
updateInt(digest, glyphNames.size());
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(COSStream stream) throws IOException {
|
||||
try (InputStream inputStream = stream.createInputStream()) {
|
||||
return inputStream.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private static COSArray extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
|
||||
if (charProc == null) {
|
||||
return null;
|
||||
}
|
||||
COSStream stream = charProc.getCOSObject();
|
||||
if (stream != null) {
|
||||
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
|
||||
if (bboxArray != null && bboxArray.size() == 4) {
|
||||
return bboxArray;
|
||||
}
|
||||
}
|
||||
return font.getCOSObject().getCOSArray(COSName.BBOX);
|
||||
}
|
||||
|
||||
private static int resolveCharCode(PDType3Font font, String glyphName) {
|
||||
if (glyphName == null || font.getEncoding() == null) {
|
||||
return -1;
|
||||
}
|
||||
Encoding encoding = font.getEncoding();
|
||||
for (int code = 0; code <= 0xFF; code++) {
|
||||
String name = encoding.getName(code);
|
||||
if (glyphName.equals(name)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void updateMatrix(MessageDigest digest, Matrix matrix) {
|
||||
if (matrix == null) {
|
||||
updateInt(digest, -1);
|
||||
return;
|
||||
}
|
||||
float[][] values = matrix.getValues();
|
||||
updateInt(digest, values.length);
|
||||
for (float[] row : values) {
|
||||
if (row == null) {
|
||||
updateInt(digest, -1);
|
||||
continue;
|
||||
}
|
||||
updateInt(digest, row.length);
|
||||
for (float value : row) {
|
||||
updateFloat(digest, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateRectangle(MessageDigest digest, PDRectangle rectangle) {
|
||||
if (rectangle == null) {
|
||||
updateInt(digest, -1);
|
||||
return;
|
||||
}
|
||||
updateFloat(digest, rectangle.getLowerLeftX());
|
||||
updateFloat(digest, rectangle.getLowerLeftY());
|
||||
updateFloat(digest, rectangle.getUpperRightX());
|
||||
updateFloat(digest, rectangle.getUpperRightY());
|
||||
}
|
||||
|
||||
private static void updateRectangle(MessageDigest digest, COSArray array) {
|
||||
if (array == null) {
|
||||
updateInt(digest, -1);
|
||||
return;
|
||||
}
|
||||
updateInt(digest, array.size());
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
COSBase value = array.getObject(i);
|
||||
if (value instanceof COSNumber number) {
|
||||
updateFloat(digest, number.floatValue());
|
||||
} else {
|
||||
updateFloat(digest, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateString(MessageDigest digest, String value) {
|
||||
if (value == null) {
|
||||
updateInt(digest, -1);
|
||||
return;
|
||||
}
|
||||
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
updateInt(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void updateInt(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
|
||||
private static void updateFloat(MessageDigest digest, float value) {
|
||||
if (Float.isNaN(value) || Float.isInfinite(value)) {
|
||||
value = 0f;
|
||||
}
|
||||
digest.update(ByteBuffer.allocate(Float.BYTES).putFloat(value).array());
|
||||
}
|
||||
|
||||
private static MessageDigest newDigest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("Missing SHA-256 MessageDigest", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
StringBuilder builder = new StringBuilder(bytes.length * 2);
|
||||
for (byte value : bytes) {
|
||||
builder.append(String.format(Locale.ROOT, "%02x", Byte.toUnsignedInt(value)));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
|
||||
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
|
||||
|
||||
class Type3GlyphContext {
|
||||
private final Type3ConversionRequest request;
|
||||
private final Type3GlyphExtractor extractor;
|
||||
private final AtomicReference<List<Type3GlyphOutline>> glyphs = new AtomicReference<>();
|
||||
|
||||
Type3GlyphContext(Type3ConversionRequest request, Type3GlyphExtractor extractor) {
|
||||
this.request = request;
|
||||
this.extractor = extractor;
|
||||
}
|
||||
|
||||
public List<Type3GlyphOutline> getGlyphs() throws IOException {
|
||||
List<Type3GlyphOutline> cached = glyphs.get();
|
||||
if (cached == null) {
|
||||
cached =
|
||||
extractor.extractGlyphs(
|
||||
request.getDocument(),
|
||||
request.getFont(),
|
||||
request.getFontId(),
|
||||
request.getPageNumber());
|
||||
glyphs.compareAndSet(null, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public PDType3Font getFont() {
|
||||
return request.getFont();
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.awt.geom.GeneralPath;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class Type3GlyphExtractor {
|
||||
|
||||
public List<Type3GlyphOutline> extractGlyphs(
|
||||
PDDocument document, PDType3Font font, String fontId, int pageNumber)
|
||||
throws IOException {
|
||||
Objects.requireNonNull(font, "font");
|
||||
COSDictionary charProcs =
|
||||
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
|
||||
if (charProcs == null || charProcs.size() == 0) {
|
||||
return List.of();
|
||||
}
|
||||
List<Type3GlyphOutline> outlines = new ArrayList<>();
|
||||
for (COSName glyphName : charProcs.keySet()) {
|
||||
COSStream stream =
|
||||
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
|
||||
? cosStream
|
||||
: null;
|
||||
if (stream == null) {
|
||||
continue;
|
||||
}
|
||||
PDType3CharProc charProc = new PDType3CharProc(font, stream);
|
||||
outlines.add(analyseGlyph(document, font, glyphName, charProc, fontId, pageNumber));
|
||||
}
|
||||
return outlines;
|
||||
}
|
||||
|
||||
private Type3GlyphOutline analyseGlyph(
|
||||
PDDocument document,
|
||||
PDType3Font font,
|
||||
COSName glyphName,
|
||||
PDType3CharProc charProc,
|
||||
String fontId,
|
||||
int pageNumber)
|
||||
throws IOException {
|
||||
int code = resolveCharCode(font, glyphName.getName());
|
||||
float advanceWidth = 0f;
|
||||
if (code >= 0) {
|
||||
advanceWidth = font.getWidthFromFont(code);
|
||||
}
|
||||
|
||||
PDRectangle glyphBBox = extractGlyphBoundingBox(font, charProc);
|
||||
PDRectangle bbox = font.getFontBBox();
|
||||
GlyphGraphicsExtractor extractor =
|
||||
new GlyphGraphicsExtractor(new PDPage(bbox != null ? bbox : new PDRectangle()));
|
||||
extractor.process(charProc);
|
||||
GeneralPath outline = extractor.getAccumulatedPath();
|
||||
Integer unicodeValue = null;
|
||||
if (code >= 0) {
|
||||
String unicode = font.toUnicode(code);
|
||||
if (unicode != null && !unicode.isEmpty()) {
|
||||
unicodeValue = unicode.codePointAt(0);
|
||||
} else {
|
||||
unicodeValue = code;
|
||||
}
|
||||
}
|
||||
return Type3GlyphOutline.builder()
|
||||
.glyphName(glyphName.getName())
|
||||
.charCode(code)
|
||||
.advanceWidth(advanceWidth)
|
||||
.boundingBox(glyphBBox)
|
||||
.outline(outline)
|
||||
.hasFill(extractor.isSawFill())
|
||||
.hasStroke(extractor.isSawStroke())
|
||||
.hasImages(extractor.isSawImage())
|
||||
.hasText(extractor.isSawText())
|
||||
.hasShading(extractor.isSawShading())
|
||||
.warnings(extractor.getWarnings())
|
||||
.unicode(unicodeValue)
|
||||
.build();
|
||||
}
|
||||
|
||||
private PDRectangle extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
|
||||
COSStream stream = charProc != null ? charProc.getCOSObject() : null;
|
||||
if (stream != null) {
|
||||
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
|
||||
if (bboxArray != null && bboxArray.size() == 4) {
|
||||
return new PDRectangle(bboxArray);
|
||||
}
|
||||
}
|
||||
return font.getFontBBox();
|
||||
}
|
||||
|
||||
private int resolveCharCode(PDType3Font font, String glyphName) {
|
||||
if (glyphName == null || font.getEncoding() == null) {
|
||||
return -1;
|
||||
}
|
||||
for (int code = 0; code <= 0xFF; code++) {
|
||||
String name = font.getEncoding().getName(code);
|
||||
if (glyphName.equals(name)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static final class GlyphGraphicsExtractor extends Type3GraphicsEngine {
|
||||
GlyphGraphicsExtractor(PDPage page) {
|
||||
super(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.awt.geom.GeneralPath;
|
||||
import java.awt.geom.Point2D;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.apache.pdfbox.util.Vector;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
class Type3GraphicsEngine extends PDFGraphicsStreamEngine {
|
||||
|
||||
private final GeneralPath accumulatedPath = new GeneralPath();
|
||||
private final GeneralPath linePath = new GeneralPath();
|
||||
private final Point2D.Float currentPoint = new Point2D.Float();
|
||||
private boolean hasCurrentPoint;
|
||||
@Getter private boolean sawStroke;
|
||||
@Getter private boolean sawFill;
|
||||
@Getter private boolean sawImage;
|
||||
@Getter private boolean sawText;
|
||||
@Getter private boolean sawShading;
|
||||
@Getter private String warnings;
|
||||
|
||||
protected Type3GraphicsEngine(PDPage page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
public GeneralPath getAccumulatedPath() {
|
||||
return (GeneralPath) accumulatedPath.clone();
|
||||
}
|
||||
|
||||
public void process(PDType3CharProc charProc) throws IOException {
|
||||
accumulatedPath.reset();
|
||||
linePath.reset();
|
||||
sawStroke = false;
|
||||
sawFill = false;
|
||||
sawImage = false;
|
||||
sawText = false;
|
||||
sawShading = false;
|
||||
warnings = null;
|
||||
if (charProc != null) {
|
||||
processChildStream(charProc, getPage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) throws IOException {
|
||||
moveTo((float) p0.getX(), (float) p0.getY());
|
||||
lineTo((float) p1.getX(), (float) p1.getY());
|
||||
lineTo((float) p2.getX(), (float) p2.getY());
|
||||
lineTo((float) p3.getX(), (float) p3.getY());
|
||||
closePath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawImage(PDImage pdImage) throws IOException {
|
||||
sawImage = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shadingFill(COSName shadingName) throws IOException {
|
||||
sawShading = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void strokePath() throws IOException {
|
||||
accumulatedPath.append(linePath, false);
|
||||
linePath.reset();
|
||||
sawStroke = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillPath(int windingRule) throws IOException {
|
||||
linePath.setWindingRule(
|
||||
windingRule == 0 ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO);
|
||||
accumulatedPath.append(linePath, false);
|
||||
linePath.reset();
|
||||
sawFill = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillAndStrokePath(int windingRule) throws IOException {
|
||||
fillPath(windingRule);
|
||||
sawStroke = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clip(int windingRule) throws IOException {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveTo(float x, float y) throws IOException {
|
||||
linePath.moveTo(x, y);
|
||||
currentPoint.setLocation(x, y);
|
||||
hasCurrentPoint = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void lineTo(float x, float y) throws IOException {
|
||||
linePath.lineTo(x, y);
|
||||
currentPoint.setLocation(x, y);
|
||||
hasCurrentPoint = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
|
||||
throws IOException {
|
||||
linePath.curveTo(x1, y1, x2, y2, x3, y3);
|
||||
currentPoint.setLocation(x3, y3);
|
||||
hasCurrentPoint = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Point2D getCurrentPoint() throws IOException {
|
||||
return hasCurrentPoint ? (Point2D) currentPoint.clone() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closePath() throws IOException {
|
||||
linePath.closePath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endPath() throws IOException {
|
||||
linePath.reset();
|
||||
hasCurrentPoint = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showText(byte[] string) throws IOException {
|
||||
sawText = true;
|
||||
super.showText(string);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showFontGlyph(
|
||||
Matrix textRenderingMatrix, PDFont font, int code, Vector displacement)
|
||||
throws IOException {
|
||||
sawText = true;
|
||||
super.showFontGlyph(textRenderingMatrix, font, code, displacement);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processOperator(
|
||||
Operator operator, java.util.List<org.apache.pdfbox.cos.COSBase> operands)
|
||||
throws IOException {
|
||||
if ("cm".equals(operator.getName())) {
|
||||
warnings =
|
||||
warnings == null ? "Encountered CTM concatenation" : warnings + "; CTM concat";
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
|
||||
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibrary;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryEntry;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryMatch;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayload;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(0)
|
||||
@RequiredArgsConstructor
|
||||
public class Type3LibraryStrategy implements Type3ConversionStrategy {
|
||||
|
||||
private final Type3FontLibrary fontLibrary;
|
||||
|
||||
@Value("${stirling.pdf.json.type3.library.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "type3-library";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return "Type3 Font Library";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return enabled && fontLibrary != null && fontLibrary.isLoaded();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PdfJsonFontConversionCandidate convert(
|
||||
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
|
||||
if (request == null || request.getFont() == null) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(getId())
|
||||
.strategyLabel(getLabel())
|
||||
.status(PdfJsonFontConversionStatus.FAILURE)
|
||||
.message("No font supplied")
|
||||
.build();
|
||||
}
|
||||
if (!isAvailable()) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(getId())
|
||||
.strategyLabel(getLabel())
|
||||
.status(PdfJsonFontConversionStatus.SKIPPED)
|
||||
.message("Library disabled")
|
||||
.build();
|
||||
}
|
||||
|
||||
Type3FontLibraryMatch match = fontLibrary.match(request.getFont(), request.getFontUid());
|
||||
if (match == null || match.getEntry() == null) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(getId())
|
||||
.strategyLabel(getLabel())
|
||||
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
|
||||
.message("No library entry found")
|
||||
.build();
|
||||
}
|
||||
|
||||
Type3FontLibraryEntry entry = match.getEntry();
|
||||
if (!entry.hasAnyPayload()) {
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(getId())
|
||||
.strategyLabel(getLabel())
|
||||
.status(PdfJsonFontConversionStatus.FAILURE)
|
||||
.message("Library entry has no payloads")
|
||||
.build();
|
||||
}
|
||||
|
||||
String message =
|
||||
String.format(
|
||||
"Matched %s via %s",
|
||||
entry.getLabel(),
|
||||
match.getMatchType() != null ? match.getMatchType() : "alias");
|
||||
|
||||
return PdfJsonFontConversionCandidate.builder()
|
||||
.strategyId(getId())
|
||||
.strategyLabel(getLabel())
|
||||
.status(PdfJsonFontConversionStatus.SUCCESS)
|
||||
.program(toBase64(entry.getProgram()))
|
||||
.programFormat(toFormat(entry.getProgram()))
|
||||
.webProgram(toBase64(entry.getWebProgram()))
|
||||
.webProgramFormat(toFormat(entry.getWebProgram()))
|
||||
.pdfProgram(toBase64(entry.getPdfProgram()))
|
||||
.pdfProgramFormat(toFormat(entry.getPdfProgram()))
|
||||
.glyphCoverage(entry.getGlyphCoverage())
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String toBase64(Type3FontLibraryPayload payload) {
|
||||
return payload != null ? payload.getBase64() : null;
|
||||
}
|
||||
|
||||
private String toFormat(Type3FontLibraryPayload payload) {
|
||||
return payload != null ? payload.getFormat() : null;
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.library;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class Type3FontLibrary {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
|
||||
private String indexLocation;
|
||||
|
||||
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
|
||||
private final Map<String, Type3FontLibraryEntry> aliasIndex = new ConcurrentHashMap<>();
|
||||
private List<Type3FontLibraryEntry> entries = List.of();
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void initialise() {
|
||||
Resource resource = resourceLoader.getResource(indexLocation);
|
||||
if (!resource.exists()) {
|
||||
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
|
||||
entries = List.of();
|
||||
return;
|
||||
}
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
List<RawEntry> rawEntries =
|
||||
objectMapper.readValue(inputStream, new TypeReference<List<RawEntry>>() {});
|
||||
List<Type3FontLibraryEntry> loaded = new ArrayList<>();
|
||||
for (RawEntry rawEntry : rawEntries) {
|
||||
Type3FontLibraryEntry entry = toEntry(rawEntry);
|
||||
if (entry != null && entry.hasAnyPayload()) {
|
||||
loaded.add(entry);
|
||||
}
|
||||
}
|
||||
entries = Collections.unmodifiableList(loaded);
|
||||
signatureIndex.clear();
|
||||
aliasIndex.clear();
|
||||
|
||||
for (Type3FontLibraryEntry entry : entries) {
|
||||
if (entry.getSignatures() != null) {
|
||||
for (String signature : entry.getSignatures()) {
|
||||
if (signature == null) {
|
||||
continue;
|
||||
}
|
||||
String key = signature.toLowerCase(Locale.ROOT);
|
||||
signatureIndex.putIfAbsent(key, entry);
|
||||
}
|
||||
}
|
||||
if (entry.getAliases() != null) {
|
||||
for (String alias : entry.getAliases()) {
|
||||
String normalized = normalizeAlias(alias);
|
||||
if (normalized != null) {
|
||||
aliasIndex.putIfAbsent(normalized, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info(
|
||||
"[TYPE3] Loaded {} Type3 library entries (signatures={}, aliases={}) from {}",
|
||||
entries.size(),
|
||||
signatureIndex.size(),
|
||||
aliasIndex.size(),
|
||||
indexLocation);
|
||||
} catch (IOException ex) {
|
||||
log.warn(
|
||||
"[TYPE3] Failed to load Type3 library index {}: {}",
|
||||
indexLocation,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
entries = List.of();
|
||||
signatureIndex.clear();
|
||||
aliasIndex.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return !entries.isEmpty();
|
||||
}
|
||||
|
||||
public Type3FontLibraryMatch match(PDType3Font font, String fontUid) throws IOException {
|
||||
if (font == null || entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String signature = Type3FontSignatureCalculator.computeSignature(font);
|
||||
if (signature != null) {
|
||||
Type3FontLibraryEntry entry = signatureIndex.get(signature.toLowerCase(Locale.ROOT));
|
||||
if (entry != null) {
|
||||
log.debug(
|
||||
"[TYPE3] Matched Type3 font {} to library entry {} via signature {}",
|
||||
fontUid,
|
||||
entry.getId(),
|
||||
signature);
|
||||
return Type3FontLibraryMatch.builder()
|
||||
.entry(entry)
|
||||
.matchType("signature")
|
||||
.signature(signature)
|
||||
.build();
|
||||
}
|
||||
log.debug(
|
||||
"[TYPE3] No library entry for signature {} (font {})",
|
||||
signature,
|
||||
fontUid != null ? fontUid : font.getName());
|
||||
}
|
||||
|
||||
String aliasKey = normalizeAlias(resolveBaseFontName(font));
|
||||
if (aliasKey != null) {
|
||||
Type3FontLibraryEntry entry = aliasIndex.get(aliasKey);
|
||||
if (entry != null) {
|
||||
log.debug(
|
||||
"[TYPE3] Matched Type3 font {} to library entry {} via alias {}",
|
||||
fontUid,
|
||||
entry.getId(),
|
||||
aliasKey);
|
||||
return Type3FontLibraryMatch.builder()
|
||||
.entry(entry)
|
||||
.matchType("alias:" + aliasKey)
|
||||
.signature(signature)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
if (signature != null) {
|
||||
log.debug(
|
||||
"[TYPE3] Library had no alias match for signature {} (font {})",
|
||||
signature,
|
||||
fontUid != null ? fontUid : font.getName());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Type3FontLibraryEntry toEntry(RawEntry rawEntry) {
|
||||
if (rawEntry == null || rawEntry.id == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Type3FontLibraryEntry.Type3FontLibraryEntryBuilder builder =
|
||||
Type3FontLibraryEntry.builder()
|
||||
.id(rawEntry.id)
|
||||
.label(rawEntry.label != null ? rawEntry.label : rawEntry.id)
|
||||
.signatures(normalizeList(rawEntry.signatures))
|
||||
.aliases(normalizeList(rawEntry.aliases))
|
||||
.program(loadPayload(rawEntry.program))
|
||||
.webProgram(loadPayload(rawEntry.webProgram))
|
||||
.pdfProgram(loadPayload(rawEntry.pdfProgram))
|
||||
.source(rawEntry.source);
|
||||
if (rawEntry.glyphCoverage != null && !rawEntry.glyphCoverage.isEmpty()) {
|
||||
for (Integer codePoint : rawEntry.glyphCoverage) {
|
||||
if (codePoint != null) {
|
||||
builder.glyphCode(codePoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
} catch (IOException ex) {
|
||||
log.warn(
|
||||
"[TYPE3] Failed to load Type3 library entry {}: {}",
|
||||
rawEntry.id,
|
||||
ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Type3FontLibraryPayload loadPayload(RawPayload payload) throws IOException {
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] data = null;
|
||||
if (payload.base64 != null && !payload.base64.isBlank()) {
|
||||
try {
|
||||
data = Base64.getDecoder().decode(payload.base64);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage());
|
||||
}
|
||||
} else if (payload.resource != null && !payload.resource.isBlank()) {
|
||||
data = loadResourceBytes(payload.resource);
|
||||
}
|
||||
if (data == null || data.length == 0) {
|
||||
return null;
|
||||
}
|
||||
String base64 = Base64.getEncoder().encodeToString(data);
|
||||
return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format));
|
||||
}
|
||||
|
||||
private byte[] loadResourceBytes(String location) throws IOException {
|
||||
String resolved = resolveLocation(location);
|
||||
Resource resource = resourceLoader.getResource(resolved);
|
||||
if (!resource.exists()) {
|
||||
throw new IOException("Resource not found: " + resolved);
|
||||
}
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
return inputStream.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveLocation(String location) {
|
||||
if (location == null || location.isBlank()) {
|
||||
return location;
|
||||
}
|
||||
if (location.contains(":")) {
|
||||
return location;
|
||||
}
|
||||
if (location.startsWith("/")) {
|
||||
return "classpath:" + location;
|
||||
}
|
||||
return "classpath:/" + location;
|
||||
}
|
||||
|
||||
private List<String> normalizeList(List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return values.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String normalizeAlias(String alias) {
|
||||
if (alias == null) {
|
||||
return null;
|
||||
}
|
||||
String value = alias.trim();
|
||||
int plus = value.indexOf('+');
|
||||
if (plus >= 0 && plus < value.length() - 1) {
|
||||
value = value.substring(plus + 1);
|
||||
}
|
||||
return value.isEmpty() ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeFormat(String format) {
|
||||
if (format == null) {
|
||||
return null;
|
||||
}
|
||||
return format.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String resolveBaseFontName(PDType3Font font) {
|
||||
if (font == null) {
|
||||
return null;
|
||||
}
|
||||
String baseName = null;
|
||||
try {
|
||||
baseName = font.getName();
|
||||
} catch (Exception ignored) {
|
||||
// Some Type3 fonts throw when resolving names; fall back to COS dictionary.
|
||||
}
|
||||
if (baseName == null && font.getCOSObject() != null) {
|
||||
baseName = font.getCOSObject().getNameAsString(COSName.BASE_FONT);
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
|
||||
private static final class RawEntry {
|
||||
public String id;
|
||||
public String label;
|
||||
public List<String> signatures;
|
||||
public List<String> aliases;
|
||||
public RawPayload program;
|
||||
public RawPayload webProgram;
|
||||
public RawPayload pdfProgram;
|
||||
public List<Integer> glyphCoverage;
|
||||
public String source;
|
||||
}
|
||||
|
||||
private static final class RawPayload {
|
||||
public String resource;
|
||||
public String format;
|
||||
public String base64;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.library;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class Type3FontLibraryEntry {
|
||||
String id;
|
||||
String label;
|
||||
@Singular List<String> signatures;
|
||||
@Singular List<String> aliases;
|
||||
Type3FontLibraryPayload program;
|
||||
Type3FontLibraryPayload webProgram;
|
||||
Type3FontLibraryPayload pdfProgram;
|
||||
|
||||
@Singular("glyphCode")
|
||||
List<Integer> glyphCoverage;
|
||||
|
||||
String source;
|
||||
|
||||
public boolean hasAnyPayload() {
|
||||
return (program != null && program.hasPayload())
|
||||
|| (webProgram != null && webProgram.hasPayload())
|
||||
|| (pdfProgram != null && pdfProgram.hasPayload());
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.library;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class Type3FontLibraryMatch {
|
||||
Type3FontLibraryEntry entry;
|
||||
String matchType;
|
||||
String signature;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.library;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class Type3FontLibraryPayload {
|
||||
String base64;
|
||||
String format;
|
||||
|
||||
public boolean hasPayload() {
|
||||
return base64 != null && !base64.isBlank();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.model;
|
||||
|
||||
import java.awt.geom.GeneralPath;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class Type3GlyphOutline {
|
||||
String glyphName;
|
||||
int charCode;
|
||||
float advanceWidth;
|
||||
PDRectangle boundingBox;
|
||||
GeneralPath outline;
|
||||
boolean hasStroke;
|
||||
boolean hasFill;
|
||||
boolean hasImages;
|
||||
boolean hasText;
|
||||
boolean hasShading;
|
||||
String warnings;
|
||||
Integer unicode;
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package stirling.software.SPDF.service.pdfjson.type3.tool;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectWriter;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
|
||||
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
|
||||
|
||||
/**
|
||||
* Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally
|
||||
* emits JSON describing the glyph coverage. This allows Type3 library entries to be added without
|
||||
* digging through backend logs.
|
||||
*
|
||||
* <p>Usage:
|
||||
*
|
||||
* <pre>
|
||||
* ./gradlew :proprietary:type3SignatureTool --args="--pdf path/to/sample.pdf --output type3.json --pretty"
|
||||
* </pre>
|
||||
*/
|
||||
public final class Type3SignatureTool {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER =
|
||||
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
private Type3SignatureTool() {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Arguments arguments = Arguments.parse(args);
|
||||
if (arguments.showHelp || arguments.pdf == null) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
Path pdfPath = arguments.pdf.toAbsolutePath();
|
||||
if (!Files.exists(pdfPath)) {
|
||||
throw new IOException("PDF not found: " + pdfPath);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> fonts;
|
||||
try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) {
|
||||
fonts = collectType3Fonts(document);
|
||||
}
|
||||
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("pdf", pdfPath.toString());
|
||||
output.put("fonts", fonts);
|
||||
ObjectWriter writer =
|
||||
arguments.pretty
|
||||
? OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
|
||||
: OBJECT_MAPPER.writer();
|
||||
if (arguments.output != null) {
|
||||
Path parent = arguments.output.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
writer.writeValue(arguments.output.toFile(), output);
|
||||
verifyOutput(arguments.output, fonts.size());
|
||||
} else {
|
||||
writer.writeValue(System.out, output);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> collectType3Fonts(PDDocument document)
|
||||
throws IOException {
|
||||
if (document == null || document.getNumberOfPages() == 0) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> fonts = new ArrayList<>();
|
||||
Type3GlyphExtractor glyphExtractor = new Type3GlyphExtractor();
|
||||
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
|
||||
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDResources resources = page.getResources();
|
||||
if (resources == null) {
|
||||
continue;
|
||||
}
|
||||
scanResources(document, pageIndex + 1, resources, glyphExtractor, visited, fonts);
|
||||
}
|
||||
return fonts;
|
||||
}
|
||||
|
||||
private static void scanResources(
|
||||
PDDocument document,
|
||||
int pageNumber,
|
||||
PDResources resources,
|
||||
Type3GlyphExtractor glyphExtractor,
|
||||
Set<Object> visited,
|
||||
List<Map<String, Object>> fonts)
|
||||
throws IOException {
|
||||
if (resources == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (COSName name : resources.getFontNames()) {
|
||||
PDFont font = resources.getFont(name);
|
||||
if (!(font instanceof PDType3Font type3Font)) {
|
||||
continue;
|
||||
}
|
||||
Object cosObject = type3Font.getCOSObject();
|
||||
if (cosObject != null && !visited.add(cosObject)) {
|
||||
continue;
|
||||
}
|
||||
fonts.add(
|
||||
describeFont(document, pageNumber, name.getName(), type3Font, glyphExtractor));
|
||||
}
|
||||
|
||||
Deque<PDResources> embedded = new ArrayDeque<>();
|
||||
for (COSName name : resources.getXObjectNames()) {
|
||||
PDXObject xobject = resources.getXObject(name);
|
||||
if (xobject instanceof PDFormXObject form && form.getResources() != null) {
|
||||
embedded.add(form.getResources());
|
||||
}
|
||||
}
|
||||
while (!embedded.isEmpty()) {
|
||||
scanResources(document, pageNumber, embedded.pop(), glyphExtractor, visited, fonts);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> describeFont(
|
||||
PDDocument document,
|
||||
int pageNumber,
|
||||
String fontId,
|
||||
PDType3Font font,
|
||||
Type3GlyphExtractor glyphExtractor)
|
||||
throws IOException {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("pageNumber", pageNumber);
|
||||
payload.put("fontId", fontId);
|
||||
payload.put("baseName", safeFontName(font));
|
||||
payload.put("alias", normalizeAlias(safeFontName(font)));
|
||||
payload.put("encoding", resolveEncoding(font));
|
||||
payload.put("signature", Type3FontSignatureCalculator.computeSignature(font));
|
||||
|
||||
List<Type3GlyphOutline> glyphs =
|
||||
glyphExtractor.extractGlyphs(document, font, fontId, pageNumber);
|
||||
payload.put("glyphCount", glyphs != null ? glyphs.size() : 0);
|
||||
|
||||
Set<Integer> coverage = new TreeSet<>();
|
||||
if (glyphs != null) {
|
||||
for (Type3GlyphOutline glyph : glyphs) {
|
||||
if (glyph == null) {
|
||||
continue;
|
||||
}
|
||||
if (glyph.getUnicode() != null) {
|
||||
coverage.add(glyph.getUnicode());
|
||||
} else if (glyph.getCharCode() >= 0) {
|
||||
coverage.add(0xF000 | (glyph.getCharCode() & 0xFF));
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> warnings = new ArrayList<>();
|
||||
for (Type3GlyphOutline glyph : glyphs) {
|
||||
if (glyph != null && glyph.getWarnings() != null) {
|
||||
Map<String, Object> warning = new LinkedHashMap<>();
|
||||
warning.put("glyphName", glyph.getGlyphName());
|
||||
warning.put("message", glyph.getWarnings());
|
||||
warnings.add(warning);
|
||||
}
|
||||
}
|
||||
if (!warnings.isEmpty()) {
|
||||
payload.put("warnings", warnings);
|
||||
}
|
||||
}
|
||||
if (!coverage.isEmpty()) {
|
||||
payload.put("glyphCoverage", new ArrayList<>(coverage));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void verifyOutput(Path output, int fontCount) throws IOException {
|
||||
Path absolute = output.toAbsolutePath();
|
||||
if (!Files.exists(absolute)) {
|
||||
throw new IOException("Expected output file not found: " + absolute);
|
||||
}
|
||||
long size = Files.size(absolute);
|
||||
if (size == 0) {
|
||||
throw new IOException("Output file is empty: " + absolute);
|
||||
}
|
||||
System.out.println(
|
||||
"Wrote " + fontCount + " fonts to " + absolute + " (" + size + " bytes, verified)");
|
||||
}
|
||||
|
||||
private static String resolveEncoding(PDType3Font font) {
|
||||
if (font == null || font.getEncoding() == null) {
|
||||
return null;
|
||||
}
|
||||
Object encoding = font.getCOSObject().getDictionaryObject(COSName.ENCODING);
|
||||
return encoding != null
|
||||
? encoding.toString()
|
||||
: font.getEncoding().getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private static String safeFontName(PDType3Font font) {
|
||||
if (font == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (font.getName() != null) {
|
||||
return font.getName();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
if (font.getCOSObject() != null) {
|
||||
return font.getCOSObject().getNameAsString(COSName.BASE_FONT);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeAlias(String name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
int plus = name.indexOf('+');
|
||||
String normalized = plus >= 0 ? name.substring(plus + 1) : name;
|
||||
normalized = normalized.trim();
|
||||
return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static void printUsage() {
|
||||
System.out.println(
|
||||
"""
|
||||
Type3SignatureTool - dump Type3 font signatures for library building
|
||||
Usage:
|
||||
--pdf <file.pdf> Input PDF to analyse (required)
|
||||
--output <file.json> Optional output file (defaults to stdout)
|
||||
--pretty Pretty-print JSON output
|
||||
--help Show this help
|
||||
|
||||
Example:
|
||||
./gradlew :proprietary:type3SignatureTool --args="--pdf samples/foo.pdf --output foo.json --pretty"
|
||||
""");
|
||||
}
|
||||
|
||||
private static final class Arguments {
|
||||
private final Path pdf;
|
||||
private final Path output;
|
||||
private final boolean pretty;
|
||||
private final boolean showHelp;
|
||||
|
||||
private Arguments(Path pdf, Path output, boolean pretty, boolean showHelp) {
|
||||
this.pdf = pdf;
|
||||
this.output = output;
|
||||
this.pretty = pretty;
|
||||
this.showHelp = showHelp;
|
||||
}
|
||||
|
||||
static Arguments parse(String[] args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return new Arguments(null, null, true, true);
|
||||
}
|
||||
Path pdf = null;
|
||||
Path output = null;
|
||||
boolean pretty = false;
|
||||
boolean showHelp = false;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String arg = args[i];
|
||||
if ("--pdf".equals(arg) && i + 1 < args.length) {
|
||||
pdf = Paths.get(args[++i]);
|
||||
} else if ("--output".equals(arg) && i + 1 < args.length) {
|
||||
output = Paths.get(args[++i]);
|
||||
} else if ("--pretty".equals(arg)) {
|
||||
pretty = true;
|
||||
} else if ("--help".equals(arg) || "-h".equals(arg)) {
|
||||
showHelp = true;
|
||||
}
|
||||
}
|
||||
return new Arguments(pdf, output, pretty, showHelp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -24,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -41,6 +43,9 @@ public class JobController {
|
||||
private final JobQueue jobQueue;
|
||||
private final HttpServletRequest request;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JobOwnershipService jobOwnershipService;
|
||||
|
||||
/**
|
||||
* Get the status of a job
|
||||
*
|
||||
@@ -50,6 +55,13 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}")
|
||||
@Operation(summary = "Get job status")
|
||||
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job status: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -79,6 +91,13 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}/result")
|
||||
@Operation(summary = "Get job result")
|
||||
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job result: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -144,13 +163,8 @@ public class JobController {
|
||||
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
|
||||
log.debug("Request to cancel job: {}", jobId);
|
||||
|
||||
// Verify that this job belongs to the current user
|
||||
// We can use the current request's session to validate ownership
|
||||
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
|
||||
if (sessionJobIds == null
|
||||
|| !(sessionJobIds instanceof java.util.Set)
|
||||
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
|
||||
// Either no jobs in session or jobId doesn't match user's jobs
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to cancel job: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to cancel this job"));
|
||||
@@ -210,6 +224,13 @@ public class JobController {
|
||||
@GetMapping("/job/{jobId}/result/files")
|
||||
@Operation(summary = "Get job result files")
|
||||
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
|
||||
// Validate job ownership
|
||||
if (!validateJobAccess(jobId)) {
|
||||
log.warn("Unauthorized attempt to access job files: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this job"));
|
||||
}
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -330,4 +351,26 @@ public class JobController {
|
||||
return "attachment; filename=\"" + fileName + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
* @param jobId the job identifier to validate
|
||||
* @return true if user has access, false otherwise
|
||||
*/
|
||||
private boolean validateJobAccess(String jobId) {
|
||||
// If JobOwnershipService is available (security enabled), use it
|
||||
if (jobOwnershipService != null) {
|
||||
try {
|
||||
return jobOwnershipService.validateJobAccess(jobId);
|
||||
} catch (SecurityException e) {
|
||||
log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Security disabled - allow all access (backwards compatibility)
|
||||
// When security is not enabled, any user can access any job by jobId
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
multipart.enabled=true
|
||||
logging.level.org.springframework=WARN
|
||||
logging.level.org.springframework.security=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
@@ -7,6 +8,9 @@ logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
|
||||
logging.level.stirling.software.common.service.JobExecutorService=INFO
|
||||
logging.level.stirling.software.common.service.TaskManager=INFO
|
||||
spring.jpa.open-in-view=false
|
||||
server.forward-headers-strategy=NATIVE
|
||||
server.error.path=/error
|
||||
@@ -48,7 +52,7 @@ server.servlet.session.timeout:30m
|
||||
springdoc.api-docs.path=/v1/api-docs
|
||||
# Set the URL of the OpenAPI JSON for the Swagger UI
|
||||
springdoc.swagger-ui.url=/v1/api-docs
|
||||
springdoc.swagger-ui.path=/index.html
|
||||
springdoc.swagger-ui.path=/swagger-ui.html
|
||||
# Force OpenAPI 3.0 specification version
|
||||
springdoc.api-docs.version=OPENAPI_3_0
|
||||
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user