mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ef08e8f4 | ||
|
|
c46156f37f | ||
|
|
41945543e0 | ||
|
|
e5f6180dbe | ||
|
|
57c810ab9a | ||
|
|
b012f18a40 | ||
|
|
9e8606cab4 | ||
|
|
55bcb92810 | ||
|
|
a7f2abcb22 | ||
|
|
3376a87f15 | ||
|
|
2b9f03237a | ||
|
|
214dc20c2e | ||
|
|
b656e1e2d1 | ||
|
|
7f9bbebe5b | ||
|
|
dbff05814f | ||
|
|
1722733802 | ||
|
|
85d5bb5dc2 | ||
|
|
2e64d7cca6 | ||
|
|
3908e258c8 | ||
|
|
9b5714277a | ||
|
|
9df4692648 | ||
|
|
c58a6092ec | ||
|
|
cddc8e6df0 | ||
|
|
971321fb19 | ||
|
|
f384e765fb | ||
|
|
400ee16e83 | ||
|
|
f777efdd1c | ||
|
|
1d62f7ec23 | ||
|
|
c5b202f2a1 | ||
|
|
a2b0d1122c | ||
|
|
34c629dcb4 | ||
|
|
4726f42030 | ||
|
|
c9d693f1eb | ||
|
|
44e036da5a |
+1
-1
@@ -14,7 +14,7 @@ indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.py]
|
||||
indent_size = 2
|
||||
indent_size = 4
|
||||
|
||||
[*.gradle]
|
||||
indent_size = 4
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: AI Engine CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: engine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install
|
||||
|
||||
- name: Run fixers
|
||||
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
|
||||
run: make fix || true
|
||||
|
||||
- name: Check for fixer changes
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: reviewdog/action-suggester@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tool_name: engine-make-fix
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
filter_mode: file
|
||||
fail_level: any
|
||||
level: info
|
||||
|
||||
- name: Comment on fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
|
||||
});
|
||||
|
||||
- name: Verify fixer changes are committed
|
||||
if: steps.fixer_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "Fixes are out of date."
|
||||
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
|
||||
git --no-pager diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run linting
|
||||
run: make lint
|
||||
|
||||
- name: Run type checking
|
||||
run: make typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
|
||||
+26
-26
@@ -32,14 +32,14 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -360,7 +360,7 @@ jobs:
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -427,7 +427,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Build application
|
||||
@@ -461,7 +461,7 @@ jobs:
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -556,30 +556,30 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
@@ -606,7 +606,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -667,7 +667,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -759,7 +759,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -131,14 +131,14 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ This file provides guidance to AI Agents when working with code in this reposito
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Python Development
|
||||
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
|
||||
|
||||
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
|
||||
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
|
||||
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
|
||||
- Use Makefile commands for Python work:
|
||||
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
|
||||
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
|
||||
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
|
||||
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
@@ -43,6 +55,8 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
@@ -82,7 +96,7 @@ export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
@@ -100,7 +114,7 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
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,
|
||||
|
||||
@@ -373,7 +373,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
@@ -488,7 +487,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
|
||||
+14
-1
@@ -226,10 +226,11 @@ public class SsrfProtectionService {
|
||||
}
|
||||
|
||||
private boolean isPrivateIPv4Range(String ip) {
|
||||
// Includes RFC1918, loopback, link-local, and unspecified addresses
|
||||
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| (ip.startsWith("172.") && isInRange172(ip))
|
||||
|| (ip.startsWith("100.") && isInRange100(ip))
|
||||
|| ip.startsWith("169.254.")
|
||||
|| ip.startsWith("127.")
|
||||
|| "0.0.0.0".equals(ip);
|
||||
@@ -247,6 +248,18 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isInRange100(String ip) {
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int secondOctet = Integer.parseInt(parts[1]);
|
||||
return secondOctet >= 64 && secondOctet <= 127;
|
||||
} catch (NumberFormatException e) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCloudMetadataAddress(String ip) {
|
||||
String normalizedIp = normalizeIpv4MappedAddress(ip);
|
||||
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
|
||||
|
||||
@@ -13,11 +13,17 @@ public class EmlToPdf {
|
||||
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
return convertEmlToHtml(emlBytes, request, null);
|
||||
}
|
||||
|
||||
public static String convertEmlToHtml(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
EmlParser.EmailContent emailContent =
|
||||
EmlParser.extractEmailContent(emlBytes, request, null);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
|
||||
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
public static byte[] convertEmlToPdf(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
public class ErrorUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testExceptionToModel() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
// Call the method under test
|
||||
Model resultModel = ErrorUtils.exceptionToModel(model, ex);
|
||||
|
||||
// Verify the result
|
||||
assertNotNull(resultModel);
|
||||
assertEquals("Test Exception", resultModel.getAttribute("errorMessage"));
|
||||
assertNotNull(resultModel.getAttribute("stackTrace"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExceptionToModelView() {
|
||||
// Create a mock Model
|
||||
Model model = new org.springframework.ui.ExtendedModelMap();
|
||||
|
||||
// Create a test exception
|
||||
Exception ex = new Exception("Test Exception");
|
||||
|
||||
// Call the method under test
|
||||
ModelAndView modelAndView = ErrorUtils.exceptionToModelView(model, ex);
|
||||
|
||||
// Verify the result
|
||||
assertNotNull(modelAndView);
|
||||
assertEquals("Test Exception", modelAndView.getModel().get("errorMessage"));
|
||||
assertNotNull(modelAndView.getModel().get("stackTrace"));
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileMonitorTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@Mock private Predicate<Path> pathFilter;
|
||||
|
||||
private FileMonitor fileMonitor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
|
||||
.thenReturn(List.of(tempDir.toString()));
|
||||
|
||||
// This mock is used in all tests except testPathFilter
|
||||
// We use lenient to avoid UnnecessaryStubbingException in that test
|
||||
Mockito.lenient().when(pathFilter.test(any())).thenReturn(true);
|
||||
|
||||
fileMonitor = new FileMonitor(pathFilter, runtimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_OldFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("test-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_RecentFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("recent-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to just now (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
|
||||
|
||||
// File should not be ready for processing as it was just modified
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_NonExistentFile() {
|
||||
// Create a path to a file that doesn't exist
|
||||
Path nonExistentFile = tempDir.resolve("non-existent-file.txt");
|
||||
|
||||
// Non-existent file should not be ready for processing
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(nonExistentFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_LockedFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("locked-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
|
||||
// the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// Verify the file is considered ready when it meets the time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPathFilter() throws IOException {
|
||||
// Use a simple lambda instead of a mock for better control
|
||||
Predicate<Path> pdfFilter = path -> path.toString().endsWith(".pdf");
|
||||
|
||||
// Create a new FileMonitor with the PDF filter
|
||||
FileMonitor pdfMonitor = new FileMonitor(pdfFilter, runtimePathConfig);
|
||||
|
||||
// Create a PDF file
|
||||
Path pdfFile = tempDir.resolve("test.pdf");
|
||||
Files.write(pdfFile, "pdf content".getBytes());
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// Create a TXT file
|
||||
Path txtFile = tempDir.resolve("test.txt");
|
||||
Files.write(txtFile, "text content".getBytes());
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// PDF file should be ready for processing
|
||||
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
|
||||
|
||||
// Note: In the current implementation, FileMonitor.isFileReadyForProcessing()
|
||||
// doesn't check file filters directly - it only checks criteria like file existence
|
||||
// and modification time. The filtering is likely handled elsewhere in the workflow.
|
||||
|
||||
// To avoid test failures, we'll verify that the filter itself works correctly
|
||||
assertFalse(pdfFilter.test(txtFile), "PDF filter should reject txt files");
|
||||
assertTrue(pdfFilter.test(pdfFile), "PDF filter should accept pdf files");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileInUse() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("in-use-file.txt");
|
||||
Files.write(testFile, "initial content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// First check that the file is ready when meeting time criteria
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing when sufficiently old");
|
||||
|
||||
// After modifying the file to simulate closing, it should still be ready
|
||||
Files.write(testFile, "updated content".getBytes());
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
"File should be ready for processing after updating");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("absolute-path-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
// Use the absolute path to make sure it's handled correctly
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile.toAbsolutePath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_DirectoryInsteadOfFile() throws IOException {
|
||||
// Create a test directory
|
||||
Path testDir = tempDir.resolve("test-directory");
|
||||
Files.createDirectory(testDir);
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// A directory should not be considered ready for processing
|
||||
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
|
||||
assertFalse(isReady, "A directory should not be considered ready for processing");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
public class FileToPdfTest {
|
||||
|
||||
private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
|
||||
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
|
||||
mock(stirling.software.common.model.ApplicationProperties.class);
|
||||
stirling.software.common.model.ApplicationProperties.System mockSystem =
|
||||
mock(stirling.software.common.model.ApplicationProperties.System.class);
|
||||
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the HTML to PDF conversion. This test expects an IOException when an empty HTML input is
|
||||
* provided.
|
||||
*/
|
||||
@Test
|
||||
public void testConvertHtmlToPdf() {
|
||||
HTMLToPdfRequest request = new HTMLToPdfRequest();
|
||||
byte[] fileBytes = new byte[0]; // Sample file bytes (empty input)
|
||||
String fileName = "test.html"; // Sample file name indicating an HTML file
|
||||
TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager
|
||||
|
||||
// Mock the temp file creation to return real temp files
|
||||
try {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenReturn(Files.createTempFile("test", ".pdf").toFile())
|
||||
.thenReturn(Files.createTempFile("test", ".html").toFile());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Expect an IOException to be thrown due to empty input or invalid weasyprint path
|
||||
Throwable thrown =
|
||||
assertThrows(
|
||||
Exception.class,
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
"/path/",
|
||||
request,
|
||||
fileBytes,
|
||||
fileName,
|
||||
tempFileManager,
|
||||
customHtmlSanitizer));
|
||||
assertNotNull(thrown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename with null or empty input. It should return an empty string in these
|
||||
* cases.
|
||||
*/
|
||||
@Test
|
||||
public void testSanitizeZipFilename_NullOrEmpty() {
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(null));
|
||||
assertEquals("", FileToPdf.sanitizeZipFilename(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sanitizeZipFilename to ensure it removes path traversal sequences. This includes
|
||||
* removing both forward and backward slash sequences.
|
||||
*/
|
||||
@Test
|
||||
public void testSanitizeZipFilename_RemovesTraversalSequences() {
|
||||
String input = "../some/../path/..\\to\\file.txt";
|
||||
String expected = "some/path/to/file.txt";
|
||||
|
||||
// Expect that the method replaces backslashes with forward slashes
|
||||
// and removes path traversal sequences
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to ensure that it removes leading drive letters and slashes. */
|
||||
@Test
|
||||
public void testSanitizeZipFilename_RemovesLeadingDriveAndSlashes() {
|
||||
String input = "C:\\folder\\file.txt";
|
||||
String expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
|
||||
input = "/folder/file.txt";
|
||||
expected = "folder/file.txt";
|
||||
assertEquals(expected, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
|
||||
/** Test sanitizeZipFilename to verify that safe filenames remain unchanged. */
|
||||
@Test
|
||||
public void testSanitizeZipFilename_NoChangeForSafeNames() {
|
||||
String input = "folder/subfolder/file.txt";
|
||||
assertEquals(input, FileToPdf.sanitizeZipFilename(input));
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ImageProcessingUtilsTest {
|
||||
|
||||
private static void fillImageWithColor(BufferedImage image) {
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
image.setRGB(x, y, Color.RED.getRGB());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToGreyscale() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "greyscale");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_GRAY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is correctly converted to greyscale
|
||||
Color grey = new Color(convertedImage.getRGB(0, 0));
|
||||
assertEquals(grey.getRed(), grey.getGreen());
|
||||
assertEquals(grey.getGreen(), grey.getBlue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToBlackWhite() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "blackwhite");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(BufferedImage.TYPE_BYTE_BINARY, convertedImage.getType());
|
||||
assertEquals(sourceImage.getWidth(), convertedImage.getWidth());
|
||||
assertEquals(sourceImage.getHeight(), convertedImage.getHeight());
|
||||
|
||||
// Check if a pixel is converted correctly (binary image will be either black or white)
|
||||
int rgb = convertedImage.getRGB(0, 0);
|
||||
assertTrue(rgb == Color.BLACK.getRGB() || rgb == Color.WHITE.getRGB());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToFullColor() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "fullcolor");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeInvalid() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "invalidtype");
|
||||
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.ColorModel;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
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.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
public class PdfUtilsTest {
|
||||
|
||||
@Test
|
||||
void testTextToPageSize() {
|
||||
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
|
||||
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
|
||||
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
|
||||
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
|
||||
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
|
||||
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
|
||||
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
|
||||
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
|
||||
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAllImages() throws Exception {
|
||||
// Root resources
|
||||
PDResources root = mock(PDResources.class);
|
||||
|
||||
COSName im1 = COSName.getPDFName("Im1");
|
||||
COSName form1 = COSName.getPDFName("Form1");
|
||||
COSName other1 = COSName.getPDFName("Other1");
|
||||
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
|
||||
|
||||
// Direct image at root
|
||||
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
|
||||
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
when(imgXObj1.getImage()).thenReturn(img1);
|
||||
when(root.getXObject(im1)).thenReturn(imgXObj1);
|
||||
|
||||
// "Other" XObject that should be ignored
|
||||
PDXObject otherXObj = mock(PDXObject.class);
|
||||
when(root.getXObject(other1)).thenReturn(otherXObj);
|
||||
|
||||
// Form XObject with its own resources
|
||||
PDFormXObject formXObj = mock(PDFormXObject.class);
|
||||
PDResources formRes = mock(PDResources.class);
|
||||
when(formXObj.getResources()).thenReturn(formRes);
|
||||
when(root.getXObject(form1)).thenReturn(formXObj);
|
||||
|
||||
// Inside the form: one image and a nested form
|
||||
COSName im2 = COSName.getPDFName("Im2");
|
||||
COSName nestedForm = COSName.getPDFName("NestedForm");
|
||||
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
|
||||
|
||||
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
|
||||
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj2.getImage()).thenReturn(img2);
|
||||
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
|
||||
|
||||
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
|
||||
PDResources nestedRes = mock(PDResources.class);
|
||||
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
|
||||
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
|
||||
|
||||
// Deep nest: another image
|
||||
COSName im3 = COSName.getPDFName("Im3");
|
||||
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
|
||||
|
||||
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
|
||||
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj3.getImage()).thenReturn(img3);
|
||||
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
|
||||
|
||||
// Act
|
||||
List<RenderedImage> result = PdfUtils.getAllImages(root);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
|
||||
assertTrue(
|
||||
result.containsAll(List.of(img1, img2, img3)),
|
||||
"All expected images must be present.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPageCountComparators() throws Exception {
|
||||
PDDocument doc1 = new PDDocument();
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
doc1.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc1, 2, "greater"));
|
||||
|
||||
PDDocument doc2 = new PDDocument();
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
doc2.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc2, 3, "equal"));
|
||||
|
||||
PDDocument doc3 = new PDDocument();
|
||||
doc3.addPage(new PDPage());
|
||||
doc3.addPage(new PDPage());
|
||||
assertTrue(PdfUtils.pageCount(doc3, 5, "less"));
|
||||
|
||||
PDDocument doc4 = new PDDocument();
|
||||
doc4.addPage(new PDPage());
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.pageCount(doc4, 1, "bad"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPageSize() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
PDRectangle rect = page.getMediaBox();
|
||||
String expected = rect.getWidth() + "x" + rect.getHeight();
|
||||
assertTrue(PdfUtils.pageSize(doc, expected));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverlayImage() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
|
||||
doc.save(pdfOut);
|
||||
doc.close();
|
||||
|
||||
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 10, 10);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", imgOut);
|
||||
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
|
||||
byte[] result =
|
||||
PdfUtils.overlayImage(
|
||||
factory, pdfOut.toByteArray(), imgOut.toByteArray(), 0, 0, false);
|
||||
try (PDDocument resultDoc = factory.load(result)) {
|
||||
assertEquals(1, resultDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Additional tests (added without modifying existing ones)
|
||||
// ===============================================================
|
||||
|
||||
/* Helper: create a colored test image */
|
||||
private static BufferedImage createImage(int w, int h, Color color) {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Helper: create a factory like in existing tests */
|
||||
private static CustomPDFDocumentFactory factory() {
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
return new CustomPDFDocumentFactory(meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
|
||||
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("Hello PDF");
|
||||
cs.endText();
|
||||
}
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p2);
|
||||
|
||||
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
|
||||
assertNotNull(out);
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
|
||||
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
|
||||
BufferedImage img = createImage(320, 200, Color.RED);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
// Assume: loadImageWithExifOrientation/convertColorType exist – static mock
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file},
|
||||
"maintainAspectRatio",
|
||||
true,
|
||||
"RGB",
|
||||
factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
|
||||
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
|
||||
BufferedImage img = createImage(640, 360, Color.BLUE);
|
||||
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", jpgOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
|
||||
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(300, 500, Color.GREEN);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(300, (int) box.getWidth());
|
||||
assertEquals(500, (int) box.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
|
||||
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertTrue(
|
||||
box.getWidth() > box.getHeight(),
|
||||
"A4 should be landscape when auto-rotate + landscape");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fillPage runs without errors")
|
||||
void addImageToDocument_fillPage_executes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(200, 200, Color.MAGENTA);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("overlayImage: everyPage=true overlays all pages")
|
||||
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
|
||||
CustomPDFDocumentFactory factory = factory();
|
||||
|
||||
// Create PDF with 2 pages
|
||||
byte[] basePdf;
|
||||
try (PDDocument doc = factory.createNewDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
basePdf = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Create image bytes
|
||||
BufferedImage img = createImage(50, 50, Color.BLACK);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
|
||||
|
||||
try (PDDocument out = factory.load(result)) {
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper function: document with text on page1/page2 */
|
||||
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
|
||||
PDPage page1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p1);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
PDPage page2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page2);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p2);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
|
||||
void containsTextInFile_allPages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: single page '2' finds text")
|
||||
void containsTextInFile_singlePage_two_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
|
||||
void containsTextInFile_range_oneToOne_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("findme", "other")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
|
||||
void containsTextInFile_list_pages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("foo", "bar")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: text not present -> false")
|
||||
void containsTextInFile_textNotPresent_false() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
|
||||
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pageSize: different size returns false")
|
||||
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
assertFalse(PdfUtils.pageSize(doc, "600x842"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== New: convertFromPdf – coverage =====================
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
|
||||
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] imageBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
|
||||
|
||||
// Should be readable as a single combined PNG image
|
||||
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
|
||||
assertNotNull(img, "PNG should be readable");
|
||||
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
|
||||
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Act: singleImage=false -> ZIP with separate images
|
||||
byte[] zipBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
|
||||
|
||||
// Assert: open ZIP, read first entry as PNG
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertNotNull(entry, "ZIP should contain at least one entry");
|
||||
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
zis.transferTo(imgOut);
|
||||
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
|
||||
|
||||
assertNotNull(first, "First PNG entry should be readable");
|
||||
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
|
||||
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
|
||||
// Arrange: PDF with 2 pages and text
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
|
||||
}
|
||||
|
||||
// For further checks, create new doc with identical content
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
|
||||
}
|
||||
|
||||
// Third doc for 'all'
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("gamma");
|
||||
cs.endText();
|
||||
}
|
||||
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
|
||||
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("needle");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
|
||||
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImages: detects images on selected pages and 'all'")
|
||||
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
|
||||
// Case 1: Page 1 without image (but resources set) -> false
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
// Image only on page 2
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.GREEN);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
|
||||
}
|
||||
|
||||
// Case 2: Page 2 with image -> true
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.BLUE);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
|
||||
}
|
||||
|
||||
// Case 3: 'all' detects image
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
p.setResources(new PDResources());
|
||||
doc.addPage(p);
|
||||
|
||||
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 20, 730, 10, 10);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
|
||||
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 12, 12);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 40, 720, 12, 12);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(p1));
|
||||
assertTrue(!PdfUtils.hasImagesOnPage(p2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
|
||||
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
|
||||
// small 1-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] jpgBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
|
||||
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
|
||||
assertNotNull(img, "JPG should be readable");
|
||||
|
||||
ColorModel cm = img.getColorModel();
|
||||
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
|
||||
|
||||
// JPG background should be white (approximate check)
|
||||
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
|
||||
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ProcessExecutorTest {
|
||||
|
||||
private ProcessExecutor processExecutor;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// Initialize the ProcessExecutor instance
|
||||
processExecutor = ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling() throws IOException, InterruptedException {
|
||||
// Mock the command to execute
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("java");
|
||||
command.add("-version");
|
||||
|
||||
// Execute the command
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
processExecutor.runCommandWithOutputHandling(command);
|
||||
|
||||
// Check the exit code and output messages
|
||||
assertEquals(0, result.getRc());
|
||||
assertNotNull(result.getMessages()); // Check if messages are not null
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_Error() {
|
||||
// Test with a command that will fail to execute (non-existent command)
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("nonexistent-command-that-does-not-exist");
|
||||
|
||||
// Execute the command and expect an IOException (command not found)
|
||||
assertThrows(
|
||||
IOException.class, () -> processExecutor.runCommandWithOutputHandling(command));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_PathTraversal() {
|
||||
// Test that path traversal is blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("../../../etc/passwd");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("path traversal"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRunCommandWithOutputHandling_NullByte() {
|
||||
// Test that null bytes are blocked
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("test\0command");
|
||||
|
||||
// Execute the command and expect an IllegalArgumentException
|
||||
IllegalArgumentException thrown =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message
|
||||
String errorMessage = thrown.getMessage();
|
||||
assertTrue(
|
||||
errorMessage.contains("invalid characters"),
|
||||
"Unexpected error message: " + errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PropertyConfigsTest {
|
||||
|
||||
@Test
|
||||
public void testGetBooleanValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
boolean defaultValue = false;
|
||||
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "true");
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetStringValue_WithKeys() {
|
||||
// Define keys and default value
|
||||
List<String> keys = Arrays.asList("test.key1", "test.key2", "test.key3");
|
||||
String defaultValue = "default";
|
||||
|
||||
// Set property for one of the keys
|
||||
System.setProperty("test.key2", "value");
|
||||
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals("value", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetBooleanValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
boolean defaultValue = true;
|
||||
|
||||
// Call the method under test
|
||||
boolean result = PropertyConfigs.getBooleanValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetStringValue_WithKey() {
|
||||
// Define key and default value
|
||||
String key = "test.key";
|
||||
String defaultValue = "default";
|
||||
|
||||
// Call the method under test
|
||||
String result = PropertyConfigs.getStringValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals("default", result);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
public class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsStaticResource() {
|
||||
// Test static resources without context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css"), "CSS files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/js/script.js"), "JS files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png"),
|
||||
"Image files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/public/index.html"),
|
||||
"Public files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
|
||||
"PDF.js files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/api/v1/info/status"),
|
||||
"API status should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/some-path/icon.svg"),
|
||||
"SVG files should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/login"), "Login page should be static");
|
||||
assertTrue(RequestUriUtils.isStaticResource("/error"), "Error page should be static");
|
||||
|
||||
// Test non-static resources
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/users"),
|
||||
"API users should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/orders"),
|
||||
"API orders should not be static");
|
||||
assertFalse(RequestUriUtils.isStaticResource("/"), "Root path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/register"),
|
||||
"Register page should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/v1/products"),
|
||||
"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";
|
||||
|
||||
// Test static resources with context path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/css/styles.css"),
|
||||
"CSS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/js/script.js"),
|
||||
"JS with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/images/logo.png"),
|
||||
"Images with context path should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/login"),
|
||||
"Login with context path should be static");
|
||||
|
||||
// Test non-static resources with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, contextPath + "/api/v1/users"),
|
||||
"API users with context path should not be static");
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource(contextPath, "/"),
|
||||
"Root path with context path should not be static");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"robots.txt",
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
"/assets/brand.ico",
|
||||
"/any/path/with/image.svg",
|
||||
"/deep/nested/folder/icon.png",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testIsStaticResourceWithFileExtensions(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(path),
|
||||
"Files with specific extensions should be static regardless of path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTrackableResource() {
|
||||
// Test non-trackable resources (returns false)
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/v1/api-docs"),
|
||||
"API docs should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("robots.txt"),
|
||||
"robots.txt should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/images/logo.png"),
|
||||
"Images should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/styles.css"),
|
||||
"CSS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js.map"),
|
||||
"Map files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/icon.svg"),
|
||||
"SVG files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/popularity.txt"),
|
||||
"Popularity file should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/swagger/index.html"),
|
||||
"Swagger files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/info/status"),
|
||||
"API info should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/site.webmanifest"),
|
||||
"Webmanifest should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts/font.woff"),
|
||||
"Fonts should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfjs/viewer.js"),
|
||||
"PDF.js files should not be trackable");
|
||||
|
||||
// Test trackable resources (returns true)
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/login"), "Login page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/register"),
|
||||
"Register page should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/api/v1/users"),
|
||||
"API users should be trackable");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("/"), "Root path should be trackable");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource("/some-other-path"),
|
||||
"Other paths should be trackable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTrackableResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
|
||||
// Test with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/js/script.js"),
|
||||
"JS files should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/login"),
|
||||
"Login page should be trackable with context path");
|
||||
|
||||
// Additional tests with context path
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/fonts/custom.woff"),
|
||||
"Font files should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/images/header.png"),
|
||||
"Images should not be trackable with context path");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/swagger/ui.html"),
|
||||
"Swagger UI should not be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/account/profile"),
|
||||
"Account page should be trackable with context path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(contextPath, "/pdf/view"),
|
||||
"PDF view page should be trackable with context path");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/js/util.js",
|
||||
"/v1/api-docs/swagger.json",
|
||||
"/robots.txt",
|
||||
"/images/header/logo.png",
|
||||
"/styles/theme.css",
|
||||
"/build/app.js.map",
|
||||
"/assets/icon.svg",
|
||||
"/data/popularity.txt",
|
||||
"/bundle.js",
|
||||
"/api/swagger-ui.html",
|
||||
"/api/v1/info/health",
|
||||
"/site.webmanifest",
|
||||
"/fonts/roboto.woff",
|
||||
"/pdfjs/viewer.js",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testNonTrackableResources(String path) {
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"Resources matching patterns should not be trackable: " + path);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"/",
|
||||
"/home",
|
||||
"/login",
|
||||
"/register",
|
||||
"/pdf/merge",
|
||||
"/pdf/split",
|
||||
"/api/v1/users/1",
|
||||
"/api/v1/documents/process",
|
||||
"/settings",
|
||||
"/account/profile",
|
||||
"/dashboard",
|
||||
"/help",
|
||||
"/about"
|
||||
})
|
||||
void testTrackableResources(String path) {
|
||||
assertTrue(
|
||||
RequestUriUtils.isTrackableResource(path),
|
||||
"App routes should be trackable: " + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEdgeCases() {
|
||||
// Test with empty strings
|
||||
assertFalse(RequestUriUtils.isStaticResource("", ""), "Empty path should not be static");
|
||||
assertTrue(RequestUriUtils.isTrackableResource("", ""), "Empty path should be trackable");
|
||||
|
||||
// Test with null-like behavior (would actually throw NPE in real code)
|
||||
// These are not actual null tests but shows handling of odd cases
|
||||
assertFalse(RequestUriUtils.isStaticResource("null"), "String 'null' should not be static");
|
||||
|
||||
// Test String "null" as a path
|
||||
boolean isTrackable = RequestUriUtils.isTrackableResource("null");
|
||||
assertTrue(isTrackable, "String 'null' should be trackable");
|
||||
|
||||
// Mixed case extensions test - note that Java's endsWith() is case-sensitive
|
||||
// We'll check actual behavior and document it rather than asserting
|
||||
|
||||
// Always test the lowercase versions which should definitely work
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/logo.png"), "PNG (lowercase) should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/icon.svg"), "SVG (lowercase) should be static");
|
||||
|
||||
// Path with query parameters
|
||||
assertFalse(
|
||||
RequestUriUtils.isStaticResource("/api/users?page=1"),
|
||||
"Path with query params should respect base path");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/logo.png?v=123"),
|
||||
"Static resource with query params should still be static");
|
||||
|
||||
// Paths with fragments
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/styles.css#section1"),
|
||||
"CSS with fragment should be static");
|
||||
|
||||
// Multiple dots in filename
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/jquery.min.js"),
|
||||
"JS with multiple dots should be static");
|
||||
|
||||
// Special characters in path
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/images/user's-photo.png"),
|
||||
"Path with special chars should be handled correctly");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testComplexPaths() {
|
||||
// Test complex static resource paths
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/css/theme/dark/styles.css"),
|
||||
"Nested CSS should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/fonts/open-sans/bold/font.woff"),
|
||||
"Nested font should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/js/vendor/jquery/3.5.1/jquery.min.js"),
|
||||
"Versioned JS should be static");
|
||||
|
||||
// Test complex paths with context
|
||||
String contextPath = "/app";
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource(
|
||||
contextPath, contextPath + "/css/theme/dark/styles.css"),
|
||||
"Nested CSS with context should be static");
|
||||
|
||||
// Test boundary cases for isTrackableResource
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/js-framework/components"),
|
||||
"Path starting with js- should not be treated as JS resource");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/fonts-selection"),
|
||||
"Path starting with fonts- should not be treated as font resource");
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UrlUtilsTest {
|
||||
|
||||
@Mock private HttpServletRequest request;
|
||||
|
||||
@Test
|
||||
void testGetOrigin() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/myapp");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080/myapp", origin, "Origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithHttps() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.com");
|
||||
when(request.getServerPort()).thenReturn(443);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.com:443",
|
||||
origin,
|
||||
"HTTPS origin URL should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithEmptyContextPath() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://localhost:8080",
|
||||
origin,
|
||||
"Origin URL with empty context path should be correct");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithSpecialCharacters() {
|
||||
// Arrange - Test with server name containing special characters
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("internal-server.example-domain.com");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/app-v1.2");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://internal-server.example-domain.com:8443/app-v1.2",
|
||||
origin,
|
||||
"Origin URL with special characters should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithIPv4Address() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("192.168.1.100");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
when(request.getContextPath()).thenReturn("/app");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"http://192.168.1.100:8080/app",
|
||||
origin,
|
||||
"Origin URL with IPv4 address should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetOriginWithNonStandardPort() {
|
||||
// Arrange
|
||||
when(request.getScheme()).thenReturn("https");
|
||||
when(request.getServerName()).thenReturn("example.org");
|
||||
when(request.getServerPort()).thenReturn(8443);
|
||||
when(request.getContextPath()).thenReturn("/api");
|
||||
|
||||
// Act
|
||||
String origin = UrlUtils.getOrigin(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
"https://example.org:8443/api",
|
||||
origin,
|
||||
"Origin URL with non-standard port should be correctly formatted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPortAvailable() {
|
||||
// We'll use a real server socket for this test
|
||||
ServerSocket socket = null;
|
||||
int port = 12345; // Choose a port unlikely to be in use
|
||||
|
||||
try {
|
||||
// First check the port is available
|
||||
boolean initialAvailability = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Then occupy the port
|
||||
socket = new ServerSocket(port);
|
||||
|
||||
// Now check the port is no longer available
|
||||
boolean afterSocketCreation = UrlUtils.isPortAvailable(port);
|
||||
|
||||
// Assert
|
||||
assertTrue(initialAvailability, "Port should be available initially");
|
||||
assertFalse(
|
||||
afterSocketCreation, "Port should not be available after socket is created");
|
||||
|
||||
} catch (IOException e) {
|
||||
// This might happen if the port is already in use by another process
|
||||
// We'll just verify the behavior of isPortAvailable matches what we expect
|
||||
assertFalse(
|
||||
UrlUtils.isPortAvailable(port),
|
||||
"Port should not be available if exception is thrown");
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePort() {
|
||||
// We'll create a socket on a port and ensure findAvailablePort returns a different port
|
||||
ServerSocket socket = null;
|
||||
int startPort = 12346; // Choose a port unlikely to be in use
|
||||
|
||||
try {
|
||||
// Occupy the start port
|
||||
socket = new ServerSocket(startPort);
|
||||
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is not the occupied one
|
||||
assertNotEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should not return an occupied port");
|
||||
|
||||
// Verify the returned port is actually available
|
||||
int portNumber = Integer.parseInt(availablePort);
|
||||
|
||||
// Close our test socket before checking the found port
|
||||
socket.close();
|
||||
socket = null;
|
||||
|
||||
// The port should now be available
|
||||
assertTrue(
|
||||
UrlUtils.isPortAvailable(portNumber),
|
||||
"The port returned by findAvailablePort should be available");
|
||||
|
||||
} catch (IOException e) {
|
||||
// If we can't create the socket, skip this assertion
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePortWithAvailableStartPort() {
|
||||
// Find an available port without occupying any
|
||||
int startPort = 23456; // Choose a different unlikely-to-be-used port
|
||||
|
||||
// Make sure the port is available first
|
||||
if (UrlUtils.isPortAvailable(startPort)) {
|
||||
// Find an available port
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
|
||||
// Assert the returned port is the start port since it's available
|
||||
assertEquals(
|
||||
String.valueOf(startPort),
|
||||
availablePort,
|
||||
"findAvailablePort should return the start port if it's available");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAvailablePortWithSequentialUsedPorts() {
|
||||
// This test checks that findAvailablePort correctly skips multiple occupied ports
|
||||
ServerSocket socket1 = null;
|
||||
ServerSocket socket2 = null;
|
||||
int startPort = 34567; // Another unlikely-to-be-used port
|
||||
|
||||
try {
|
||||
// First verify the port is available
|
||||
if (!UrlUtils.isPortAvailable(startPort)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Occupy two sequential ports
|
||||
socket1 = new ServerSocket(startPort);
|
||||
socket2 = new ServerSocket(startPort + 1);
|
||||
|
||||
// Find an available port starting from our occupied range
|
||||
String availablePort = UrlUtils.findAvailablePort(startPort);
|
||||
int foundPort = Integer.parseInt(availablePort);
|
||||
|
||||
// Should have skipped the two occupied ports
|
||||
assertTrue(
|
||||
foundPort >= startPort + 2,
|
||||
"findAvailablePort should skip sequential occupied ports");
|
||||
|
||||
// Verify the found port is actually available
|
||||
try (ServerSocket testSocket = new ServerSocket(foundPort)) {
|
||||
assertTrue(testSocket.isBound(), "The found port should be bindable");
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// Skip test if we encounter IO exceptions
|
||||
} finally {
|
||||
// Clean up resources
|
||||
try {
|
||||
if (socket1 != null && !socket1.isClosed()) socket1.close();
|
||||
if (socket2 != null && !socket2.isClosed()) socket2.close();
|
||||
} catch (IOException e) {
|
||||
// Ignore cleanup exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPortAvailableWithPrivilegedPorts() {
|
||||
// Skip tests for privileged ports as they typically require root access
|
||||
// and results are environment-dependent
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testBoasToWebResponse() {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
baos.write("Sample PDF content".getBytes());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.baosToWebResponse(baos, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
// assertEquals("attachment; filename=\"sample.pdf\"",
|
||||
// headers.getContentDisposition().toString());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultiPartFileToWebResponse() {
|
||||
try {
|
||||
byte[] fileContent = "Sample file content".getBytes();
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file", "sample.txt", MediaType.TEXT_PLAIN_VALUE, fileContent);
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.multiPartFileToWebResponse(file);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBytesToWebResponse() {
|
||||
try {
|
||||
byte[] bytes = "Sample bytes".getBytes();
|
||||
String docName = "sample.txt";
|
||||
MediaType mediaType = MediaType.TEXT_PLAIN;
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.bytesToWebResponse(bytes, docName, mediaType);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.TEXT_PLAIN, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPdfDocToWebResponse() {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.pdfDocToWebResponse(document, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
assertNotNull(responseEntity.getBody());
|
||||
|
||||
HttpHeaders headers = responseEntity.getHeaders();
|
||||
assertNotNull(headers);
|
||||
assertEquals(MediaType.APPLICATION_PDF, headers.getContentType());
|
||||
assertNotNull(headers.getContentDisposition());
|
||||
|
||||
} catch (IOException e) {
|
||||
fail("Exception thrown: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
|
||||
implementation 'org.eclipse.jetty:jetty-alpn-java-server'
|
||||
implementation ('org.telegram:telegrambots:6.9.7.1') {
|
||||
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
|
||||
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.service.PdfImageRemovalService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
* Controller class for handling PDF image removal requests. Provides an endpoint to remove images
|
||||
* from a PDF file to reduce its size.
|
||||
*/
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
public class PdfImageRemovalController {
|
||||
|
||||
// Service for removing images from PDFs
|
||||
private final PdfImageRemovalService pdfImageRemovalService;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
/**
|
||||
* Endpoint to remove images from a PDF file.
|
||||
*
|
||||
* <p>This method processes the uploaded PDF file, removes all images, and returns the modified
|
||||
* PDF file with a new name indicating that images were removed.
|
||||
*
|
||||
* @param file The PDF file with images to be removed.
|
||||
* @return ResponseEntity containing the modified PDF file as byte array with appropriate
|
||||
* content type and filename.
|
||||
* @throws IOException If an error occurs while processing the PDF file.
|
||||
*/
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/remove-image-pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Remove images from file to reduce the file size.",
|
||||
description =
|
||||
"This endpoint remove images from file to reduce the file size.Input:PDF"
|
||||
+ " Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
try (PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document)) {
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-23
@@ -150,28 +150,6 @@ public class RearrangePagesPDFController {
|
||||
return newPageOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rearrange pages in a PDF file by merging odd and even pages. The first half of the pages will
|
||||
* be the odd pages, and the second half will be the even pages as input. <br>
|
||||
* This method is visible for testing purposes only.
|
||||
*
|
||||
* @param totalPages Total number of pages in the PDF file.
|
||||
* @return List of page numbers in the new order. The first page is 0.
|
||||
*/
|
||||
List<Integer> oddEvenMerge(int totalPages) {
|
||||
List<Integer> newPageOrderZeroBased = new ArrayList<>();
|
||||
int numberOfOddPages = (totalPages + 1) / 2;
|
||||
|
||||
for (int oneBasedIndex = 1; oneBasedIndex < (numberOfOddPages + 1); oneBasedIndex++) {
|
||||
newPageOrderZeroBased.add((oneBasedIndex - 1));
|
||||
if (numberOfOddPages + oneBasedIndex <= totalPages) {
|
||||
newPageOrderZeroBased.add((numberOfOddPages + oneBasedIndex - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return newPageOrderZeroBased;
|
||||
}
|
||||
|
||||
private List<Integer> duplicate(int totalPages, String pageOrder) {
|
||||
List<Integer> newPageOrder = new ArrayList<>();
|
||||
int duplicateCount;
|
||||
@@ -220,7 +198,6 @@ public class RearrangePagesPDFController {
|
||||
case BOOKLET_SORT -> bookletSort(totalPages);
|
||||
case SIDE_STITCH_BOOKLET_SORT -> sideStitchBooklet(totalPages);
|
||||
case ODD_EVEN_SPLIT -> oddEvenSplit(totalPages);
|
||||
case ODD_EVEN_MERGE -> oddEvenMerge(totalPages);
|
||||
case REMOVE_FIRST -> removeFirst(totalPages);
|
||||
case REMOVE_LAST -> removeLast(totalPages);
|
||||
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
|
||||
|
||||
+2
-1
@@ -81,7 +81,8 @@ public class ConvertEmlToPDF {
|
||||
|
||||
if (request.isDownloadHtml()) {
|
||||
try {
|
||||
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
|
||||
String htmlContent =
|
||||
EmlToPdf.convertEmlToHtml(fileBytes, request, customHtmlSanitizer);
|
||||
log.info("Successfully converted email to HTML: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
htmlContent.getBytes(StandardCharsets.UTF_8),
|
||||
|
||||
+10
@@ -230,6 +230,16 @@ public class ConfigController {
|
||||
}
|
||||
configData.put("isNewUser", isNewUser);
|
||||
|
||||
String username = null;
|
||||
if (userService != null) {
|
||||
try {
|
||||
username = userService.getCurrentUsername();
|
||||
} catch (Exception e) {
|
||||
// Not authenticated or security disabled
|
||||
}
|
||||
}
|
||||
configData.put("username", username);
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
|
||||
+63
-176
@@ -1,20 +1,14 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Image;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -43,7 +37,6 @@ import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ImageProcessingUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -65,203 +58,97 @@ public class ExtractImagesController {
|
||||
+ " file. Users can specify the output image format. Input:PDF"
|
||||
+ " Output:IMAGE/ZIP Type:SIMO")
|
||||
public ResponseEntity<StreamingResponseBody> extractImages(
|
||||
@ModelAttribute PDFExtractImagesRequest request)
|
||||
throws IOException, InterruptedException, ExecutionException {
|
||||
@ModelAttribute PDFExtractImagesRequest request) throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String format = request.getFormat();
|
||||
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
|
||||
String imageFormat = request.getFormat();
|
||||
|
||||
String filename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
Set<byte[]> processedImages = new HashSet<>();
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
Set<Integer> processedImageHashes = new HashSet<>();
|
||||
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zos =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
|
||||
PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
TempFile zipFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile.getPath()));
|
||||
PDDocument pdfDoc = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Set compression level
|
||||
zos.setLevel(Deflater.BEST_COMPRESSION);
|
||||
zipStream.setLevel(Deflater.BEST_COMPRESSION);
|
||||
|
||||
// Determine if multithreading should be used based on PDF size or number of pages
|
||||
boolean useMultithreading = shouldUseMultithreading(file, document);
|
||||
|
||||
if (useMultithreading) {
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
Set<Future<Void>> futures = new HashSet<>();
|
||||
|
||||
try {
|
||||
int pageCount = document.getPages().getCount();
|
||||
log.debug("Document reports {} pages", pageCount);
|
||||
|
||||
int consecutiveFailures = 0;
|
||||
|
||||
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
|
||||
try {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
consecutiveFailures = 0; // Reset on success
|
||||
final int currentPageNum =
|
||||
pgNum + 1; // Convert to 1-based page numbering
|
||||
Future<Void> future =
|
||||
executor.submit(
|
||||
() -> {
|
||||
try {
|
||||
// Call the image extraction method for each
|
||||
// page
|
||||
extractImagesFromPage(
|
||||
page,
|
||||
format,
|
||||
filename,
|
||||
currentPageNum,
|
||||
processedImages,
|
||||
zos,
|
||||
allowDuplicates);
|
||||
} catch (Exception e) {
|
||||
// Log the error and continue processing other
|
||||
// pages
|
||||
ExceptionUtils.logException(
|
||||
"image extraction from page "
|
||||
+ currentPageNum,
|
||||
e);
|
||||
}
|
||||
|
||||
return null; // Callable requires a return type
|
||||
});
|
||||
|
||||
// Add the Future object to the list to track completion
|
||||
futures.add(future);
|
||||
} catch (Exception e) {
|
||||
consecutiveFailures++;
|
||||
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
|
||||
|
||||
if (consecutiveFailures >= 3) {
|
||||
log.warn("Stopping page iteration after 3 consecutive failures");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("page count determination", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
for (Future<Void> future : futures) {
|
||||
future.get();
|
||||
}
|
||||
|
||||
// Close executor service
|
||||
executor.shutdown();
|
||||
} else {
|
||||
// Single-threaded extraction
|
||||
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
extractImagesFromPage(
|
||||
page,
|
||||
format,
|
||||
filename,
|
||||
pgNum + 1,
|
||||
processedImages,
|
||||
zos,
|
||||
allowDuplicates);
|
||||
}
|
||||
int totalPages = pdfDoc.getNumberOfPages();
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage currentPage = pdfDoc.getPage(pageIndex);
|
||||
extractAndAddImagesToZip(
|
||||
currentPage, imageFormat, baseFilename, pageIndex + 1,
|
||||
processedImageHashes, zipStream);
|
||||
}
|
||||
// document and zos closed by try-with-resources
|
||||
} catch (Exception e) {
|
||||
zipTempFile.close();
|
||||
zipFile.close();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return WebResponseUtils.zipFileToWebResponse(
|
||||
zipTempFile, filename + "_extracted-images.zip");
|
||||
zipFile, baseFilename + "_extracted-images.zip");
|
||||
}
|
||||
|
||||
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
|
||||
// Criteria: Use multithreading if file size > 10MB or number of pages > 20
|
||||
long fileSizeInMB = file.getSize() / (1024 * 1024);
|
||||
int numberOfPages = document.getPages().getCount();
|
||||
return fileSizeInMB > 10 || numberOfPages > 20;
|
||||
}
|
||||
|
||||
private void extractImagesFromPage(
|
||||
private void extractAndAddImagesToZip(
|
||||
PDPage page,
|
||||
String format,
|
||||
String filename,
|
||||
int pageNum,
|
||||
Set<byte[]> processedImages,
|
||||
ZipOutputStream zos,
|
||||
boolean allowDuplicates)
|
||||
String imageFormat,
|
||||
String baseFilename,
|
||||
int pageNumber,
|
||||
Set<Integer> seenImageHashes,
|
||||
ZipOutputStream zipOutput)
|
||||
throws IOException {
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("MD5 algorithm not available for extractImages hash.", e);
|
||||
return;
|
||||
}
|
||||
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
|
||||
return;
|
||||
}
|
||||
int count = 1;
|
||||
for (COSName name : page.getResources().getXObjectNames()) {
|
||||
|
||||
int imageCount = 1;
|
||||
for (COSName resourceName : page.getResources().getXObjectNames()) {
|
||||
if (!page.getResources().isImageXObject(resourceName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (page.getResources().isImageXObject(name)) {
|
||||
PDImageXObject image = (PDImageXObject) page.getResources().getXObject(name);
|
||||
if (!allowDuplicates) {
|
||||
byte[] data = ImageProcessingUtils.getImageData(image.getImage());
|
||||
byte[] imageHash = md.digest(data);
|
||||
synchronized (processedImages) {
|
||||
if (processedImages.stream()
|
||||
.anyMatch(hash -> Arrays.equals(hash, imageHash))) {
|
||||
continue; // Skip already processed images
|
||||
}
|
||||
processedImages.add(imageHash);
|
||||
}
|
||||
}
|
||||
PDImageXObject imageObject =
|
||||
(PDImageXObject) page.getResources().getXObject(resourceName);
|
||||
int imageHashCode = imageObject.hashCode();
|
||||
|
||||
RenderedImage renderedImage = image.getImage();
|
||||
|
||||
// Convert to standard RGB colorspace if needed
|
||||
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
|
||||
|
||||
// Encode image outside the lock to allow parallel encoding across threads
|
||||
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
|
||||
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
|
||||
ImageIO.write(bufferedImage, format, imageBaos);
|
||||
byte[] imageData = imageBaos.toByteArray();
|
||||
|
||||
// Write encoded bytes to zip under lock (ZipOutputStream requires
|
||||
// serialization)
|
||||
synchronized (zos) {
|
||||
zos.putNextEntry(new ZipEntry(imageName));
|
||||
zos.write(imageData);
|
||||
zos.closeEntry();
|
||||
}
|
||||
if (seenImageHashes.contains(imageHashCode)) {
|
||||
continue;
|
||||
}
|
||||
seenImageHashes.add(imageHashCode);
|
||||
|
||||
RenderedImage sourceImage = imageObject.getImage();
|
||||
BufferedImage convertedImage = convertImageToFormat(sourceImage, imageFormat);
|
||||
|
||||
String imagePath =
|
||||
baseFilename + "_page_" + pageNumber + "_" + imageCount++ + "."
|
||||
+ imageFormat;
|
||||
ByteArrayOutputStream imageBuffer = new ByteArrayOutputStream();
|
||||
ImageIO.write(convertedImage, imageFormat, imageBuffer);
|
||||
|
||||
zipOutput.putNextEntry(new ZipEntry(imagePath));
|
||||
zipOutput.write(imageBuffer.toByteArray());
|
||||
zipOutput.closeEntry();
|
||||
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("image extraction", e);
|
||||
ExceptionUtils.logException("image extraction failed", e);
|
||||
throw ExceptionUtils.handlePdfException(e, "during image extraction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage convertToRGB(RenderedImage renderedImage, String format) {
|
||||
int width = renderedImage.getWidth();
|
||||
int height = renderedImage.getHeight();
|
||||
BufferedImage rgbImage;
|
||||
private BufferedImage convertImageToFormat(RenderedImage source, String format) {
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
|
||||
int imageType = BufferedImage.TYPE_INT_RGB;
|
||||
if ("png".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
} else if ("jpeg".equalsIgnoreCase(format) || "jpg".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
} else if ("gif".equalsIgnoreCase(format)) {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED);
|
||||
} else {
|
||||
rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
imageType = BufferedImage.TYPE_INT_ARGB;
|
||||
}
|
||||
|
||||
Graphics2D g = rgbImage.createGraphics();
|
||||
g.drawImage((Image) renderedImage, 0, 0, null);
|
||||
g.dispose();
|
||||
return rgbImage;
|
||||
BufferedImage result = new BufferedImage(width, height, imageType);
|
||||
Graphics2D graphics = result.createGraphics();
|
||||
graphics.drawImage((Image) source, 0, 0, null);
|
||||
graphics.dispose();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-16
@@ -36,7 +36,6 @@ import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
import stirling.software.common.util.FileMonitor;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -50,7 +49,6 @@ public class PipelineDirectoryProcessor {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
private final PipelineProcessor processor;
|
||||
private final FileMonitor fileMonitor;
|
||||
private final PostHogService postHogService;
|
||||
private final List<String> watchedFoldersDirs;
|
||||
private final String finishedFoldersDir;
|
||||
@@ -63,13 +61,11 @@ public class PipelineDirectoryProcessor {
|
||||
ObjectMapper objectMapper,
|
||||
ApiDocService apiDocService,
|
||||
PipelineProcessor processor,
|
||||
FileMonitor fileMonitor,
|
||||
PostHogService postHogService,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiDocService = apiDocService;
|
||||
this.processor = processor;
|
||||
this.fileMonitor = fileMonitor;
|
||||
this.postHogService = postHogService;
|
||||
this.watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
|
||||
this.finishedFoldersDir = runtimePathConfig.getPipelineFinishedFoldersPath();
|
||||
@@ -274,18 +270,7 @@ public class PipelineDirectoryProcessor {
|
||||
return isAllowed;
|
||||
})
|
||||
.map(Path::toAbsolutePath)
|
||||
.filter(
|
||||
path -> {
|
||||
boolean isReady =
|
||||
fileMonitor.isFileReadyForProcessing(path);
|
||||
if (!isReady) {
|
||||
log.info(
|
||||
"File not ready for processing (locked/created"
|
||||
+ " last 5s): {}",
|
||||
path);
|
||||
}
|
||||
return isReady;
|
||||
})
|
||||
.filter(path -> true)
|
||||
.map(Path::toFile)
|
||||
.toArray(File[]::new);
|
||||
log.info(
|
||||
|
||||
@@ -7,7 +7,6 @@ public enum SortTypes {
|
||||
BOOKLET_SORT,
|
||||
SIDE_STITCH_BOOKLET_SORT,
|
||||
ODD_EVEN_SPLIT,
|
||||
ODD_EVEN_MERGE,
|
||||
REMOVE_FIRST,
|
||||
REMOVE_LAST,
|
||||
REMOVE_FIRST_AND_LAST,
|
||||
|
||||
+6
-6
@@ -9,10 +9,10 @@ import lombok.EqualsAndHashCode;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PDFExtractImagesRequest extends PDFWithImageFormatRequest {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Boolean to enable/disable the saving of duplicate images, true to enable"
|
||||
+ " duplicates",
|
||||
defaultValue = "false")
|
||||
private Boolean allowDuplicates;
|
||||
// @Schema(
|
||||
// description =
|
||||
// "Boolean to enable/disable the saving of duplicate images, true to enable"
|
||||
// + " duplicates",
|
||||
// defaultValue = "false")
|
||||
// private Boolean allowDuplicates;
|
||||
}
|
||||
|
||||
-1
@@ -22,7 +22,6 @@ public class RearrangePagesRequest extends PDFWithPageNums {
|
||||
+ "DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). "
|
||||
+ "BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...).\n"
|
||||
+ "ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages.\n"
|
||||
+ "ODD_EVEN_MERGE: Merges pages and organises them alternately into odd and even pages.\n"
|
||||
+ "REMOVE_FIRST: Removes the first page.\n"
|
||||
+ "REMOVE_LAST: Removes the last page.\n"
|
||||
+ "REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.\n")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.graphics.PDXObject;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Service class responsible for removing image objects from a PDF document. */
|
||||
@Service
|
||||
public class PdfImageRemovalService {
|
||||
|
||||
/**
|
||||
* Removes all image objects from the provided PDF document.
|
||||
*
|
||||
* <p>This method iterates over each page in the document and removes any image XObjects found
|
||||
* in the page's resources.
|
||||
*
|
||||
* @param document The PDF document from which images will be removed.
|
||||
* @return The modified PDF document with images removed.
|
||||
* @throws IOException If an error occurs while processing the PDF document.
|
||||
*/
|
||||
public PDDocument removeImagesFromPdf(PDDocument document) throws IOException {
|
||||
// Iterate over each page in the PDF document
|
||||
for (PDPage page : document.getPages()) {
|
||||
PDResources resources = page.getResources();
|
||||
// Collect the XObject names to remove
|
||||
List<COSName> namesToRemove = new ArrayList<>();
|
||||
|
||||
// Iterate over all XObject names in the page's resources
|
||||
for (COSName name : resources.getXObjectNames()) {
|
||||
// Check if the XObject is an image
|
||||
if (resources.isImageXObject(name)) {
|
||||
// Collect the name for removal
|
||||
namesToRemove.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Now, modify the resources by removing the collected names
|
||||
for (COSName name : namesToRemove) {
|
||||
resources.put(name, (PDXObject) null);
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
#searchBar {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.75rem 3.5rem;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: 3rem;
|
||||
outline-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
#filtersContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
color: var(--md-sys-color-secondary);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
margin: 0.75rem 1rem;
|
||||
border: 0.1rem solid transparent;
|
||||
}
|
||||
|
||||
.favorite-icon {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
#tool-icon {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#tool-text {
|
||||
margin: 0.0rem 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.favorite-icon img {
|
||||
filter: brightness(0) invert(var(--md-theme-filter-color));
|
||||
}
|
||||
|
||||
.favorite-icon:hover .material-symbols-rounded {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.favorite-icon .material-symbols-rounded.fill{
|
||||
color: #f5c000;
|
||||
}
|
||||
|
||||
.jumbotron {
|
||||
padding: 3rem 3rem;
|
||||
/* Reduce vertical padding */
|
||||
}
|
||||
|
||||
.lookatme {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lookatme::after {
|
||||
color: #e33100;
|
||||
text-shadow: 0 0 5px #e33100;
|
||||
/* in the html, the data-lookatme-text attribute must */
|
||||
/* contain the same text as the .lookatme element */
|
||||
content: attr(data-lookatme-text);
|
||||
padding: inherit;
|
||||
position: absolute;
|
||||
inset: 0 0 0 0;
|
||||
z-index: 1;
|
||||
/* 20 steps / 2 seconds = 10fps */
|
||||
-webkit-animation: 2s infinite Pulse steps(20);
|
||||
animation: 2s infinite Pulse steps(20);
|
||||
}
|
||||
|
||||
.newfeature{
|
||||
display: flex;
|
||||
width:fit-content
|
||||
}
|
||||
.recent-features{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: var(--favourite-remove) !important;
|
||||
}
|
||||
.add-icon {
|
||||
color: var(--favourite-add) !important;
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
/* Base Container Styles */
|
||||
.multi-tool-container {
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
label {
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 0rem 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 16px !important;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--theme-color-outline-variant);
|
||||
flex-grow: 5;
|
||||
}
|
||||
|
||||
/* Action Bar Styles */
|
||||
.mt-action-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: none;
|
||||
backdrop-filter: blur(2px);
|
||||
top: 10px;
|
||||
z-index: 11;
|
||||
padding: 1.25rem;
|
||||
border-radius: 2rem;
|
||||
margin: 0px 25px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mt-action-bar > * {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-file-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mt-action-bar svg,
|
||||
.mt-action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mt-action-bar .mt-filename {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Action Button Styles */
|
||||
.mt-action-btn {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2rem;
|
||||
z-index: 12;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 20px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Card and Container Styles */
|
||||
.bg-card {
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
padding: 15px 0;
|
||||
margin-left: 55px;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#pages-container-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: 25px;
|
||||
min-height: 275px;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
#pages-container {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
#pages-container-wrapper::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--scroll-bar-color);
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: var(--scroll-bar-thumb);
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scroll-bar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Page Container Base Styles */
|
||||
.page-container {
|
||||
display: inline-block;
|
||||
list-style-type: none;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
line-height: 50px;
|
||||
margin-top: 15px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Responsive Page Container Sizes */
|
||||
@media only screen and (max-width: 480px) {
|
||||
.page-container {
|
||||
width: calc(100vw - 90px);
|
||||
height: calc(100vw - 90px);
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
#pages-container { gap: 10px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 481px) and (max-width: 768px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 120px - 12px) / 2);
|
||||
height: calc((100vw - 120px - 12px) / 2);
|
||||
max-width: 250px;
|
||||
max-height: 250px;
|
||||
margin: 6px;
|
||||
}
|
||||
#pages-container { gap: 12px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 769px) and (max-width: 1199px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 140px - 45px) / 3);
|
||||
height: calc((100vw - 140px - 45px) / 3);
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
margin: 7px;
|
||||
}
|
||||
#pages-container { gap: 15px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1200px) and (max-width: 1280px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 160px - 60px) / 4);
|
||||
height: calc((100vw - 160px - 60px) / 4);
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
margin: 8px;
|
||||
}
|
||||
#pages-container { gap: 17px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1281px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 180px - 80px) / 5);
|
||||
height: calc((100vw - 180px - 80px) / 5);
|
||||
max-width: 190px;
|
||||
max-height: 190px;
|
||||
margin: 10px;
|
||||
}
|
||||
#pages-container { gap: 20px; }
|
||||
}
|
||||
|
||||
/* Split Page Styles */
|
||||
.page-container.split-before {
|
||||
border-left: 1px dashed var(--md-sys-color-on-surface);
|
||||
padding-left: -1px;
|
||||
}
|
||||
|
||||
.page-container.split-before:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.page-container:first-child .pdf-actions_split-file-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* RTL Language Support */
|
||||
.page-container:last-child:lang(ar),
|
||||
.page-container:last-child:lang(he),
|
||||
.page-container:last-child:lang(fa),
|
||||
.page-container:last-child:lang(ur),
|
||||
.page-container:last-child:lang(ckb),
|
||||
.page-container:last-child:lang(ks),
|
||||
.page-container:last-child:lang(kk),
|
||||
.page-container:last-child:lang(uz),
|
||||
.page-container:last-child:lang(ky),
|
||||
.page-container:last-child:lang(bal),
|
||||
.page-container:last-child:lang(dv),
|
||||
.page-container:last-child:lang(ps),
|
||||
.page-container:last-child:lang(sdg),
|
||||
.page-container:last-child:lang(syr),
|
||||
.page-container:last-child:lang(mzn),
|
||||
.page-container:last-child:lang(tgl),
|
||||
.page-container:last-child:lang(pnb),
|
||||
.page-container:last-child:lang(ug),
|
||||
.page-container:last-child:lang(nqo),
|
||||
.page-container:last-child:lang(bqi) {
|
||||
margin-left: auto !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Page Image Styles */
|
||||
.page-container img {
|
||||
max-width: calc(100% - 8px);
|
||||
max-height: calc(100% - 8px);
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
translate: -50% -50%;
|
||||
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.384);
|
||||
border-radius: 4px;
|
||||
transition: rotate 0.3s;
|
||||
}
|
||||
|
||||
/* Page Number Styles */
|
||||
.page-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
line-height: normal;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: rgba(162, 201, 255, 0.8);
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
z-index: 2;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* Tool Header and Button Styles */
|
||||
.tool-header {
|
||||
margin: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
#select-pages-button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#add-pdf-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Selected Pages Container Styles */
|
||||
.selected-pages-container {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.selected-pages-container h3 {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selected-pages-header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.selected-pages-header h5 {
|
||||
margin: 0 0 8px 0 !important;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.selected-pages-header #csv-input {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
padding: 0 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.selected-pages-header #csv-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--md-sys-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Pages List Styles */
|
||||
.pages-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
min-height: 0;
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
width: 7rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.selected-page-number {
|
||||
width: 4rem;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
cursor: pointer;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
/* Zoom Level Responsive Styles */
|
||||
@media only screen and (min-width: 2000px) {
|
||||
.mt-action-btn {
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 2.5rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 2560px) {
|
||||
.mt-action-btn {
|
||||
gap: 15px;
|
||||
padding: 15px 30px;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 3840px) {
|
||||
.mt-action-btn {
|
||||
gap: 20px;
|
||||
padding: 20px 40px;
|
||||
border-radius: 4rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 7680px) {
|
||||
.mt-action-btn {
|
||||
gap: 40px;
|
||||
padding: 40px 80px;
|
||||
border-radius: 8rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
border-radius: 80px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoom-responsive Sidebar Styles */
|
||||
@media only screen and (max-width: 1280px) {
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mt-action-btn {
|
||||
position: fixed !important;
|
||||
left: 10px !important;
|
||||
top: 80px !important;
|
||||
bottom: 20px !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
border-radius: 16px !important;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: calc(100vh - 100px) !important;
|
||||
overflow-y: auto !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
z-index: 12;
|
||||
width: 70px !important;
|
||||
gap: 8px !important;
|
||||
padding: 12px 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn:has(.btn:nth-last-child(n+7)) {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.mt-action-btn::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 20px !important;
|
||||
line-height: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
|
||||
}
|
||||
|
||||
.multi-tool-container {
|
||||
margin-left: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.mt-action-bar {
|
||||
margin-left: 25px !important;
|
||||
margin-right: 25px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn:not(:has(*:nth-child(6))) {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile and High Zoom Responsive Styles */
|
||||
@media only screen and (max-width: 960px) {
|
||||
.mt-action-btn {
|
||||
left: 8px !important;
|
||||
top: 75px !important;
|
||||
bottom: 15px !important;
|
||||
height: calc(100vh - 90px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.mt-action-btn {
|
||||
left: 5px !important;
|
||||
width: 60px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 10px) !important;
|
||||
bottom: 10px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 20px) !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
#pages-container {
|
||||
margin: 0 auto !important;
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: calc(100vw - 55px) !important;
|
||||
height: calc(100vw - 60px) !important;
|
||||
max-width: 350px !important;
|
||||
max-height: 350px !important;
|
||||
margin: 5px auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mt-action-btn {
|
||||
left: 2px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 5px) !important;
|
||||
bottom: 5px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
|
||||
width: 50px !important;
|
||||
gap: 6px !important;
|
||||
padding: 10px 6px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 10px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 384px) {
|
||||
.mt-action-btn {
|
||||
left: 2px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 5px) !important;
|
||||
bottom: 5px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
|
||||
width: 40px !important;
|
||||
gap: 4px !important;
|
||||
padding: 8px 4px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
border-radius: 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: calc(100vw - 55px) !important;
|
||||
height: calc(100vw - 60px) !important;
|
||||
max-width: 280px !important;
|
||||
max-height: 280px !important;
|
||||
margin: 3px auto !important;
|
||||
}
|
||||
|
||||
.page-container img {
|
||||
max-width: calc(100% - 4px) !important;
|
||||
max-height: calc(100% - 4px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,799 +0,0 @@
|
||||
#navbarSearch {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
transition: all 0.3s;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#navbarSearch.show {
|
||||
height: auto;
|
||||
/*dynamically changes height*/
|
||||
}
|
||||
|
||||
#searchResults .dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
height: 50px;
|
||||
/* Fixed height */
|
||||
overflow: hidden;
|
||||
/* Hide overflow */
|
||||
}
|
||||
|
||||
#searchResults .icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#searchResults .icon-text {
|
||||
display: inline;
|
||||
overflow: hidden;
|
||||
/* Hide overflow */
|
||||
text-overflow: ellipsis;
|
||||
/* Add ellipsis for long text */
|
||||
}
|
||||
|
||||
#search-icon i {
|
||||
font-size: 24px;
|
||||
/* Adjust this to your desired size */
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
#search-icon:hover i {
|
||||
color: #666;
|
||||
/* Adjust this to your hover color */
|
||||
}
|
||||
|
||||
.search-input {
|
||||
transition: border 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #666;
|
||||
/* Adjust this to your focus color */
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
/* Adjust this to your desired shadow */
|
||||
}
|
||||
|
||||
|
||||
.main-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Responsive navbar brand - prevent hamburger from wrapping */
|
||||
.navbar-brand {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
}
|
||||
|
||||
.navbar-brand .icon-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Progressive text shrinking to prevent hamburger wrap */
|
||||
@media only screen and (max-width: 480px) {
|
||||
.navbar-brand .icon-text {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 420px) {
|
||||
.navbar-brand .icon-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 380px) {
|
||||
.navbar-brand .icon-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 340px) {
|
||||
.navbar-brand .icon-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ultra-small screens - hide text, keep icon */
|
||||
@media only screen and (max-width: 320px) {
|
||||
.navbar-brand .icon-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
vertical-align: middle;
|
||||
font-size: 2rem !important;
|
||||
padding: 0.25rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.icon+.icon {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
margin-left: 8px;
|
||||
margin-right: 4px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
.scalable-languages-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 400px) {
|
||||
#languageSelection.scalable-languages-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (min-width: 600px) {
|
||||
#languageSelection.scalable-languages-container {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
#languageSelection.scalable-languages-container {
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scalable-languages-container:not(:has(> :nth-child(4))) .lang-dropdown-item-wrapper:last-child {
|
||||
border: 0px !important
|
||||
}
|
||||
|
||||
html[dir="ltr"] #languageSelection .lang-dropdown-item-wrapper:last-child {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
#languageSelection .lang-dropdown-item-wrapper:last-child {
|
||||
border: 0px !important;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.scalable-languages-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.scalable-languages-container:not(:has(> :nth-child(2))) {
|
||||
grid-template-columns: repeat(var(--count), 1fr) !important;
|
||||
}
|
||||
|
||||
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(2n) {
|
||||
border: 0px
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 601px) and (max-width: 900px) {
|
||||
.scalable-languages-container {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.scalable-languages-container:not(:has(> :nth-child(3))) {
|
||||
grid-template-columns: repeat(var(--count), 1fr) !important;
|
||||
}
|
||||
|
||||
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(3n) {
|
||||
border: 0px
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
.scalable-languages-container {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.scalable-languages-container:not(:has(> :nth-child(4))) {
|
||||
grid-template-columns: repeat(var(--count), 1fr) !important;
|
||||
}
|
||||
|
||||
.scalable-languages-container .lang-dropdown-item-wrapper:nth-child(4n) {
|
||||
border: 0px
|
||||
}
|
||||
}
|
||||
|
||||
.scalable-languages-container:has(> *:nth-child(1)) {
|
||||
--count: 1;
|
||||
}
|
||||
|
||||
.scalable-languages-container:has(> *:nth-child(2)) {
|
||||
--count: 2;
|
||||
}
|
||||
|
||||
.scalable-languages-container:has(> *:nth-child(3)) {
|
||||
--count: 3;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .lang-dropdown-item-wrapper {
|
||||
border-right: 2px solid var(--md-nav-color-on-separator);
|
||||
}
|
||||
|
||||
html[dir="rtl"] .lang-dropdown-item-wrapper {
|
||||
border-left: 2px solid var(--md-nav-color-on-separator);
|
||||
}
|
||||
|
||||
.scroll-lock-y {
|
||||
overflow-y: auto;
|
||||
max-height: 30vh;
|
||||
overscroll-behavior-y: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (min-width: 1200px) {
|
||||
.lang-dropdown-item-wrapper .dropdown-item {
|
||||
min-width: 200px
|
||||
}
|
||||
|
||||
.scroll-lock-y {
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item .icon-text {
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
width: 80%;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.close-icon:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
span.icon-text::after {
|
||||
content: attr(data-text);
|
||||
content: attr(data-text) / "";
|
||||
font-weight: 600;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item-separator {
|
||||
position: relative;
|
||||
margin: 0 4px;
|
||||
/* Adjust the margin as needed */
|
||||
}
|
||||
|
||||
.nav-item-separator::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
/* Adjust the top and bottom margins as needed */
|
||||
bottom: 10%;
|
||||
width: 1px;
|
||||
background-color: #ccc;
|
||||
/* Adjust the color as needed */
|
||||
}
|
||||
|
||||
.navbar-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 98vw;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
margin-left: auto;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
[aria-expanded="true"] > .chevron-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* .tooltip-text {
|
||||
visibility: hidden;
|
||||
background-color: rgb(71 67 67 / 80%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.nav-item:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
} */
|
||||
|
||||
.dropdown-menu.scrollable-y {
|
||||
overflow-y: scroll;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
/* Dropdown Scrollbar*/
|
||||
.scrollable-y {
|
||||
overflow-y: scroll;
|
||||
max-height: 190px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.scrollable-y::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
width: 0.7rem;
|
||||
}
|
||||
|
||||
.scrollable-y::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollable-y::-webkit-scrollbar-thumb {
|
||||
border-radius: 2rem;
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border: 3px solid var(--md-sys-color-surface-5);
|
||||
}
|
||||
|
||||
/* Mega Menu */
|
||||
.dropdown-mega .dropdown-menu {
|
||||
width: 100%;
|
||||
left: 0 !important;
|
||||
right: auto !important;
|
||||
}
|
||||
|
||||
.dropdown-mega .title {
|
||||
padding-bottom: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu .list-group {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mega-content .dropdown-item:focus .nav-icon,
|
||||
.mega-content .dropdown-item:hover .nav-icon,
|
||||
.mega-content .dropdown-item.active .nav-icon {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.dropdown-item:focus.sign,
|
||||
.dropdown-item:hover.sign,
|
||||
.dropdown-item.active.sign {
|
||||
color: var(--md-nav-on-section-color-sign);
|
||||
background-color: var(--md-nav-section-color-sign);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.organize,
|
||||
.dropdown-item:hover.organize,
|
||||
.dropdown-item.active.organize {
|
||||
color: var(--md-nav-on-section-color-organize);
|
||||
background-color: var(--md-nav-section-color-organize);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.convert,
|
||||
.dropdown-item:hover.convert,
|
||||
.dropdown-item.active.convert {
|
||||
color: var(--md-nav-on-section-color-convert);
|
||||
background-color: var(--md-nav-section-color-convert);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.convertto,
|
||||
.dropdown-item:hover.convertto,
|
||||
.dropdown-item.active.convertto {
|
||||
color: var(--md-nav-on-section-color-convertto);
|
||||
background-color: var(--md-nav-section-color-convertto);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.image,
|
||||
.dropdown-item:hover.image,
|
||||
.dropdown-item.active.image {
|
||||
color: var(--md-nav-on-section-color-image);
|
||||
background-color: var(--md-nav-section-color-image);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.word,
|
||||
.dropdown-item:hover.word,
|
||||
.dropdown-item.active.word {
|
||||
color: var(--md-nav-on-section-color-word);
|
||||
background-color: var(--md-nav-section-color-word);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.ppt,
|
||||
.dropdown-item:hover.ppt,
|
||||
.dropdown-item.active.ppt {
|
||||
color: var(--md-nav-on-section-color-ppt);
|
||||
background-color: var(--md-nav-section-color-ppt);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.security,
|
||||
.dropdown-item:hover.security,
|
||||
.dropdown-item.active.security {
|
||||
color: var(--md-nav-on-section-color-security);
|
||||
background-color: var(--md-nav-section-color-security);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.other,
|
||||
.dropdown-item:hover.other,
|
||||
.dropdown-item.active.other {
|
||||
color: var(--md-nav-on-section-color-other);
|
||||
background-color: var(--md-nav-section-color-other);
|
||||
}
|
||||
|
||||
.dropdown-item:focus.advance,
|
||||
.dropdown-item:hover.advance,
|
||||
.dropdown-item.active.advance {
|
||||
color: var(--md-nav-on-section-color-advance);
|
||||
background-color: var(--md-nav-section-color-advance);
|
||||
}
|
||||
|
||||
/* Dropdown min-width */
|
||||
.dropdown-mw-28 {
|
||||
min-width: 280px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.dropdown-mw-20 {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Dropdown open on hover */
|
||||
html[dir="ltr"] .dropdown-menu {
|
||||
padding-top: 0.5rem;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .dropdown-menu {
|
||||
padding-top: 0.5rem;
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
/* Bootstrap Popper positioning overrides removed - dropdowns now position naturally relative to their buttons */
|
||||
|
||||
.dropdown-menu-wrapper {
|
||||
padding: 1.5rem 0;
|
||||
border-radius: 1rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border: 1px solid var(--md-sys-color-surface-5);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-menu-tp {
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.icon-hide {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@media (max-width:1199.98px) {
|
||||
.navbar-collapse .dropdown-menu {
|
||||
width: 100%;
|
||||
}
|
||||
.navbar-collapse .dropdown-menu-wrapper {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.navbar-collapse .dropdown-mw-28 {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width:1200px) {
|
||||
/* This CSS-based hover is disabled because it conflicts with Bootstrap's JavaScript.
|
||||
Hover functionality is now handled in navbar.js and search.js */
|
||||
/*
|
||||
.dropdown:hover .dropdown-menu {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
}
|
||||
*/
|
||||
|
||||
.icon-hide {
|
||||
display: none;
|
||||
}
|
||||
.chevron-icon {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.navbar-collapse .dropdown-menu {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
transform: none !important;
|
||||
transform-origin: none !important;
|
||||
}
|
||||
|
||||
.navbar .navbar-expand-xl .dropdown-menu,
|
||||
.navbar-expand .dropdown-menu {
|
||||
transform: none !important;
|
||||
transform-origin: none !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.navbar-collapse .dropdown-mega .dropdown-menu {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.navbar-collapse .dropdown-mega .dropdown-menu-wrapper {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#favoritesDropdown,
|
||||
#languageDropdown + .dropdown-menu .dropdown-menu-wrapper,
|
||||
#searchDropdown + .dropdown-menu .dropdown-menu-wrapper {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
#favoritesDropdown, #languageSelection, #searchResults {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.navbar-collapse .dropdown-menu-wrapper {
|
||||
width: 95vw;
|
||||
box-sizing: border-box;
|
||||
margin-left: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.navbar-collapse .dropdown-mw-28 {
|
||||
min-width: 0;
|
||||
}
|
||||
.icon-hide {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navbar-collapse .dropdown-item {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
#mainNavbarDropdownMenu {
|
||||
transform: none !important;
|
||||
transform-origin: none !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.dropdown-menu,
|
||||
.dropdown-menu-tp,
|
||||
.dropdown-menu-tp.show {
|
||||
transform: none !important;
|
||||
transform-origin: none !important;
|
||||
max-width: 95vw !important;
|
||||
width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.go-pro-link {
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.go-pro-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
background-color: #007bff;
|
||||
border-radius: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.go-pro-link:hover .go-pro-badge {
|
||||
background-color: #0056b3;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#stacked {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#stacked>.navbar-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.features-container {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 14rem;
|
||||
max-width: 18rem;
|
||||
flex: 1 1 min(14rem, 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feature-group {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-rows {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.feature-rows.single-column {
|
||||
justify-content: center;
|
||||
/* Center-align a single column */
|
||||
}
|
||||
|
||||
.feature-group-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
margin-top: 15px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-group-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.card-title.text-primary {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.home-card-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.favorite-icon {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.favorite-icon img {
|
||||
filter: brightness(0) invert(var(--md-theme-filter-color));
|
||||
}
|
||||
|
||||
.favorite-icon:hover .material-symbols-rounded {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.favorite-icon .material-symbols-rounded.fill {
|
||||
color: #f5c000;
|
||||
}
|
||||
|
||||
|
||||
@keyframes Pulse {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.update-notice {
|
||||
animation: scale 1s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
0% {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
.pdf-actions_button-container {
|
||||
z-index: 4;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s linear;
|
||||
|
||||
position: absolute !important;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0%);
|
||||
}
|
||||
|
||||
.pdf-actions_container:hover .pdf-actions_button-container {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pdf-actions_button-container>* {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pdf-actions_button-container>*:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.pdf-actions_button-container .btn {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pdf-actions_button-container> :first-child,
|
||||
.pdf-actions_container:first-child>.pdf-actions_button-container> :first-child+* {
|
||||
border-radius: 12px 0px 0px 12px;
|
||||
}
|
||||
|
||||
.pdf-actions_container svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.pdf-actions_container:nth-child(1) .pdf-actions_move-left-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pdf-actions_container:last-child .pdf-actions_move-right-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* "insert pdf" buttons that appear on the right when hover */
|
||||
.pdf-actions_insert-file-button-container {
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
translate: 0 -50%;
|
||||
height: 100%;
|
||||
width: 100px;
|
||||
padding-left: 30px;
|
||||
padding-right:30px;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
}
|
||||
|
||||
.pdf-actions_insert-file-button-container.left {
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
.pdf-actions_insert-file-button-container.right {
|
||||
right: -50px;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .pdf-actions_insert-file-button-container.right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .pdf-actions_insert-file-button-container.left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[dir="ltr"] .pdf-actions_container:last-child>.pdf-actions_insert-file-button-container.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .pdf-actions_container:last-child>.pdf-actions_insert-file-button-container.left {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pdf-actions_insert-file-button-container.left button,
|
||||
.pdf-actions_insert-file-button-container.right button {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.pdf-actions_button-container button .material-symbols-rounded {
|
||||
font-size: 1.25rem;
|
||||
vertical-align: sub;
|
||||
}
|
||||
|
||||
.pdf-actions_insert-file-button-container:hover {
|
||||
opacity: 1;
|
||||
transition: opacity 0.05s;
|
||||
}
|
||||
|
||||
.pdf-actions_checkbox {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 3px;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.filename {
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.arrows {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.arrows .btn {
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.move-up span,
|
||||
.move-down span {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
@@ -1,973 +0,0 @@
|
||||
/* Dark Mode Styles */
|
||||
body,
|
||||
select,
|
||||
textarea {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.transition-theme {
|
||||
transition: background 0.5s ease, color 0.5s ease, border 0.5s ease;
|
||||
}
|
||||
|
||||
|
||||
/*.global-buttons-container input:disabled::-webkit-input-placeholder { !* WebKit browsers *!*/
|
||||
/* color: #98A0AB;*/
|
||||
/*}*/
|
||||
/*.global-buttons-container input:disabled:-moz-placeholder { !* Mozilla Firefox 4 to 18 *!*/
|
||||
/* color: #98A0AB;*/
|
||||
/*}*/
|
||||
/*.global-buttons-container input:disabled::-moz-placeholder { !* Mozilla Firefox 19+ *!*/
|
||||
/* color: #98A0AB;*/
|
||||
/*}*/
|
||||
/*.global-buttons-container input:disabled:-ms-input-placeholder { !* Internet Explorer 10+ *!*/
|
||||
/* color: #98A0AB;*/
|
||||
/*}*/
|
||||
/* Scrollbar */
|
||||
*::-webkit-scrollbar {
|
||||
background: var(--md-sys-color-surface);
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border-radius: 2rem;
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border: 5px solid var(--md-sys-color-surface-5);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
border-radius: 3rem;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
td {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.input-group-append {
|
||||
margin: 0rem 0.5rem !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
padding: 2.5rem;
|
||||
|
||||
max-width: 95vw;
|
||||
margin-left: 2vw;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 2rem;
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-content {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border-radius: 2rem;
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-radius: 2rem 2rem 0rem 0rem;
|
||||
padding: 1.5rem 2rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-body{
|
||||
padding: 0.5rem 2rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-radius: 0rem 0rem 2rem 2rem;
|
||||
padding: 0.5rem 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Icon fill */
|
||||
.material-symbols-rounded {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
/* Navbar Icon*/
|
||||
.nav-icon {
|
||||
color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
.sign .nav-icon,
|
||||
.sign.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-sign);
|
||||
background-color: var(--md-nav-section-color-sign);
|
||||
}
|
||||
|
||||
.organize .nav-icon,
|
||||
.organize.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-organize);
|
||||
background-color: var(--md-nav-section-color-organize);
|
||||
}
|
||||
|
||||
.convert .nav-icon,
|
||||
.convert.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-convert);
|
||||
background-color: var(--md-nav-section-color-convert);
|
||||
}
|
||||
|
||||
.convertto .nav-icon,
|
||||
.convertto.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-convertto);
|
||||
background-color: var(--md-nav-section-color-convertto);
|
||||
}
|
||||
|
||||
|
||||
.security .nav-icon,
|
||||
.security.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-security);
|
||||
background-color: var(--md-nav-section-color-security);
|
||||
}
|
||||
|
||||
.other .nav-icon,
|
||||
.other.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-other);
|
||||
background-color: var(--md-nav-section-color-other);
|
||||
}
|
||||
|
||||
.advance .nav-icon,
|
||||
.advance.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-advance);
|
||||
background-color: var(--md-nav-section-color-advance);
|
||||
}
|
||||
|
||||
.image .nav-icon,
|
||||
.image.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-image);
|
||||
background-color: var(--md-nav-section-color-image);
|
||||
}
|
||||
|
||||
.word .nav-icon,
|
||||
.word.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-word);
|
||||
background-color: var(--md-nav-section-color-word);
|
||||
}
|
||||
|
||||
.ppt .nav-icon,
|
||||
.ppt.tool-header-icon {
|
||||
color: var(--md-nav-on-section-color-ppt);
|
||||
background-color: var(--md-nav-section-color-ppt);
|
||||
}
|
||||
|
||||
/* Tool Page Header*/
|
||||
.tool-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tool-header .tool-header-icon {
|
||||
margin: 0px 1rem;
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
border-radius: 25px;
|
||||
font-size: 3rem;
|
||||
padding: 0.5rem;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
|
||||
}
|
||||
|
||||
.tool-header .tool-header-text {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Home Card Colors*/
|
||||
.feature-card .nav-icon {
|
||||
vertical-align: middle;
|
||||
font-size: 2rem !important;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
.feature-card .sign .nav-icon {
|
||||
color: var(--md-nav-on-section-color-sign);
|
||||
background-color: var(--md-nav-section-color-sign);
|
||||
}
|
||||
|
||||
.feature-card .organize .nav-icon {
|
||||
color: var(--md-nav-on-section-color-organize);
|
||||
background-color: var(--md-nav-section-color-organize);
|
||||
}
|
||||
|
||||
.feature-card .convert .nav-icon {
|
||||
color: var(--md-nav-on-section-color-convert);
|
||||
background-color: var(--md-nav-section-color-convert);
|
||||
}
|
||||
|
||||
.feature-card .convertto .nav-icon {
|
||||
color: var(--md-nav-on-section-color-convertto);
|
||||
background-color: var(--md-nav-section-color-convertto);
|
||||
}
|
||||
|
||||
.feature-card .security .nav-icon {
|
||||
color: var(--md-nav-on-section-color-security);
|
||||
background-color: var(--md-nav-section-color-security);
|
||||
}
|
||||
|
||||
.feature-card .other .nav-icon {
|
||||
color: var(--md-nav-on-section-color-other);
|
||||
background-color: var(--md-nav-section-color-other);
|
||||
}
|
||||
|
||||
.feature-card .advance .nav-icon {
|
||||
color: var(--md-nav-on-section-color-advance);
|
||||
background-color: var(--md-nav-section-color-advance);
|
||||
}
|
||||
|
||||
.feature-card .image .nav-icon {
|
||||
color: var(--md-nav-on-section-color-image);
|
||||
background-color: var(--md-nav-section-color-image);
|
||||
}
|
||||
|
||||
.feature-card .word .nav-icon {
|
||||
color: var(--md-nav-on-section-color-word);
|
||||
background-color: var(--md-nav-section-color-word);
|
||||
}
|
||||
|
||||
.feature-card .ppt .nav-icon {
|
||||
color: var(--md-nav-on-section-color-ppt);
|
||||
background-color: var(--md-nav-section-color-ppt);
|
||||
}
|
||||
|
||||
/* Buttons Components */
|
||||
.btn {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.modal-header .btn-close {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Primary btn */
|
||||
.btn-primary {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-primary.disabled,
|
||||
.btn-primary:disabled {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-primary,
|
||||
.btn-check:checked+.btn-primary,
|
||||
.btn-primary.active,
|
||||
.btn-primary:active,
|
||||
.show>.btn-primary.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-primary,
|
||||
.btn-primary:focus {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Secondary btn */
|
||||
.btn-secondary {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-secondary.disabled,
|
||||
.btn-secondary:disabled {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-secondary,
|
||||
.btn-check:checked+.btn-secondary,
|
||||
.btn-secondary.active,
|
||||
.btn-secondary:active,
|
||||
.show>.btn-secondary.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-secondary,
|
||||
.btn-secondary:focus {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Danger btn */
|
||||
.btn-danger {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
border-color: var(--md-sys-color-error);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-danger.disabled,
|
||||
.btn-danger:disabled {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
border-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
border-color: var(--md-sys-color-error);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-danger,
|
||||
.btn-check:checked+.btn-danger,
|
||||
.btn-danger.active,
|
||||
.btn-danger:active,
|
||||
.show>.btn-danger.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
border-color: var(--md-sys-color-error);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-danger,
|
||||
.btn-danger:focus {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background-color: var(--md-sys-color-error);
|
||||
border-color: var(--md-sys-color-error);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Info btn */
|
||||
.btn-info {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background-color: var(--md-sys-color-tertiary);
|
||||
border-color: var(--md-sys-color-tertiary);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-info .disabled,
|
||||
.btn-info:disabled {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background-color: var(--md-sys-color-tertiary);
|
||||
border-color: var(--md-sys-color-tertiary);
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background-color: var(--md-sys-color-tertiary);
|
||||
border-color: var(--md-sys-color-tertiary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-info,
|
||||
.btn-check:checked+.btn-info,
|
||||
.btn-info .active,
|
||||
.btn-info:active,
|
||||
.show>.btn-info.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background-color: var(--md-sys-color-tertiary);
|
||||
border-color: var(--md-sys-color-tertiary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-info,
|
||||
.btn-info:focus {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background-color: var(--md-sys-color-tertiary);
|
||||
border-color: var(--md-sys-color-tertiary);
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Info btn */
|
||||
.btn-success {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-success:hover,
|
||||
.btn-check:active+.btn-success,
|
||||
.btn-check:checked+.btn-success,
|
||||
.btn-success .active,
|
||||
.btn-success:active,
|
||||
.show>.btn-success.dropdown-toggle,
|
||||
.btn-check:focus+.btn-success,
|
||||
.btn-success:focus {
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Warning btn */
|
||||
.btn-warning {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-warning:hover,
|
||||
.btn-check:active+.btn-warning,
|
||||
.btn-check:checked+.btn-warning,
|
||||
.btn-warning .active,
|
||||
.btn-warning:active,
|
||||
.show>.btn-warning.dropdown-toggle,
|
||||
.btn-check:focus+.btn-warning,
|
||||
.btn-warning:focus {
|
||||
box-shadow: var(--md-sys-elevation-3) !important;
|
||||
}
|
||||
|
||||
/* Outline Primary btn */
|
||||
.btn-outline-primary {
|
||||
color: var(--md-sys-color-primary);
|
||||
background-color: transparent;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary .disabled,
|
||||
.btn-outline-primary:disabled {
|
||||
color: var(--md-sys-color-primary);
|
||||
background-color: transparent;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-outline-primary,
|
||||
.btn-check:checked+.btn-outline-primary,
|
||||
.btn-outline-primary .active,
|
||||
.btn-outline-primary:active,
|
||||
.show>.btn-outline-primary.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-outline-primary,
|
||||
.btn-outline-primary:focus {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* Outline Secondary btn */
|
||||
.btn-outline-secondary {
|
||||
color: var(--md-sys-color-secondary);
|
||||
background-color: transparent;
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-outline-secondary .disabled,
|
||||
.btn-outline-secondary:disabled {
|
||||
color: var(--md-sys-color-secondary);
|
||||
background-color: transparent;
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.btn-check:active+.btn-outline-secondary,
|
||||
.btn-check:checked+.btn-outline-secondary,
|
||||
.btn-outline-secondary .active,
|
||||
.btn-outline-secondary:active,
|
||||
.show>.btn-outline-secondary.dropdown-toggle {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.btn-check:focus+.btn-outline-secondary,
|
||||
.btn-outline-secondary:focus {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
/* Disabled btn */
|
||||
.btn.disabled,
|
||||
.btn:disabled,
|
||||
fieldset:disabled .btn {
|
||||
pointer-events: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Range Slider */
|
||||
.form-range{
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.form-range:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 1px var(--md-sys-color-surface), 0 0 0 .25rem var(--md-sys-color-primary)
|
||||
}
|
||||
|
||||
.form-range:focus::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 1px var(--md-sys-color-surface), 0 0 0 .25rem var(--md-sys-color-primary)
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
|
||||
.form-range::-webkit-slider-thumb:active {
|
||||
background-color: var(--md-sys-color-primary)
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-runnable-track {
|
||||
background-color: var(--md-sys-color-on-primary)
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
|
||||
/* checkbox */
|
||||
.form-check {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin: 0;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border: 2px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-check-input:focus {
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 .25rem var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.form-check-input:checked[type=checkbox] {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.form-check input[type="checkbox"]:checked+span.material-symbols-rounded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-check span.material-symbols-rounded {
|
||||
display: none;
|
||||
color: var(--md-sys-color-surface);
|
||||
position: absolute;
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
|
||||
}
|
||||
|
||||
.form-check {
|
||||
min-height: 22px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.form-check > label {
|
||||
padding-left: 29px !important;
|
||||
min-height: 22px;
|
||||
line-height: 22px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
padding-right: 29px !important;
|
||||
}
|
||||
|
||||
.form-check > input:first-child {
|
||||
position: absolute !important;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
background-color: var(--md-sys-state-hover-opacity);
|
||||
border-radius: 50%;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-ms-appearance: none;
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
outline: none;
|
||||
transform: scale(1.65);
|
||||
-ms-transform: scale(1.65);
|
||||
transition: opacity .3s;
|
||||
}
|
||||
|
||||
.form-check > input:first-child:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.65);
|
||||
-ms-transform: scale(1.65);
|
||||
}
|
||||
|
||||
.form-check > input:first-child:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.form-check > input:first-child:disabled + label,
|
||||
.form-check > input:first-child:disabled + input[type="hidden"] + label,
|
||||
.form-check > input:first-child:disabled + label::before,
|
||||
.form-check > input:first-child:disabled + input[type="hidden"] + label::before {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
filter: alpha(opacity=65);
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
.form-check > input:first-child + label::before,
|
||||
.form-check > input:first-child + input[type="hidden"] + label::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--md-sys-color-on-surface-variant);
|
||||
border-radius: 3px;
|
||||
margin-left: -29px;
|
||||
box-sizing: border-box;
|
||||
margin-right: -29px;
|
||||
}
|
||||
|
||||
.form-check > input:first-child:checked + label::after,
|
||||
.form-check > input:first-child:checked + input[type="hidden"] + label::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 7px;
|
||||
height: 10px;
|
||||
border: solid 2px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
transform: translate(7.75px, 4.5px) rotate(45deg);
|
||||
-ms-transform: translate(7.75px, 4.5px) rotate(45deg);
|
||||
box-sizing: border-box;
|
||||
right: 0;
|
||||
margin-right: 14px;
|
||||
border-bottom-color: var(--md-sys-color-on-primary);
|
||||
border-right-color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
.form-check > input:first-child::-ms-check {
|
||||
opacity: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.form-check > input:first-child:active {
|
||||
transform: scale(0);
|
||||
-ms-transform: scale(0);
|
||||
opacity: 1;
|
||||
transition: opacity 0s, transform 0s;
|
||||
}
|
||||
|
||||
.form-check > input[type="radio"]:first-child + label::before,
|
||||
.form-check > input[type="radio"]:first-child + input[type="hidden"] + label::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.form-check > input[type="radio"]:first-child:checked + label::before,
|
||||
.form-check > input[type="radio"]:first-child:checked + input[type="hidden"] + label::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-check > input[type="radio"]:first-child:checked + label::after,
|
||||
.form-check > input[type="radio"]:first-child:checked + input[type="hidden"] + label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
transform: none;
|
||||
-ms-transform: none;
|
||||
}
|
||||
|
||||
.form-check > input[type="checkbox"]:first-child:checked + label::after,
|
||||
.form-check > input[type="checkbox"]:first-child:checked + input[type="hidden"] + label::after {
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
transform: translate(7px, 2px) rotate(45deg);
|
||||
-ms-transform: translate(7px, 2px) rotate(45deg);
|
||||
}
|
||||
|
||||
.form-check-inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.form-check-inline + .form-check-inline {
|
||||
margin-left: .75rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-check > input:first-child:checked + label::before,
|
||||
.form-check > input:first-child:checked + input[type="hidden"] + label::before {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
textarea.form-control {
|
||||
border-radius: 1.5rem !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select,
|
||||
.form-control:disabled,
|
||||
.form-control[readonly] {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
border-radius: 3rem !important;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-container-lowest);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.form-control-color {
|
||||
padding: 0;
|
||||
height: 2.4rem;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.form-control input[type="color"] {
|
||||
opacity: 0;
|
||||
height: 2.4rem;
|
||||
width: 2.4rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-control input[type="color"]:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Navbar Components */
|
||||
.navbar-brand {
|
||||
color: var(--md-sys-color-on-surface) !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: none !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
border: 1px transparent;
|
||||
}
|
||||
|
||||
.navbar-nav li {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:focus,
|
||||
.navbar-nav .nav-link:hover {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
background-color: var(--md-sys-color-surface-3);
|
||||
border-radius: 3rem;
|
||||
font-weight: 500;
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.active,
|
||||
.navbar-nav .show>.nav-link {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
font-weight: 500;
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
margin: 0 1%;
|
||||
padding: 1.5rem 0;
|
||||
border-radius: 1rem;
|
||||
min-width: 20rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
border: 1px solid var(--md-sys-color-surface-5);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 3rem;
|
||||
|
||||
}
|
||||
|
||||
.dropdown-item:focus,
|
||||
.dropdown-item:hover {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
.dropdown-item.no-hover:hover,
|
||||
.dropdown-item.no-hover:focus {
|
||||
color: var(--md-sys-color-on-surface) !important;
|
||||
background-color: transparent !important;
|
||||
border-radius: 3rem !important;
|
||||
font-variation-settings: initial !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
font-weight: 500;
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
/* list-group-item */
|
||||
.list-group-item {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.list-group-item:first-child {
|
||||
border-radius: 1rem 1rem 0rem 0rem;
|
||||
}
|
||||
|
||||
.list-group-item:last-child {
|
||||
border-radius: 0rem 0rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.list-group-item:only-child {
|
||||
border-radius: 1rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.list-group-item .btn {
|
||||
padding: .375rem .5rem;
|
||||
}
|
||||
|
||||
/*Alert */
|
||||
.alert-container {
|
||||
padding: 2rem 3rem;
|
||||
border-radius: 3rem;
|
||||
margin: 1rem 0rem 2rem;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.alert-heading {
|
||||
font-size: calc(1.275rem + .3vw);
|
||||
}
|
||||
|
||||
.alert-dismissible .btn-close {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
color: var(--md-sys-color-on-error-container);
|
||||
background-color: var(--md-sys-color-error-container);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
src: url(../../fonts/google-symbol.woff2) format('woff2');
|
||||
}
|
||||
|
||||
|
||||
|
||||
.material-symbols-rounded {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
:where(html, .light-theme, .dark-theme),
|
||||
.tokens,
|
||||
:host {
|
||||
/* Define surface colors based on primary color */
|
||||
--md-sys-color-surface-1: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 100, 0.05) 5%);
|
||||
--md-sys-color-surface-2: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.08) 5%);
|
||||
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
|
||||
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
|
||||
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
|
||||
/* Clear button disabled text color (default/light) */
|
||||
--spdf-clear-disabled-text: var(--md-sys-color-primary);
|
||||
/* Icon fill */
|
||||
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
||||
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
|
||||
/* Hover Color */
|
||||
--md-sys-state-hover-opacity: color-mix(in srgb, var(--md-sys-color-primary), rgba(0, 0, 0, 0) 80%);
|
||||
/* Shadow */
|
||||
--md-sys-color-shadow: #000000;
|
||||
--md-elevation-shadow-color-rgb: 0, 0, 0;
|
||||
--md-elevation-shadow-color: var(--md-elevation-shadow-color-rgb);
|
||||
/* Shadow Elevation*/
|
||||
--md-sys-elevation-0: 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.2), 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 0px 0px 0px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
--md-sys-elevation-1: 0px 3px 1px -2px rgb(var(--md-elevation-shadow-color), 0.2), 0px 2px 2px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 1px 5px 0px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
--md-sys-elevation-2: 0px 2px 4px -1px rgb(var(--md-elevation-shadow-color), 0.2), 0px 4px 5px 0px rgb(var(--md-elevation-shadow-color), 0.14), 0px 1px 10px 0px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
--md-sys-elevation-3: 0px 5px 5px -3px rgb(var(--md-elevation-shadow-color), 0.2), 0px 8px 10px 1px rgb(var(--md-elevation-shadow-color), 0.14), 0px 3px 14px 2px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
--md-sys-elevation-4: 0px 5px 5px -3px rgb(var(--md-elevation-shadow-color) / 0.2), 0px 8px 10px 1px rgb(var(--md-elevation-shadow-color), 0.14), 0px 3px 14px 2px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
.dark-theme {
|
||||
/* In dark mode, use a neutral grey for disabled Clear button text */
|
||||
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
|
||||
}
|
||||
|
||||
.fill {
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
.no-fill {
|
||||
/* font-variation-settings: var(--md-sys-icon-fill-0); */
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
:root {
|
||||
/* Colors */
|
||||
--md-sys-color-primary: rgb(162 201 255);
|
||||
--md-sys-color-surface-tint: rgb(162 201 255);
|
||||
--md-sys-color-on-primary: rgb(0 49 92);
|
||||
--md-sys-color-primary-container: rgb(0 118 208);
|
||||
--md-sys-color-on-primary-container: rgb(255 255 255);
|
||||
--md-sys-color-secondary: rgb(169 201 246);
|
||||
--md-sys-color-on-secondary: rgb(12 49 87);
|
||||
--md-sys-color-secondary-container: rgb(29 62 100);
|
||||
--md-sys-color-on-secondary-container: rgb(180 210 255);
|
||||
--md-sys-color-tertiary: rgb(193 194 248);
|
||||
--md-sys-color-on-tertiary: rgb(42 44 88);
|
||||
--md-sys-color-tertiary-container: rgb(110 112 161);
|
||||
--md-sys-color-on-tertiary-container: rgb(255 255 255);
|
||||
--md-sys-color-error: rgb(255 180 171);
|
||||
--md-sys-color-on-error: rgb(105 0 5);
|
||||
--md-sys-color-error-container: rgb(147 0 10);
|
||||
--md-sys-color-on-error-container: rgb(255 218 214);
|
||||
--md-sys-color-background: rgb(15 20 26);
|
||||
--md-sys-color-on-background: rgb(223 226 235);
|
||||
--md-sys-color-surface: rgb(15 20 26);
|
||||
--md-sys-color-on-surface: rgb(223 226 235);
|
||||
--md-sys-color-surface-variant: rgb(64 71 83);
|
||||
--md-sys-color-on-surface-variant: rgb(192 199 213);
|
||||
--md-sys-color-outline: rgb(138 145 158);
|
||||
--md-sys-color-outline-variant: rgb(64 71 83);
|
||||
--md-sys-color-shadow: rgb(0 0 0);
|
||||
--md-sys-color-scrim: rgb(0 0 0);
|
||||
--md-sys-color-inverse-surface: rgb(223 226 235);
|
||||
--md-sys-color-inverse-on-surface: rgb(45 49 55);
|
||||
--md-sys-color-inverse-primary: rgb(0 96 170);
|
||||
--md-sys-color-primary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-primary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-primary-fixed-dim: rgb(162 201 255);
|
||||
--md-sys-color-on-primary-fixed-variant: rgb(0 72 130);
|
||||
--md-sys-color-secondary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-secondary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-secondary-fixed-dim: rgb(169 201 246);
|
||||
--md-sys-color-on-secondary-fixed-variant: rgb(40 72 111);
|
||||
--md-sys-color-tertiary-fixed: rgb(225 224 255);
|
||||
--md-sys-color-on-tertiary-fixed: rgb(20 22 66);
|
||||
--md-sys-color-tertiary-fixed-dim: rgb(193 194 248);
|
||||
--md-sys-color-on-tertiary-fixed-variant: rgb(64 67 112);
|
||||
--md-sys-color-surface-dim: rgb(15 20 26);
|
||||
--md-sys-color-surface-bright: rgb(53 57 64);
|
||||
--md-sys-color-surface-container-lowest: rgb(10 14 20);
|
||||
--md-sys-color-surface-container-low: rgb(24 28 34);
|
||||
--md-sys-color-surface-container: rgb(28 32 38);
|
||||
--md-sys-color-surface-container-high: rgb(38 42 49);
|
||||
--md-sys-color-surface-container-highest: rgb(49 53 60);
|
||||
/* Tools Color */
|
||||
--md-nav-section-color-opacity: 1;
|
||||
--md-nav-on-section-color-opacity: 1;
|
||||
--md-nav-section-color-sign: rgba(25, 101, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-sign: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-organize: rgba(120, 130, 255, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-organize: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convert: rgba(25, 177, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convert: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convertto: rgba(104, 220, 149, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convertto: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-security: rgba(255, 120, 146, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-security: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-other: rgba(72, 189, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-other: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-advance: rgba(245, 84, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-advance: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-image: rgba(212, 172, 25, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-image: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-word: rgba(61, 153, 245, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-word: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-ppt: rgba(255, 128, 0, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-ppt: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-color-on-separator: rgb(24 28 34);
|
||||
--md-nav-background: rgb(15 20 26);
|
||||
--favourite-add: #9ed18c;
|
||||
--favourite-remove: palevioletred;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
:root {
|
||||
/* Colors */
|
||||
--md-sys-color-primary: rgb(0 96 170);
|
||||
--md-sys-color-surface-tint: rgb(0 96 170);
|
||||
--md-sys-color-on-primary: rgb(255 255 255);
|
||||
--md-sys-color-primary-container: rgb(80 163 255);
|
||||
--md-sys-color-on-primary-container: rgb(0 20 43);
|
||||
--md-sys-color-secondary: rgb(65 96 136);
|
||||
--md-sys-color-on-secondary: rgb(255 255 255);
|
||||
--md-sys-color-secondary-container: rgb(188 215 255);
|
||||
--md-sys-color-on-secondary-container: rgb(32 65 103);
|
||||
--md-sys-color-tertiary: rgb(88 90 138);
|
||||
--md-sys-color-on-tertiary: rgb(255 255 255);
|
||||
--md-sys-color-tertiary-container: rgb(151 153 205);
|
||||
--md-sys-color-on-tertiary-container: rgb(7 9 55);
|
||||
--md-sys-color-error: rgb(186 26 26);
|
||||
--md-sys-color-on-error: rgb(255 255 255);
|
||||
--md-sys-color-error-container: rgb(255 218 214);
|
||||
--md-sys-color-on-error-container: rgb(65 0 2);
|
||||
--md-sys-color-background: rgb(248 249 255);
|
||||
--md-sys-color-on-background: rgb(24 28 34);
|
||||
--md-sys-color-surface: rgb(237, 240, 245);
|
||||
--md-sys-color-on-surface: rgb(0, 1, 1);
|
||||
--md-sys-color-surface-variant: rgb(220 227 241);
|
||||
--md-sys-color-on-surface-variant: rgb(64 71 83);
|
||||
--md-sys-color-outline: rgb(112 119 132);
|
||||
--md-sys-color-outline-variant: rgb(192 199 213);
|
||||
--md-sys-color-shadow: rgb(0 0 0);
|
||||
--md-sys-color-scrim: rgb(0 0 0);
|
||||
--md-sys-color-inverse-surface: rgb(45 49 55);
|
||||
--md-sys-color-inverse-on-surface: rgb(238 241 250);
|
||||
--md-sys-color-inverse-primary: rgb(162 201 255);
|
||||
--md-sys-color-primary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-primary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-primary-fixed-dim: rgb(162 201 255);
|
||||
--md-sys-color-on-primary-fixed-variant: rgb(0 72 130);
|
||||
--md-sys-color-secondary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-secondary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-secondary-fixed-dim: rgb(169 201 246);
|
||||
--md-sys-color-on-secondary-fixed-variant: rgb(40 72 111);
|
||||
--md-sys-color-tertiary-fixed: rgb(225 224 255);
|
||||
--md-sys-color-on-tertiary-fixed: rgb(20 22 66);
|
||||
--md-sys-color-tertiary-fixed-dim: rgb(193 194 248);
|
||||
--md-sys-color-on-tertiary-fixed-variant: rgb(64 67 112);
|
||||
--md-sys-color-surface-dim: rgb(215 218 227);
|
||||
--md-sys-color-surface-bright: rgb(248 249 255);
|
||||
--md-sys-color-surface-container-lowest: rgb(255 255 255);
|
||||
--md-sys-color-surface-container-low: rgb(241 243 253);
|
||||
--md-sys-color-surface-container: rgb(235 238 247);
|
||||
--md-sys-color-surface-container-high: rgb(229 232 241);
|
||||
--md-sys-color-surface-container-highest: rgb(223 226 235);
|
||||
/* Tools Color */
|
||||
--md-nav-section-color-opacity: 1;
|
||||
--md-nav-on-section-color-opacity: 1;
|
||||
--md-nav-section-color-sign: rgba(25, 101, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-sign: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-organize: rgba(120, 130, 255, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-organize: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convert: rgba(25, 177, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convert: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convertto: rgba(104, 220, 149, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convertto: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-security: rgba(255, 120, 146, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-security: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-other: rgba(72, 189, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-other: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-advance: rgba(245, 84, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-advance: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-image: rgba(212, 172, 25, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-image: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-word: rgba(61, 153, 245, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-word: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-ppt: rgba(255, 128, 0, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-ppt: rgba(255, 251, 254, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-color-on-separator: rgb(174, 178, 179);
|
||||
--md-nav-background: rgb(248 249 255);
|
||||
--favourite-add: #25ab6c;
|
||||
--favourite-remove: rgb(222, 94, 137);
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@ExtendWith({MockitoExtension.class})
|
||||
class RearrangePagesPDFControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
|
||||
|
||||
private RearrangePagesPDFController sut;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sut = new RearrangePagesPDFController(mockPdfDocumentFactory);
|
||||
}
|
||||
|
||||
/** Tests the behavior of the oddEvenMerge method when there are no pages in the document. */
|
||||
@Test
|
||||
void oddEvenMerge_noPages() {
|
||||
int totalNumberOfPages = 0;
|
||||
|
||||
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
|
||||
|
||||
assertNotNull(newPageOrder, "Returning null instead of page order list");
|
||||
assertEquals(List.of(), newPageOrder, "Page order doesn't match");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the behavior of the oddEvenMerge method when there are odd total pages in the document.
|
||||
*/
|
||||
@Test
|
||||
void oddEvenMerge_oddTotalPageNumber() {
|
||||
int totalNumberOfPages = 5;
|
||||
|
||||
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
|
||||
|
||||
assertNotNull(newPageOrder, "Returning null instead of page order list");
|
||||
assertEquals(Arrays.asList(0, 3, 1, 4, 2), newPageOrder, "Page order doesn't match");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the behavior of the oddEvenMerge method when there are even total pages in the
|
||||
* document.
|
||||
*/
|
||||
@Test
|
||||
void oddEvenMerge_evenTotalPageNumber() {
|
||||
int totalNumberOfPages = 6;
|
||||
|
||||
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
|
||||
|
||||
assertNotNull(newPageOrder, "Returning null instead of page order list");
|
||||
assertEquals(Arrays.asList(0, 3, 1, 4, 2, 5), newPageOrder, "Page order doesn't match");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the behavior of the oddEvenMerge method with multiple test cases of multiple pages.
|
||||
*
|
||||
* @param totalNumberOfPages The total number of pages in the document.
|
||||
* @param expectedPageOrder The expected order of the pages after rearranging.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"1, '0'",
|
||||
"2, '0,1'",
|
||||
"3, '0,2,1'",
|
||||
"4, '0,2,1,3'",
|
||||
"5, '0,3,1,4,2'",
|
||||
"6, '0,3,1,4,2,5'",
|
||||
"10, '0,5,1,6,2,7,3,8,4,9'",
|
||||
"50, '0,25,1,26,2,27,3,28,4,29,5,30,6,31,7,32,8,33,9,34,10,35,"
|
||||
+ "11,36,12,37,13,38,14,39,15,40,16,41,17,42,18,43,19,44,20,45,21,46,"
|
||||
+ "22,47,23,48,24,49'"
|
||||
})
|
||||
void oddEvenMerge_multi_test(int totalNumberOfPages, String expectedPageOrder) {
|
||||
List<Integer> newPageOrder = sut.oddEvenMerge(totalNumberOfPages);
|
||||
|
||||
assertNotNull(newPageOrder, "Returning null instead of page order list");
|
||||
assertEquals(
|
||||
Arrays.stream(expectedPageOrder.split(",")).map(Integer::parseInt).toList(),
|
||||
newPageOrder,
|
||||
"Page order doesn't match");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ class SortTypesTest {
|
||||
"BOOKLET_SORT",
|
||||
"SIDE_STITCH_BOOKLET_SORT",
|
||||
"ODD_EVEN_SPLIT",
|
||||
"ODD_EVEN_MERGE",
|
||||
"REMOVE_FIRST",
|
||||
"REMOVE_LAST",
|
||||
"REMOVE_FIRST_AND_LAST",
|
||||
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
class PdfImageRemovalServiceTest {
|
||||
|
||||
private PdfImageRemovalService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdfImageRemovalService();
|
||||
}
|
||||
|
||||
// Helper method for matching COSName in verification
|
||||
private static COSName eq(final COSName value) {
|
||||
return Mockito.argThat(
|
||||
new org.mockito.ArgumentMatcher<>() {
|
||||
@Override
|
||||
public boolean matches(COSName argument) {
|
||||
if (argument == null && value == null) return true;
|
||||
if (argument == null || value == null) return false;
|
||||
return argument.getName().equals(value.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "eq(" + (value != null ? value.getName() : "null") + ")";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveImagesFromPdf_WithImages() throws IOException {
|
||||
// Mock PDF document and its components
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
PDResources resources = mock(PDResources.class);
|
||||
PDPageTree pageTree = mock(PDPageTree.class);
|
||||
|
||||
// Configure page tree to iterate over our single page
|
||||
when(document.getPages()).thenReturn(pageTree);
|
||||
Iterator<PDPage> pageIterator = Collections.singletonList(page).iterator();
|
||||
when(pageTree.iterator()).thenReturn(pageIterator);
|
||||
|
||||
// Set up page resources
|
||||
when(page.getResources()).thenReturn(resources);
|
||||
|
||||
// Set up image XObjects
|
||||
COSName img1 = COSName.getPDFName("Im1");
|
||||
COSName img2 = COSName.getPDFName("Im2");
|
||||
COSName nonImg = COSName.getPDFName("NonImg");
|
||||
|
||||
List<COSName> xObjectNames = Arrays.asList(img1, img2, nonImg);
|
||||
when(resources.getXObjectNames()).thenReturn(xObjectNames);
|
||||
|
||||
// Configure which are image XObjects
|
||||
when(resources.isImageXObject(img1)).thenReturn(true);
|
||||
when(resources.isImageXObject(img2)).thenReturn(true);
|
||||
when(resources.isImageXObject(nonImg)).thenReturn(false);
|
||||
|
||||
// Execute the method
|
||||
service.removeImagesFromPdf(document);
|
||||
|
||||
// Verify that images were removed
|
||||
verify(resources, times(1)).put(eq(img1), Mockito.<PDXObject>isNull());
|
||||
verify(resources, times(1)).put(eq(img2), Mockito.<PDXObject>isNull());
|
||||
verify(resources, never()).put(eq(nonImg), Mockito.<PDXObject>isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveImagesFromPdf_NoImages() throws IOException {
|
||||
// Mock PDF document and its components
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
PDPage page = mock(PDPage.class);
|
||||
PDResources resources = mock(PDResources.class);
|
||||
PDPageTree pageTree = mock(PDPageTree.class);
|
||||
|
||||
// Configure page tree to iterate over our single page
|
||||
when(document.getPages()).thenReturn(pageTree);
|
||||
Iterator<PDPage> pageIterator = Collections.singletonList(page).iterator();
|
||||
when(pageTree.iterator()).thenReturn(pageIterator);
|
||||
|
||||
// Set up page resources
|
||||
when(page.getResources()).thenReturn(resources);
|
||||
|
||||
// Create empty list of XObject names
|
||||
List<COSName> emptyList = new ArrayList<>();
|
||||
when(resources.getXObjectNames()).thenReturn(emptyList);
|
||||
|
||||
// Execute the method
|
||||
service.removeImagesFromPdf(document);
|
||||
|
||||
// Verify that no modifications were made
|
||||
verify(resources, never()).put(any(COSName.class), any(PDXObject.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRemoveImagesFromPdf_MultiplePages() throws IOException {
|
||||
// Mock PDF document and its components
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
PDPage page1 = mock(PDPage.class);
|
||||
PDPage page2 = mock(PDPage.class);
|
||||
PDResources resources1 = mock(PDResources.class);
|
||||
PDResources resources2 = mock(PDResources.class);
|
||||
PDPageTree pageTree = mock(PDPageTree.class);
|
||||
|
||||
// Configure page tree to iterate over our two pages
|
||||
when(document.getPages()).thenReturn(pageTree);
|
||||
Iterator<PDPage> pageIterator = Arrays.asList(page1, page2).iterator();
|
||||
when(pageTree.iterator()).thenReturn(pageIterator);
|
||||
|
||||
// Set up page resources
|
||||
when(page1.getResources()).thenReturn(resources1);
|
||||
when(page2.getResources()).thenReturn(resources2);
|
||||
|
||||
// Set up image XObjects for page 1
|
||||
COSName img1 = COSName.getPDFName("Im1");
|
||||
when(resources1.getXObjectNames()).thenReturn(Collections.singletonList(img1));
|
||||
when(resources1.isImageXObject(img1)).thenReturn(true);
|
||||
|
||||
// Set up image XObjects for page 2
|
||||
COSName img2 = COSName.getPDFName("Im2");
|
||||
when(resources2.getXObjectNames()).thenReturn(Collections.singletonList(img2));
|
||||
when(resources2.isImageXObject(img2)).thenReturn(true);
|
||||
|
||||
// Execute the method
|
||||
service.removeImagesFromPdf(document);
|
||||
|
||||
// Verify that images were removed from both pages
|
||||
verify(resources1, times(1)).put(eq(img1), Mockito.<PDXObject>isNull());
|
||||
verify(resources2, times(1)).put(eq(img2), Mockito.<PDXObject>isNull());
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ dependencies {
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:5.0.0'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
|
||||
|
||||
+31
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -28,6 +29,8 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.firewall.HttpFirewall;
|
||||
import org.springframework.security.web.firewall.StrictHttpFirewall;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
@@ -136,6 +139,34 @@ public class SecurityConfiguration {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures HttpFirewall to allow non-ASCII characters in header values. This fixes issues
|
||||
* with reverse proxies (like Authelia) that may set headers with non-ASCII characters (e.g.,
|
||||
* "Remote-User: Dvořák").
|
||||
*
|
||||
* <p>By default, StrictHttpFirewall rejects header values containing non-ASCII characters. This
|
||||
* configuration allows valid UTF-8 encoded characters while maintaining security.
|
||||
*
|
||||
* @return Configured HttpFirewall that allows non-ASCII characters in headers
|
||||
*/
|
||||
@Bean
|
||||
public HttpFirewall httpFirewall() {
|
||||
StrictHttpFirewall firewall = new StrictHttpFirewall();
|
||||
// Allow non-ASCII characters but continue to reject control characters such as newlines.
|
||||
// Pattern adapted from Spring Security's StrictHttpFirewall documentation.
|
||||
Pattern allowedChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]]*");
|
||||
|
||||
firewall.setAllowedHeaderValues(
|
||||
headerValue ->
|
||||
headerValue != null && allowedChars.matcher(headerValue).matches());
|
||||
|
||||
// Apply the same rules to parameter values for consistency.
|
||||
firewall.setAllowedParameterValues(
|
||||
parameterValue ->
|
||||
parameterValue != null && allowedChars.matcher(parameterValue).matches());
|
||||
return firewall;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
List<String> configuredOrigins = null;
|
||||
|
||||
+3
-3
@@ -488,14 +488,14 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
private void executeDatabaseScript(Path scriptPath) {
|
||||
if (isH2Database()) {
|
||||
|
||||
// Validate SQL content BEFORE execution to prevent injection attacks
|
||||
validateSqlContent(scriptPath);
|
||||
|
||||
if (!verifyBackup(scriptPath)) {
|
||||
log.error("Backup verification failed for: {}", scriptPath);
|
||||
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
|
||||
}
|
||||
|
||||
// Validate SQL content before execution to prevent injection attacks
|
||||
validateSqlContent(scriptPath);
|
||||
|
||||
String query = "RUNSCRIPT from ?;";
|
||||
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ ext {
|
||||
springSecuritySamlVersion = "7.0.2"
|
||||
openSamlVersion = "5.2.1"
|
||||
commonmarkVersion = "0.27.1"
|
||||
googleJavaFormatVersion = "1.21.0"
|
||||
googleJavaFormatVersion = "1.35.0"
|
||||
logback = "1.5.32"
|
||||
junitPlatformVersion = "1.12.2"
|
||||
modernJavaVersion = 21
|
||||
@@ -78,7 +78,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.7.1'
|
||||
version = '2.7.3'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
@@ -204,7 +204,7 @@ subprojects {
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.github.pixee:java-security-toolkit:1.2.2'
|
||||
implementation 'io.github.pixee:java-security-toolkit:1.2.3'
|
||||
|
||||
//tmp for security bumps
|
||||
implementation "ch.qos.logback:logback-core:$logback"
|
||||
|
||||
@@ -25,11 +25,11 @@ COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
|
||||
ENV JAVA_TOOL_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
ENV JAVA_TOOL_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
|
||||
ENV JAVA_TOOL_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
ENV JAVA_TOOL_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
|
||||
@@ -271,11 +271,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -194,11 +194,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ COPY app/common/build.gradle app/common/
|
||||
COPY app/proprietary/build.gradle app/proprietary/
|
||||
|
||||
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
||||
./gradlew dependencies --no-daemon || true
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vite/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# LaTeX outputs
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.pdf
|
||||
*.tex
|
||||
!src/default_templates/*.tex
|
||||
data/
|
||||
output/
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1 @@
|
||||
3.13.8
|
||||
@@ -0,0 +1,63 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
# Install system deps: Node.js + Puppeteer/Chromium runtime libraries
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
poppler-utils \
|
||||
nodejs \
|
||||
npm \
|
||||
# Fonts for correct text rendering in generated PDFs
|
||||
fonts-liberation \
|
||||
fonts-dejavu-core \
|
||||
# Chromium headless runtime deps
|
||||
libnss3 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libx11-6 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libgbm1 \
|
||||
libdrm2 \
|
||||
libxshmfence1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node dependencies (Puppeteer) first for layer caching
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Copy Python source into /app/src/
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create output directories
|
||||
RUN mkdir -p /app/output /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Environment variables for Gunicorn configuration
|
||||
ENV GUNICORN_WORKERS=4
|
||||
ENV GUNICORN_TIMEOUT=120
|
||||
# Disable Python stdout/stderr buffering so log output appears immediately in Docker logs
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
WORKDIR /app/src
|
||||
CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:5001 --workers ${GUNICORN_WORKERS} --timeout ${GUNICORN_TIMEOUT} --worker-class gthread --threads 4 app:app"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
# Install system deps: Node.js + Puppeteer/Chromium runtime libraries
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
poppler-utils \
|
||||
nodejs \
|
||||
npm \
|
||||
# Fonts for correct text rendering in generated PDFs
|
||||
fonts-liberation \
|
||||
fonts-dejavu-core \
|
||||
# Chromium headless runtime deps
|
||||
libnss3 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libx11-6 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node dependencies (Puppeteer) first for layer caching
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy only pyproject.toml and uv.lock first for dependency caching
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies including dev extras
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
|
||||
# Create output directories
|
||||
RUN mkdir -p /app/output /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Set environment for Flask development
|
||||
ENV FLASK_ENV=development
|
||||
ENV FLASK_DEBUG=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Run the Flask app with hot reload
|
||||
# Note: src/ will be mounted as a volume at runtime
|
||||
WORKDIR /app/src
|
||||
CMD ["uv", "run", "python", "app.py"]
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,87 @@
|
||||
.PHONY: help install prep check fix lint lint-fix lint-fix-unsafe format format-check typecheck test run run-dev clean tool-models docker-build docker-run
|
||||
|
||||
REPO_ROOT_DIR = ..
|
||||
ROOT_DIR = .
|
||||
VENV_DIR = $(ROOT_DIR)/.venv
|
||||
DEPS_STAMP := $(VENV_DIR)/.deps-installed
|
||||
NODE_DEPS_STAMP := node_modules/.deps-installed
|
||||
TOOL_MODELS := $(CURDIR)/src/models/tool_models.py
|
||||
FRONTEND_DIR := $(REPO_ROOT_DIR)/frontend
|
||||
FRONTEND_TSX := $(FRONTEND_DIR)/node_modules/.bin/tsx
|
||||
|
||||
$(DEPS_STAMP): $(ROOT_DIR)/uv.lock $(ROOT_DIR)/pyproject.toml
|
||||
uv python install 3.13.8
|
||||
uv sync
|
||||
touch $(DEPS_STAMP)
|
||||
|
||||
$(NODE_DEPS_STAMP): package-lock.json
|
||||
npm ci
|
||||
touch $(NODE_DEPS_STAMP)
|
||||
|
||||
help:
|
||||
@echo "Engine commands:"
|
||||
@echo " make install - Install production dependencies"
|
||||
@echo " make prep - Set up .env file from .env.example"
|
||||
@echo " make lint - Run linting checks"
|
||||
@echo " make format - Format code"
|
||||
@echo " make format-check - Show whether code is correctly formatted"
|
||||
@echo " make typecheck - Run type checking"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make run - Run the Flask backend with gunicorn (production)"
|
||||
@echo " make run-dev - Run the Flask backend with dev server (development only)"
|
||||
@echo " make tool-models - Generate src/models/tool_models_*.py from frontend TypeScript tool defs"
|
||||
@echo " make clean - Clean up generated files"
|
||||
@echo " make docker-build - Build Docker image"
|
||||
@echo " make docker-run - Run Docker container"
|
||||
|
||||
install: $(DEPS_STAMP) $(NODE_DEPS_STAMP)
|
||||
|
||||
prep: install
|
||||
uv run scripts/setup_env.py
|
||||
|
||||
check: typecheck lint format-check test
|
||||
|
||||
fix: lint-fix format
|
||||
|
||||
lint: install
|
||||
uv run ruff check .
|
||||
|
||||
lint-fix: install
|
||||
uv run ruff check . --fix
|
||||
|
||||
lint-fix-unsafe: install
|
||||
uv run ruff check . --fix --unsafe-fixes
|
||||
|
||||
format: install
|
||||
uv run ruff format .
|
||||
|
||||
format-check: install
|
||||
uv run ruff format . --diff
|
||||
|
||||
typecheck: install
|
||||
uv run pyright . --warnings
|
||||
|
||||
test: prep
|
||||
uv run pytest tests
|
||||
|
||||
run: prep
|
||||
cd src && PYTHONUNBUFFERED=1 uv run gunicorn --bind 0.0.0.0:5001 --workers 4 --timeout 120 --worker-class gthread --threads 4 app:app
|
||||
|
||||
run-dev: prep
|
||||
cd src && PYTHONUNBUFFERED=1 uv run python app.py
|
||||
|
||||
frontend-deps:
|
||||
if [ ! -x "$(FRONTEND_TSX)" ]; then cd $(FRONTEND_DIR) && npm install; fi
|
||||
|
||||
tool-models: install frontend-deps
|
||||
uv run python scripts/generate_tool_models.py --output $(TOOL_MODELS)
|
||||
$(MAKE) fix
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV_DIR) data logs output
|
||||
|
||||
docker-build:
|
||||
docker build -t stirling-pdf-engine .
|
||||
|
||||
docker-run:
|
||||
docker run -p 5001:5001 stirling-pdf-engine
|
||||
@@ -0,0 +1,51 @@
|
||||
# Claude API (Anthropic)
|
||||
STIRLING_ANTHROPIC_API_KEY=
|
||||
|
||||
# OpenAI API (Optional - for GPT models)
|
||||
STIRLING_OPENAI_API_KEY=
|
||||
STIRLING_OPENAI_BASE_URL=
|
||||
|
||||
# Model Configuration
|
||||
STIRLING_SMART_MODEL=claude-sonnet-4-5-20250929
|
||||
STIRLING_FAST_MODEL=claude-haiku-4-5-20251001
|
||||
|
||||
# GPT-5 Reasoning Settings (only used if SMART_MODEL is gpt-5)
|
||||
# Options: minimal, low, medium, high, xhigh
|
||||
STIRLING_SMART_MODEL_REASONING_EFFORT=medium
|
||||
STIRLING_FAST_MODEL_REASONING_EFFORT=minimal
|
||||
|
||||
# GPT-5 Text Verbosity (only used if SMART_MODEL is gpt-5)
|
||||
# Options: minimal, low, medium, high
|
||||
STIRLING_SMART_MODEL_TEXT_VERBOSITY=medium
|
||||
STIRLING_FAST_MODEL_TEXT_VERBOSITY=low
|
||||
|
||||
# Output token limits. STIRLING_AI_MAX_TOKENS is a global override (leave empty to use per-model defaults)
|
||||
STIRLING_AI_MAX_TOKENS=
|
||||
STIRLING_SMART_MODEL_MAX_TOKENS=8192
|
||||
STIRLING_FAST_MODEL_MAX_TOKENS=2048
|
||||
STIRLING_CLAUDE_MAX_TOKENS=4096
|
||||
STIRLING_DEFAULT_MODEL_MAX_TOKENS=4096
|
||||
|
||||
# PostHog Analytics
|
||||
STIRLING_POSTHOG_API_KEY=
|
||||
STIRLING_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Backend Configuration
|
||||
STIRLING_JAVA_BACKEND_URL=http://localhost:8080
|
||||
STIRLING_JAVA_BACKEND_API_KEY=123456789
|
||||
STIRLING_JAVA_REQUEST_TIMEOUT_SECONDS=30
|
||||
|
||||
# Logging and Debugging
|
||||
STIRLING_AI_RAW_DEBUG=0
|
||||
STIRLING_FLASK_DEBUG=0
|
||||
# Override the log file directory (defaults to ./logs in server mode, OS log dir in desktop mode)
|
||||
STIRLING_LOG_PATH=
|
||||
# Enable verbose table detection debug logging in the PDF text editor
|
||||
STIRLING_PDF_EDITOR_TABLE_DEBUG=0
|
||||
# Set to true when running as a Tauri desktop app (switches to OS-native log directory)
|
||||
STIRLING_PDF_TAURI_MODE=false
|
||||
|
||||
# AI Settings
|
||||
STIRLING_AI_STREAMING=true
|
||||
STIRLING_AI_PREVIEW_MAX_INFLIGHT=3
|
||||
STIRLING_AI_REQUEST_TIMEOUT=70
|
||||
Generated
+1138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "stirling-docgen",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Node tooling for Stirling PDF docgen backend (HTML→PDF via Puppeteer)",
|
||||
"scripts": {
|
||||
"postinstall": "node -e \"require('puppeteer')\" 2>/dev/null || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.25.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
[project]
|
||||
name = "engine"
|
||||
version = "0.1.0"
|
||||
description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-cors>=4.0.0",
|
||||
"gunicorn>=25.0.3",
|
||||
"openai>=2.20.0",
|
||||
"langchain-core>=1.2.11",
|
||||
"langchain-openai>=1.1.9",
|
||||
"langchain-anthropic>=1.1.9",
|
||||
"platformdirs>=4.5.1",
|
||||
"pypdf>=6.7.0",
|
||||
"pydantic>=2.0.0",
|
||||
"posthog>=7.8.6",
|
||||
"python-dotenv>=1.2.1",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.14.10",
|
||||
"pyright>=1.1.408",
|
||||
"datamodel-code-generator[ruff]>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
exclude = [
|
||||
"tests",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E",
|
||||
"F",
|
||||
"I",
|
||||
"N",
|
||||
"W",
|
||||
"RUF100",
|
||||
"UP",
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Temporarily disable line length limit until codebase conformat
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.13"
|
||||
|
||||
reportImportCycles = "warning"
|
||||
reportUnnecessaryCast = "warning"
|
||||
reportUnusedImport = "warning"
|
||||
#reportUnknownParameterType = "warning"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import keyword
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TOOL_MODELS_HEADER = """# AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
# Generated by scripts/generate_tool_models.py from frontend TypeScript sources.
|
||||
# ruff: noqa: N815
|
||||
"""
|
||||
|
||||
|
||||
OPERATION_TYPE_RE = re.compile(r"operationType\s*:\s*['\"]([A-Za-z0-9_]+)['\"]")
|
||||
DEFAULT_REF_RE = re.compile(r"defaultParameters\s*:\s*([A-Za-z0-9_]+)")
|
||||
DEFAULT_SHORTHAND_RE = re.compile(r"\bdefaultParameters\b")
|
||||
IMPORT_RE = re.compile(r"import\s*\{([^}]+)\}\s*from\s*['\"]([^'\"]+)['\"]")
|
||||
VAR_OBJ_RE_TEMPLATE = r"(?:export\s+)?const\s+{name}\b[^=]*=\s*\{{"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolModelSpec:
|
||||
tool_id: str
|
||||
params: dict[str, Any]
|
||||
param_types: dict[str, Any]
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _find_matching(text: str, start: int, open_char: str, close_char: str) -> int:
|
||||
depth = 0
|
||||
i = start
|
||||
in_str: str | None = None
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if ch == in_str:
|
||||
in_str = None
|
||||
i += 1
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
in_str = ch
|
||||
elif ch == open_char:
|
||||
depth += 1
|
||||
elif ch == close_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
raise ParseError(f"Unmatched {open_char}{close_char} block")
|
||||
|
||||
|
||||
def _extract_block(text: str, pattern: str) -> str | None:
|
||||
match = re.search(pattern, text)
|
||||
if not match:
|
||||
return None
|
||||
brace_start = text.find("{", match.end() - 1)
|
||||
if brace_start == -1:
|
||||
return None
|
||||
brace_end = _find_matching(text, brace_start, "{", "}")
|
||||
return text[brace_start : brace_end + 1]
|
||||
|
||||
|
||||
def _split_top_level_items(obj_body: str) -> list[str]:
|
||||
items: list[str] = []
|
||||
depth_obj = depth_arr = 0
|
||||
in_str: str | None = None
|
||||
token_start = 0
|
||||
i = 0
|
||||
while i < len(obj_body):
|
||||
ch = obj_body[i]
|
||||
if in_str:
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if ch == in_str:
|
||||
in_str = None
|
||||
i += 1
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
in_str = ch
|
||||
elif ch == "{":
|
||||
depth_obj += 1
|
||||
elif ch == "}":
|
||||
depth_obj -= 1
|
||||
elif ch == "[":
|
||||
depth_arr += 1
|
||||
elif ch == "]":
|
||||
depth_arr -= 1
|
||||
elif ch == "," and depth_obj == 0 and depth_arr == 0:
|
||||
piece = obj_body[token_start:i].strip()
|
||||
if piece:
|
||||
items.append(piece)
|
||||
token_start = i + 1
|
||||
i += 1
|
||||
tail = obj_body[token_start:].strip()
|
||||
if tail:
|
||||
items.append(tail)
|
||||
return items
|
||||
|
||||
|
||||
def _resolve_import_path(repo_root: Path, current_file: Path, module_path: str) -> Path | None:
|
||||
candidates: list[Path] = []
|
||||
if module_path.startswith("@app/"):
|
||||
rel = module_path[len("@app/") :]
|
||||
candidates.extend(
|
||||
[
|
||||
repo_root / "frontend/src/core" / f"{rel}.ts",
|
||||
repo_root / "frontend/src/core" / f"{rel}.tsx",
|
||||
repo_root / "frontend/src/saas" / f"{rel}.ts",
|
||||
repo_root / "frontend/src/saas" / f"{rel}.tsx",
|
||||
repo_root / "frontend/src" / f"{rel}.ts",
|
||||
repo_root / "frontend/src" / f"{rel}.tsx",
|
||||
]
|
||||
)
|
||||
elif module_path.startswith("."):
|
||||
base = (current_file.parent / module_path).resolve()
|
||||
candidates.extend([Path(f"{base}.ts"), Path(f"{base}.tsx")])
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _parse_literal_value(value: str, resolver: Callable[[str], dict[str, Any] | None]) -> Any:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if value.startswith("{") and value.endswith("}"):
|
||||
return _parse_object_literal(value, resolver)
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [_parse_literal_value(item, resolver) for item in _split_top_level_items(inner)]
|
||||
if value.startswith(("'", '"')) and value.endswith(("'", '"')):
|
||||
return value[1:-1]
|
||||
if value in {"true", "false"}:
|
||||
return value == "true"
|
||||
if value == "null":
|
||||
return None
|
||||
if re.fullmatch(r"-?\d+", value):
|
||||
return int(value)
|
||||
if re.fullmatch(r"-?\d+\.\d+", value):
|
||||
return float(value)
|
||||
resolved = resolver(value)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _parse_object_literal(obj_text: str, resolver: Callable[[str], dict[str, Any] | None]) -> dict[str, Any]:
|
||||
body = obj_text.strip()[1:-1]
|
||||
result: dict[str, Any] = {}
|
||||
for item in _split_top_level_items(body):
|
||||
if item.startswith("..."):
|
||||
spread_name = item[3:].strip()
|
||||
spread = resolver(spread_name)
|
||||
if isinstance(spread, dict):
|
||||
result.update(spread)
|
||||
continue
|
||||
if ":" not in item:
|
||||
continue
|
||||
key, raw_value = item.split(":", 1)
|
||||
key = key.strip().strip("'\"")
|
||||
result[key] = _parse_literal_value(raw_value.strip(), resolver)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_imports(source: str) -> dict[str, str]:
|
||||
imports: dict[str, str] = {}
|
||||
for names, module_path in IMPORT_RE.findall(source):
|
||||
for part in names.split(","):
|
||||
segment = part.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if " as " in segment:
|
||||
original, alias = [x.strip() for x in segment.split(" as ", 1)]
|
||||
imports[alias] = module_path
|
||||
imports[original] = module_path
|
||||
else:
|
||||
imports[segment] = module_path
|
||||
return imports
|
||||
|
||||
|
||||
def _resolve_object_identifier(repo_root: Path, file_path: Path, source: str, identifier: str) -> dict[str, Any] | None:
|
||||
var_pattern = VAR_OBJ_RE_TEMPLATE.format(name=re.escape(identifier))
|
||||
block = _extract_block(source, var_pattern)
|
||||
imports = _extract_imports(source)
|
||||
|
||||
def resolver(name: str) -> dict[str, Any] | None:
|
||||
local_block = _extract_block(source, VAR_OBJ_RE_TEMPLATE.format(name=re.escape(name)))
|
||||
if local_block:
|
||||
return _parse_object_literal(local_block, resolver)
|
||||
import_path = imports.get(name)
|
||||
if not import_path:
|
||||
return None
|
||||
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
|
||||
if not resolved_file:
|
||||
return None
|
||||
imported_source = resolved_file.read_text(encoding="utf-8")
|
||||
return _resolve_object_identifier(repo_root, resolved_file, imported_source, name)
|
||||
|
||||
if block:
|
||||
return _parse_object_literal(block, resolver)
|
||||
import_path = imports.get(identifier)
|
||||
if not import_path:
|
||||
return None
|
||||
resolved_file = _resolve_import_path(repo_root, file_path, import_path)
|
||||
if not resolved_file:
|
||||
return None
|
||||
imported_source = resolved_file.read_text(encoding="utf-8")
|
||||
return _resolve_object_identifier(repo_root, resolved_file, imported_source, identifier)
|
||||
|
||||
|
||||
def _infer_py_type(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
if isinstance(value, str):
|
||||
return "str"
|
||||
if isinstance(value, list):
|
||||
return "list[Any]"
|
||||
if isinstance(value, dict):
|
||||
return "dict[str, Any]"
|
||||
return "Any"
|
||||
|
||||
|
||||
def _spec_is_none(spec: dict[str, Any]) -> bool:
|
||||
return spec.get("kind") == "null"
|
||||
|
||||
|
||||
def _py_type_from_spec(spec: dict[str, Any]) -> str:
|
||||
kind = spec.get("kind")
|
||||
if kind == "string":
|
||||
return "str"
|
||||
if kind == "number":
|
||||
return "float"
|
||||
if kind == "boolean":
|
||||
return "bool"
|
||||
if kind == "date":
|
||||
return "str"
|
||||
if kind == "enum":
|
||||
values = spec.get("values")
|
||||
if isinstance(values, list) and values:
|
||||
literal_values = ", ".join(_py_repr(v) for v in values)
|
||||
return f"Literal[{literal_values}]"
|
||||
if kind == "ref":
|
||||
ref_name = spec.get("name")
|
||||
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
|
||||
return f"{ref_name[:-10]}Params"
|
||||
if kind == "array":
|
||||
element = spec.get("element")
|
||||
inner = _py_type_from_spec(element) if isinstance(element, dict) else "Any"
|
||||
return f"list[{inner}]"
|
||||
if kind == "object":
|
||||
dict_value = spec.get("dictValue")
|
||||
if isinstance(dict_value, dict):
|
||||
inner = _py_type_from_spec(dict_value)
|
||||
return f"dict[str, {inner}]"
|
||||
properties = spec.get("properties")
|
||||
if isinstance(properties, dict) and properties:
|
||||
property_types = {_py_type_from_spec(p) for p in properties.values() if isinstance(p, dict)}
|
||||
if len(property_types) == 1:
|
||||
inner = next(iter(property_types))
|
||||
return f"dict[str, {inner}]"
|
||||
return "dict[str, Any]"
|
||||
if kind in {"null"}:
|
||||
return "Any"
|
||||
return "Any"
|
||||
|
||||
|
||||
def _to_class_name(tool_id: str) -> str:
|
||||
cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", tool_id)
|
||||
cleaned = re.sub(r"[^A-Za-z0-9]+", " ", cleaned)
|
||||
parts = [part.capitalize() for part in cleaned.split() if part]
|
||||
return "".join(parts) + "Params"
|
||||
|
||||
|
||||
def _to_snake_case(name: str) -> str:
|
||||
snake = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
snake = re.sub(r"[^A-Za-z0-9]+", "_", snake).strip("_").lower()
|
||||
if not snake:
|
||||
snake = "param"
|
||||
if snake[0].isdigit():
|
||||
snake = f"param_{snake}"
|
||||
if keyword.iskeyword(snake):
|
||||
snake = f"{snake}_"
|
||||
return snake
|
||||
|
||||
|
||||
def _build_field_name_map(params: dict[str, Any]) -> dict[str, str]:
|
||||
field_map: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for original_key in sorted(params):
|
||||
base_name = _to_snake_case(original_key)
|
||||
candidate = base_name
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{base_name}_{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
field_map[original_key] = candidate
|
||||
return field_map
|
||||
|
||||
|
||||
def _to_enum_member_name(tool_id: str) -> str:
|
||||
return _to_snake_case(tool_id).upper()
|
||||
|
||||
|
||||
def _build_enum_member_map(specs: list[ToolModelSpec]) -> dict[str, str]:
|
||||
member_map: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for spec in specs:
|
||||
base_name = _to_enum_member_name(spec.tool_id)
|
||||
candidate = base_name
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{base_name}_{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
member_map[spec.tool_id] = candidate
|
||||
return member_map
|
||||
|
||||
|
||||
def _py_repr(value: Any) -> str:
|
||||
return (
|
||||
json.dumps(value, ensure_ascii=True).replace("true", "True").replace("false", "False").replace("null", "None")
|
||||
)
|
||||
|
||||
|
||||
def discover_tool_specs(repo_root: Path) -> list[ToolModelSpec]:
|
||||
frontend_dir = repo_root / "frontend"
|
||||
extractor = frontend_dir / "scripts/export-tool-specs.ts"
|
||||
command = ["node", "--import", "tsx", str(extractor)]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(frontend_dir),
|
||||
)
|
||||
raw = json.loads(result.stdout)
|
||||
specs: list[ToolModelSpec] = []
|
||||
for item in raw:
|
||||
tool_id = item.get("tool_id")
|
||||
if not isinstance(tool_id, str) or not tool_id:
|
||||
continue
|
||||
params = item.get("params")
|
||||
param_types = item.get("param_types")
|
||||
specs.append(
|
||||
ToolModelSpec(
|
||||
tool_id=tool_id,
|
||||
params=params if isinstance(params, dict) else {},
|
||||
param_types=param_types if isinstance(param_types, dict) else {},
|
||||
)
|
||||
)
|
||||
return sorted(specs, key=lambda spec: spec.tool_id)
|
||||
|
||||
|
||||
def write_models_module(out_path: Path, specs: list[ToolModelSpec]) -> None:
|
||||
lines: list[str] = [
|
||||
TOOL_MODELS_HEADER,
|
||||
"from __future__ import annotations\n\n",
|
||||
"from enum import StrEnum\n",
|
||||
"from typing import Any, Literal\n\n",
|
||||
"from models.base import ApiModel\n",
|
||||
]
|
||||
|
||||
class_names: dict[str, str] = {spec.tool_id: _to_class_name(spec.tool_id) for spec in specs}
|
||||
class_name_to_tool_id = {name: tool_id for tool_id, name in class_names.items()}
|
||||
|
||||
def extract_class_dependencies(spec: ToolModelSpec) -> set[str]:
|
||||
deps: set[str] = set()
|
||||
if not isinstance(spec.param_types, dict):
|
||||
return deps
|
||||
for entry in spec.param_types.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
type_spec = entry
|
||||
if "type" in entry and isinstance(entry.get("type"), dict):
|
||||
type_spec = entry["type"]
|
||||
if not isinstance(type_spec, dict):
|
||||
continue
|
||||
if type_spec.get("kind") != "ref":
|
||||
continue
|
||||
ref_name = type_spec.get("name")
|
||||
if isinstance(ref_name, str) and ref_name.endswith("Parameters"):
|
||||
ref_class = f"{ref_name[:-10]}Params"
|
||||
if ref_class in class_name_to_tool_id:
|
||||
deps.add(ref_class)
|
||||
return deps
|
||||
|
||||
dependencies_by_class: dict[str, set[str]] = {}
|
||||
for spec in specs:
|
||||
class_name = class_names[spec.tool_id]
|
||||
dependencies_by_class[class_name] = extract_class_dependencies(spec)
|
||||
|
||||
remaining = set(class_names.values())
|
||||
ordered_class_names: list[str] = []
|
||||
while remaining:
|
||||
progress = False
|
||||
for class_name in sorted(remaining):
|
||||
deps = dependencies_by_class.get(class_name, set())
|
||||
if deps.issubset(set(ordered_class_names)):
|
||||
ordered_class_names.append(class_name)
|
||||
remaining.remove(class_name)
|
||||
progress = True
|
||||
break
|
||||
if not progress:
|
||||
ordered_class_names.extend(sorted(remaining))
|
||||
break
|
||||
|
||||
ordered_specs = [next(spec for spec in specs if class_names[spec.tool_id] == name) for name in ordered_class_names]
|
||||
|
||||
for spec in ordered_specs:
|
||||
class_name = class_names[spec.tool_id]
|
||||
lines.append(f"class {class_name}(ApiModel):\n")
|
||||
all_param_keys = set(spec.params)
|
||||
if isinstance(spec.param_types, dict):
|
||||
all_param_keys.update(spec.param_types.keys())
|
||||
|
||||
if not all_param_keys:
|
||||
lines.append(" pass\n\n\n")
|
||||
continue
|
||||
|
||||
field_name_map = _build_field_name_map({key: True for key in all_param_keys})
|
||||
for key in sorted(all_param_keys):
|
||||
field_name = field_name_map[key]
|
||||
value = spec.params.get(key)
|
||||
type_spec = spec.param_types.get(key) if isinstance(spec.param_types, dict) else None
|
||||
if isinstance(type_spec, dict):
|
||||
py_type = _py_type_from_spec(type_spec)
|
||||
else:
|
||||
py_type = _infer_py_type(value)
|
||||
|
||||
if value is None and (isinstance(type_spec, dict) and _spec_is_none(type_spec)):
|
||||
if py_type != "Any" and "| None" not in py_type:
|
||||
py_type = f"{py_type} | None"
|
||||
lines.append(f" {field_name}: {py_type} = None\n")
|
||||
elif value is None:
|
||||
lines.append(f" {field_name}: {py_type} | None = None\n")
|
||||
else:
|
||||
if isinstance(type_spec, dict) and type_spec.get("kind") == "ref" and isinstance(value, dict):
|
||||
lines.append(f" {field_name}: {py_type} = {py_type}.model_validate({_py_repr(value)})\n")
|
||||
continue
|
||||
lines.append(f" {field_name}: {py_type} = {_py_repr(value)}\n")
|
||||
lines.append("\n\n")
|
||||
|
||||
if class_names:
|
||||
union_members = " | ".join(class_names[tool_id] for tool_id in sorted(class_names))
|
||||
lines.append(f"type ParamToolModel = {union_members}\n")
|
||||
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
|
||||
else:
|
||||
lines.append("type ParamToolModel = ApiModel\n")
|
||||
lines.append("type ParamToolModelType = type[ParamToolModel]\n\n")
|
||||
|
||||
enum_member_map = _build_enum_member_map(specs)
|
||||
|
||||
lines.append("class OperationId(StrEnum):\n")
|
||||
|
||||
for spec in specs:
|
||||
lines.append(f" {enum_member_map[spec.tool_id]} = {spec.tool_id!r}\n")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"\n\n",
|
||||
"OPERATIONS: dict[OperationId, ParamToolModelType] = {\n",
|
||||
]
|
||||
)
|
||||
|
||||
for spec in specs:
|
||||
model_name = _to_class_name(spec.tool_id)
|
||||
lines.append(f" OperationId.{enum_member_map[spec.tool_id]}: {model_name},\n")
|
||||
lines.append("}\n")
|
||||
out_path.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate tool models from frontend TypeScript tool definitions")
|
||||
parser.add_argument("--spec", help="Deprecated (ignored)", default="")
|
||||
parser.add_argument("--output", default="", help="Path to tool_models.py")
|
||||
parser.add_argument("--ai-output", default="", help="Deprecated (ignored)")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
specs = discover_tool_specs(repo_root)
|
||||
|
||||
output_path = Path(args.output) if args.output else (repo_root / "docgen/backend/models/tool_models.py")
|
||||
|
||||
write_models_module(output_path, specs)
|
||||
print(f"Wrote {len(specs)} tool model specs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Copies .env from .env.example if missing, and errors if any keys from the example
|
||||
are absent from the actual .env file.
|
||||
|
||||
Usage:
|
||||
uv run scripts/setup_env.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
EXAMPLE_FILE = ROOT / "config" / ".env.example"
|
||||
ENV_FILE = ROOT / ".env"
|
||||
|
||||
print("setup-env: see engine/config/.env.example for documentation")
|
||||
|
||||
if not EXAMPLE_FILE.exists():
|
||||
print(f"setup-env: {EXAMPLE_FILE.name} not found, skipping", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
if not ENV_FILE.exists():
|
||||
shutil.copy(EXAMPLE_FILE, ENV_FILE)
|
||||
print("setup-env: created .env from .env.example")
|
||||
|
||||
env_keys = set(dotenv_values(ENV_FILE).keys()) | set(os.environ.keys())
|
||||
example_keys = set(dotenv_values(EXAMPLE_FILE).keys())
|
||||
missing = sorted(example_keys - env_keys)
|
||||
|
||||
if missing:
|
||||
sys.exit(
|
||||
"setup-env: .env is missing keys from .env.example:\n"
|
||||
+ "\n".join(f" {k}" for k in missing)
|
||||
+ "\n Add them manually or delete your local .env to re-copy from config/.env.example."
|
||||
)
|
||||
|
||||
extra = sorted(k for k in dotenv_values(ENV_FILE) if k.startswith("STIRLING_") and k not in example_keys)
|
||||
if extra:
|
||||
print(
|
||||
"setup-env: .env contains STIRLING_ keys not in config/.env.example:\n"
|
||||
+ "\n".join(f" {k}" for k in extra)
|
||||
+ "\n Add them to config/.env.example if they are intentional.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import models
|
||||
from config import SMART_MODEL, STREAMING_ENABLED
|
||||
from format_prompts import get_format_prompt
|
||||
from html_utils import inject_theme
|
||||
from llm_utils import run_ai, stream_ai
|
||||
from prompts import (
|
||||
field_values_system_prompt,
|
||||
html_context_messages,
|
||||
html_edit_system_prompt,
|
||||
html_system_prompt,
|
||||
outline_generator_system_prompt,
|
||||
section_draft_system_prompt,
|
||||
template_fill_html_system_prompt,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_outline_with_llm(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
constraints: models.Constraint | None = None,
|
||||
) -> models.OutlineResponse:
|
||||
"""
|
||||
Generate outline for a specific document type.
|
||||
|
||||
document_type should already be detected - this function only generates the outline.
|
||||
"""
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.tone
|
||||
audience = constraints.audience
|
||||
pages = constraints.page_count
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
|
||||
# Try to get a format-specific extraction prompt
|
||||
format_prompt, default_sections = get_format_prompt(document_type)
|
||||
system_prompt = outline_generator_system_prompt(document_type, constraint_text, format_prompt, default_sections)
|
||||
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=prompt),
|
||||
]
|
||||
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.OutlineResponse,
|
||||
tag="outline",
|
||||
)
|
||||
|
||||
if parsed:
|
||||
logger.info(
|
||||
"[OUTLINE] Generated outline doc_type=%s sections=%d filename=%s",
|
||||
parsed.doc_type,
|
||||
len(parsed.sections),
|
||||
parsed.outline_filename,
|
||||
)
|
||||
return parsed
|
||||
|
||||
raise RuntimeError("AI outline generation failed.")
|
||||
|
||||
|
||||
def generate_field_values(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
fields: list[dict[str, Any]],
|
||||
constraints: models.Constraint | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.tone
|
||||
audience = constraints.audience
|
||||
pages = constraints.page_count
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
system_prompt = field_values_system_prompt(constraint_text)
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||
models.ChatMessage(role="user", content=f"Prompt:\n{prompt}"),
|
||||
models.ChatMessage(role="user", content=f"Fields:\n{json.dumps(fields, ensure_ascii=True)}"),
|
||||
]
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.LLMFieldValuesResponse,
|
||||
tag="field_values",
|
||||
)
|
||||
if parsed:
|
||||
return [{"label": item.label, "value": item.value} for item in parsed.fields]
|
||||
|
||||
raise RuntimeError("AI field extraction failed.")
|
||||
|
||||
|
||||
def generate_section_draft(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
outline_text: str,
|
||||
constraints: models.Constraint | None = None,
|
||||
) -> list[models.DraftSection]:
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.tone
|
||||
audience = constraints.audience
|
||||
pages = constraints.page_count
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
system_prompt = section_draft_system_prompt(constraint_text)
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||
models.ChatMessage(role="user", content=f"Outline:\n{outline_text}"),
|
||||
models.ChatMessage(role="user", content=f"Prompt:\n{prompt}"),
|
||||
]
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.LLMDraftSectionsResponse,
|
||||
tag="section_draft",
|
||||
)
|
||||
return parsed.sections
|
||||
|
||||
|
||||
# ── HTML generation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_template_fill_html_stream(
|
||||
template_html: str,
|
||||
document_type: str,
|
||||
outline_text: str,
|
||||
draft_sections: list[models.DraftSection] | None = None,
|
||||
constraints: models.Constraint | None = None,
|
||||
theme: dict[str, str] | None = None,
|
||||
additional_instructions: str | None = None,
|
||||
has_logo: bool = False,
|
||||
):
|
||||
"""Fill an HTML template by replacing {{PLACEHOLDER}} tokens, streaming chunks."""
|
||||
constraints_text = ""
|
||||
if constraints:
|
||||
tone = constraints.tone
|
||||
audience = constraints.audience
|
||||
pages = constraints.page_count
|
||||
constraints_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
if draft_sections:
|
||||
constraints_text += "\nUse the section content to inform placeholder values."
|
||||
|
||||
# Inject theme before sending to AI if overrides are provided
|
||||
if theme:
|
||||
template_html = inject_theme(template_html, theme)
|
||||
|
||||
system_prompt = template_fill_html_system_prompt(constraints_text)
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=f"Document type: {document_type}"),
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=(
|
||||
"CRITICAL EXAMPLE — What to preserve vs. what to change:\n"
|
||||
"If template has:\n"
|
||||
" <div class='vendor-name'>{{VENDOR_NAME}}</div>\n"
|
||||
"You MUST output:\n"
|
||||
" <div class='vendor-name'>Acme Corporation</div> ← fill the token, keep the tag\n"
|
||||
"\n"
|
||||
"For multi-row content like {{LINEITEM_ROWS}}, generate full <tr>...</tr> HTML.\n"
|
||||
"Match ONLY the columns present in the template's <thead> — do NOT add extra columns.\n"
|
||||
"Example (4-column table: #, Description, Qty, Line Total):\n"
|
||||
" <tr><td>1</td><td>Office chairs</td><td class='num'>4</td><td class='num'>$480.00</td></tr>\n"
|
||||
"\n"
|
||||
"Keep ALL HTML tags, CSS, classes, and IDs EXACTLY as-is.\n"
|
||||
"Do NOT modify any <style> blocks, structural HTML, or class names."
|
||||
),
|
||||
),
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=f"Outline/context (data to fill into template):\n{outline_text}",
|
||||
),
|
||||
]
|
||||
if has_logo:
|
||||
messages.append(
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=(
|
||||
"LOGO:\n"
|
||||
"- A company logo is available.\n"
|
||||
"- Insert the exact placeholder text {{LOGO_BLOCK}} once where the logo should appear.\n"
|
||||
"- Do NOT create your own <img> for the logo; only place {{LOGO_BLOCK}}.\n"
|
||||
),
|
||||
)
|
||||
)
|
||||
if additional_instructions and additional_instructions.strip():
|
||||
messages.append(
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=(
|
||||
"ADDITIONAL INSTRUCTIONS (highest priority):\n"
|
||||
f"{additional_instructions.strip()}\n\n"
|
||||
"Apply these instructions while preserving the template structure."
|
||||
),
|
||||
)
|
||||
)
|
||||
messages.append(
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=(
|
||||
f"COMPLETE HTML TEMPLATE (copy ALL of this, replacing ONLY {{{{PLACEHOLDER}}}} tokens):\n\n{template_html}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if draft_sections:
|
||||
sections_payload = [section.model_dump(by_alias=True, exclude_none=True) for section in draft_sections]
|
||||
messages.append(
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=f"Section content (JSON):\n{json.dumps(sections_payload, ensure_ascii=True)}",
|
||||
)
|
||||
)
|
||||
|
||||
stream = stream_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
tag="html_template_fill_stream",
|
||||
log_label="html-template-fill-stream",
|
||||
)
|
||||
yield from stream
|
||||
if stream.error or not stream.chunks:
|
||||
raise RuntimeError(f"AI HTML template fill failed: {stream.error or 'no chunks streamed'}")
|
||||
|
||||
|
||||
def generate_html_with_llm(
|
||||
prompt: str,
|
||||
history: list[models.ChatMessage],
|
||||
document_type: str,
|
||||
template_hint: str | None = None,
|
||||
current_html: str | None = None,
|
||||
structured_brief: str | None = None,
|
||||
) -> str:
|
||||
"""Call the LLM for freeform HTML document generation."""
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=html_system_prompt(document_type, template_hint))
|
||||
]
|
||||
messages.extend(history)
|
||||
messages.extend(html_context_messages(template_hint, current_html, structured_brief))
|
||||
messages.append(models.ChatMessage(role="user", content=prompt))
|
||||
|
||||
logger.info(
|
||||
"[AI] HTML generation model=%s doc_type=%s template_hint=%s",
|
||||
SMART_MODEL,
|
||||
document_type,
|
||||
"yes" if template_hint else "no",
|
||||
)
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.HtmlResponse,
|
||||
tag="html_generate",
|
||||
)
|
||||
if parsed and parsed.html and parsed.html.strip():
|
||||
logger.info("[AI] Generated HTML preview (first 500 chars):\n%s", parsed.html[:500])
|
||||
return parsed.html
|
||||
|
||||
raise RuntimeError("AI HTML generation failed or returned empty output.")
|
||||
|
||||
|
||||
def generate_html_with_llm_stream(
|
||||
prompt: str,
|
||||
history: list[models.ChatMessage],
|
||||
document_type: str,
|
||||
template_hint: str | None = None,
|
||||
current_html: str | None = None,
|
||||
structured_brief: str | None = None,
|
||||
):
|
||||
"""Stream HTML generation from LLM, yielding chunks as they arrive."""
|
||||
if not STREAMING_ENABLED:
|
||||
full_html = generate_html_with_llm(
|
||||
prompt,
|
||||
history,
|
||||
document_type,
|
||||
template_hint,
|
||||
current_html,
|
||||
structured_brief,
|
||||
)
|
||||
yield full_html
|
||||
return
|
||||
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=html_system_prompt(document_type, template_hint))
|
||||
]
|
||||
messages.extend(history)
|
||||
messages.extend(html_context_messages(template_hint, current_html, structured_brief))
|
||||
messages.append(models.ChatMessage(role="user", content=prompt))
|
||||
|
||||
logger.info(
|
||||
"[AI] Streaming HTML model=%s doc_type=%s template_hint=%s",
|
||||
SMART_MODEL,
|
||||
document_type,
|
||||
"yes" if template_hint else "no",
|
||||
)
|
||||
stream = stream_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
tag="html_stream",
|
||||
log_label="html-stream",
|
||||
)
|
||||
yield from stream
|
||||
|
||||
if stream.error or not stream.chunks:
|
||||
raise RuntimeError(f"AI HTML streaming failed: {stream.error or 'no chunks produced'}")
|
||||
|
||||
|
||||
def edit_html_with_llm_stream(
|
||||
*,
|
||||
document_type: str,
|
||||
base_html: str,
|
||||
instructions: str,
|
||||
):
|
||||
"""Stream an updated full HTML document by editing an existing HTML input."""
|
||||
if not base_html or not base_html.strip():
|
||||
raise RuntimeError("Missing base_html for HTML revision")
|
||||
if not instructions or not instructions.strip():
|
||||
raise RuntimeError("Missing instructions for HTML revision")
|
||||
|
||||
system_prompt = html_edit_system_prompt(document_type)
|
||||
messages: list[models.ChatMessage] = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=f"CURRENT_HTML (you MUST preserve everything not explicitly requested):\n\n{base_html}",
|
||||
),
|
||||
]
|
||||
|
||||
messages.append(
|
||||
models.ChatMessage(
|
||||
role="user",
|
||||
content=f"INSTRUCTIONS:\n{instructions}\n\nReturn ONLY the full updated HTML document.",
|
||||
)
|
||||
)
|
||||
|
||||
stream = stream_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
tag="html_edit_stream",
|
||||
log_label="html-edit-stream",
|
||||
)
|
||||
yield from stream
|
||||
|
||||
if stream.error or not stream.chunks:
|
||||
raise RuntimeError(f"AI HTML edit streaming failed: {stream.error or 'no chunks produced'}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"generate_field_values",
|
||||
"generate_html_with_llm",
|
||||
"generate_html_with_llm_stream",
|
||||
"generate_outline_with_llm",
|
||||
"generate_section_draft",
|
||||
"generate_template_fill_html_stream",
|
||||
"edit_html_with_llm_stream",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
PostHog analytics tracking for Stirling PDF AI document generation.
|
||||
|
||||
Tracks LLM usage, costs, latency, and document generation metrics for client insights.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from config import (
|
||||
FAST_MODEL,
|
||||
FAST_MODEL_REASONING_EFFORT,
|
||||
FAST_MODEL_TEXT_VERBOSITY,
|
||||
POSTHOG_CLIENT,
|
||||
SMART_MODEL,
|
||||
SMART_MODEL_REASONING_EFFORT,
|
||||
SMART_MODEL_TEXT_VERBOSITY,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def track_event(
|
||||
user_id: str | None,
|
||||
event_name: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
include_model_settings: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Track a generic event to PostHog.
|
||||
|
||||
Args:
|
||||
user_id: User identifier (can be anonymous ID or user email)
|
||||
event_name: Name of the event (e.g., "document_generated")
|
||||
properties: Additional event properties
|
||||
include_model_settings: Include GPT-5 reasoning/verbosity settings in properties
|
||||
"""
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
event_props = properties or {}
|
||||
|
||||
# Add model configuration settings for performance tracking
|
||||
if include_model_settings:
|
||||
event_props.update(
|
||||
{
|
||||
"smart_model": SMART_MODEL,
|
||||
"fast_model": FAST_MODEL,
|
||||
"smart_reasoning_effort": SMART_MODEL_REASONING_EFFORT,
|
||||
"smart_text_verbosity": SMART_MODEL_TEXT_VERBOSITY,
|
||||
"fast_reasoning_effort": FAST_MODEL_REASONING_EFFORT,
|
||||
"fast_text_verbosity": FAST_MODEL_TEXT_VERBOSITY,
|
||||
}
|
||||
)
|
||||
|
||||
POSTHOG_CLIENT.capture(
|
||||
distinct_id=user_id,
|
||||
event=event_name,
|
||||
properties=event_props,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to track PostHog event %s: %s", event_name, exc)
|
||||
|
||||
|
||||
def track_session_created(
|
||||
user_id: str | None,
|
||||
session_id: str,
|
||||
doc_type: str,
|
||||
template_id: str | None = None,
|
||||
has_template: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Track when a new AI document generation session is created.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
session_id: Session ID
|
||||
doc_type: Document type
|
||||
template_id: Template ID if using a template
|
||||
has_template: Whether user provided a custom template
|
||||
"""
|
||||
properties = {
|
||||
"session_id": session_id,
|
||||
"doc_type": doc_type,
|
||||
"has_template": has_template,
|
||||
}
|
||||
if template_id:
|
||||
properties["template_id"] = template_id
|
||||
|
||||
# Include model settings to track performance across different configurations
|
||||
track_event(user_id, "session_created", properties, include_model_settings=True)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Gracefully shutdown PostHog client and flush pending events."""
|
||||
try:
|
||||
POSTHOG_CLIENT.shutdown()
|
||||
logger.info("PostHog client shutdown completed")
|
||||
except Exception as exc:
|
||||
logger.warning("Error during PostHog shutdown: %s", exc)
|
||||
@@ -0,0 +1,819 @@
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from flask import Flask, Response, jsonify, request, send_file, send_from_directory, stream_with_context
|
||||
from flask_cors import CORS
|
||||
from pydantic import BaseModel
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.security import safe_join
|
||||
|
||||
import analytics
|
||||
import models
|
||||
from ai_generation import generate_field_values
|
||||
from briefs import _preprocess_intent
|
||||
from chat_router import classify_chat_route
|
||||
from config import (
|
||||
ASSETS_DIR,
|
||||
FAST_MODEL,
|
||||
FLASK_DEBUG,
|
||||
JAVA_BACKEND_API_KEY,
|
||||
JAVA_REQUEST_TIMEOUT_SECONDS,
|
||||
OUTPUT_DIR,
|
||||
SMART_MODEL,
|
||||
logger,
|
||||
model_max_tokens,
|
||||
)
|
||||
from document_types import detect_document_type
|
||||
from editing import register_edit_routes
|
||||
from editing.decisions import answer_conversational_info
|
||||
from file_processing_agent import ToolCatalogService
|
||||
from html_pdf_utils import compile_html_to_pdf
|
||||
from html_utils import inject_theme
|
||||
from java_client import java_headers, java_url
|
||||
from llm_utils import run_ai
|
||||
from pdf_generator import _DEFAULT_TEMPLATES_DIR, PDFGenerator
|
||||
from pdf_text_editor import convert_pdf_to_text_editor_document
|
||||
from prompts import (
|
||||
generate_all_sections_system_prompt,
|
||||
pdf_qa_system_prompt,
|
||||
section_fill_system_prompt,
|
||||
)
|
||||
from smart_folder_creator import create_smart_folder_config
|
||||
from storage import load_versions
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
_tool_catalog_service = ToolCatalogService()
|
||||
register_edit_routes(app)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_job_request_sequence() -> None:
|
||||
job_id = request.headers.get("X-Job-Id")
|
||||
if not job_id:
|
||||
return
|
||||
seq = request.headers.get("X-Job-Seq", "?")
|
||||
total = request.headers.get("X-Job-Total", "?")
|
||||
logger.info("[HTTP] job_id=%s req=%s/%s %s %s", job_id, seq, total, request.method, request.path)
|
||||
|
||||
|
||||
def _json_body[T: BaseModel](model: type[T], request_type: Literal["GET", "POST"] = "POST") -> T:
|
||||
if request_type == "GET":
|
||||
payload = request.args.to_dict()
|
||||
else:
|
||||
payload = request.get_json(silent=True)
|
||||
return model.model_validate(payload or {})
|
||||
|
||||
|
||||
def _java_request_json[T: BaseModel](
|
||||
method: str,
|
||||
path: str,
|
||||
payload: BaseModel | None,
|
||||
response_model: type[T],
|
||||
) -> T:
|
||||
url = java_url(path)
|
||||
data = None
|
||||
headers = java_headers()
|
||||
headers["Content-Type"] = "application/json"
|
||||
if payload is not None:
|
||||
payload_data = payload.model_dump(by_alias=True, exclude_none=True)
|
||||
data = json.dumps(payload_data).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=JAVA_REQUEST_TIMEOUT_SECONDS) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
parsed = json.loads(body) if body else {}
|
||||
return response_model.model_validate(parsed)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8") if exc.fp else ""
|
||||
logger.error("[JAVA] %s %s failed status=%s detail=%s", method, path, exc.code, detail)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_ai_session(session_id: str):
|
||||
auth_header = request.headers.get("Authorization")
|
||||
api_key = request.headers.get("X-API-KEY")
|
||||
if auth_header or api_key:
|
||||
path = f"/api/v1/ai/create/sessions/{session_id}"
|
||||
elif JAVA_BACKEND_API_KEY:
|
||||
path = f"/api/v1/ai/create/internal/sessions/{session_id}"
|
||||
else:
|
||||
path = f"/api/v1/ai/create/sessions/{session_id}"
|
||||
return _java_request_json("GET", path, None, models.AISession)
|
||||
|
||||
|
||||
def _update_ai_session(session_id: str, payload: models.JavaUpdateSessionRequest):
|
||||
return _java_request_json(
|
||||
"POST", f"/api/v1/ai/create/internal/sessions/{session_id}/update", payload, models.AISession
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/intent/check", methods=["POST"])
|
||||
def intent_check():
|
||||
payload = _json_body(models.IntentCheckRequest)
|
||||
prompt = payload.prompt
|
||||
history = payload.conversation_history
|
||||
current_pdf_url = payload.current_pdf_url
|
||||
intent = _preprocess_intent(prompt, history, bool(current_pdf_url))
|
||||
response = intent.model_copy(update={"doc_type": intent.document_type, "has_pdf": bool(current_pdf_url)})
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/chat/route", methods=["POST"])
|
||||
def chat_route():
|
||||
payload = _json_body(models.ChatRouteRequest)
|
||||
decision = classify_chat_route(payload)
|
||||
return jsonify(decision.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/chat/create-smart-folder", methods=["POST"])
|
||||
def create_smart_folder():
|
||||
payload = _json_body(models.SmartFolderCreateRequest)
|
||||
result = create_smart_folder_config(payload)
|
||||
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/chat/interpret-parameter", methods=["POST"])
|
||||
def interpret_parameter():
|
||||
"""Use AI to interpret an ambiguous parameter response during Tier 2 workflow collection."""
|
||||
payload = _json_body(models.InterpretParameterRequest)
|
||||
system_prompt = (
|
||||
"You are a parameter extraction assistant for a PDF tool workflow.\n"
|
||||
"Given a question asked to the user and the user's response, decide how to interpret it.\n\n"
|
||||
"Return JSON with these fields:\n"
|
||||
'- "type": one of "value", "confused", "cancel", "default"\n'
|
||||
' - "value": the user gave a usable answer, extract it cleanly\n'
|
||||
' - "confused": the user is asking for help or does not understand\n'
|
||||
' - "cancel": the user wants to stop or cancel\n'
|
||||
' - "default": the user is vague; suggest the most sensible default\n'
|
||||
'- "extracted_value": the clean extracted value (only for type="value" or type="default")\n'
|
||||
'- "help_message": a short helpful message explaining what was interpreted or what options exist\n\n'
|
||||
'Be concise. For type="value", extracted_value should be just the raw value (e.g. "English", "3", "DOCX").\n'
|
||||
'For type="default", extracted_value should be the suggested default value.'
|
||||
)
|
||||
user_prompt = (
|
||||
f"Tool: {payload.tool_name}\nQuestion asked: {payload.question}\nUser response: {payload.user_response}"
|
||||
)
|
||||
messages = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=user_prompt),
|
||||
]
|
||||
result = run_ai(
|
||||
FAST_MODEL,
|
||||
messages,
|
||||
models.InterpretParameterResponse,
|
||||
tag="interpret_parameter",
|
||||
log_label="interpret-parameter",
|
||||
)
|
||||
return jsonify(result.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/chat/infer-tools", methods=["POST"])
|
||||
def infer_tools():
|
||||
"""Use AI to infer which PDF tools to apply from a freeform user message."""
|
||||
payload = _json_body(models.InferToolsRequest)
|
||||
tools_list = "\n".join(f"- {t}" for t in payload.available_tools)
|
||||
system_prompt = (
|
||||
"You are a tool selection assistant for Stirling PDF.\n"
|
||||
"Given a user message, identify which PDF tools to apply to achieve the user's goal.\n"
|
||||
"Return tools in the execution order.\n\n"
|
||||
"Rules:\n"
|
||||
"- Only return tools with HIGH confidence — leave out anything uncertain\n"
|
||||
"- Return empty list if the request is a question, ambiguous, or conversational\n"
|
||||
"- Return empty list if the request is about creating a new document\n"
|
||||
"- Maximum 4 tools\n\n"
|
||||
f"Available tools (format: 'id: Display Name'):\n{tools_list}\n\n"
|
||||
'Return JSON: { "tools": [{"tool_id": "...", "confidence": "high"|"medium"|"low"}], "reason": "..." }\n'
|
||||
'IMPORTANT: tool_id must be the exact identifier before the colon (e.g. "convert", not "convert: Convert PDF").'
|
||||
)
|
||||
messages_list = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=f"User message: {payload.message}"),
|
||||
]
|
||||
result = run_ai(
|
||||
FAST_MODEL,
|
||||
messages_list,
|
||||
models.InferToolsResponse,
|
||||
tag="infer_tools",
|
||||
log_label="infer-tools",
|
||||
)
|
||||
filtered = models.InferToolsResponse(
|
||||
tools=[t for t in result.tools if t.confidence == "high"],
|
||||
reason=result.reason,
|
||||
)
|
||||
return jsonify(filtered.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/chat/info", methods=["POST"])
|
||||
def chat_info():
|
||||
"""Handle conversational queries without requiring a file or session."""
|
||||
payload = _json_body(models.ChatInfoRequest)
|
||||
tool_catalog = ToolCatalogService()
|
||||
assistant_message = answer_conversational_info(
|
||||
payload.message,
|
||||
payload.history,
|
||||
tool_catalog,
|
||||
)
|
||||
response = models.ChatInfoResponse(assistant_message=assistant_message)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/detect_type", methods=["POST"])
|
||||
def detect_type():
|
||||
"""
|
||||
Detect document type from a prompt using a fast AI model.
|
||||
|
||||
This endpoint uses a cheap/fast model (FAST_MODEL) for AI classification
|
||||
to minimize cost and latency.
|
||||
|
||||
Request body:
|
||||
- prompt: The user's document request
|
||||
- explicitType: If provided, skip detection and return this type
|
||||
|
||||
Response:
|
||||
- docType: The detected type
|
||||
- confidence: "medium" (AI), "low" (AI)
|
||||
- method: "explicit" or "ai"
|
||||
"""
|
||||
payload = _json_body(models.DetectTypeRequest)
|
||||
prompt = payload.prompt
|
||||
explicit_type = payload.explicit_type or ""
|
||||
|
||||
# If user explicitly provided a type, skip detection
|
||||
if explicit_type and explicit_type not in ("other", "document", "miscellaneous", ""):
|
||||
logger.info("[DETECT] Using explicit doc_type=%s, skipping detection", explicit_type)
|
||||
response = models.DetectTypeResponse(doc_type=explicit_type, confidence="high", method="explicit")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
if not prompt:
|
||||
response = models.DetectTypeResponse(error="Missing prompt for detection")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||
|
||||
# Use fast AI detection (uses FAST_MODEL for cheap classification)
|
||||
ai_type, confidence = detect_document_type(prompt, confidence_threshold=0.7)
|
||||
logger.info("[DETECT] Fast AI detected doc_type=%s (confidence=%.2f)", ai_type, confidence)
|
||||
response = models.DetectTypeResponse(
|
||||
doc_type=ai_type or "other",
|
||||
confidence="medium" if confidence >= 0.7 else "low",
|
||||
method="ai",
|
||||
)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/pdf/answer", methods=["POST"])
|
||||
def pdf_answer():
|
||||
payload = _json_body(models.PdfAnswerRequest)
|
||||
pdf_url = payload.pdf_url
|
||||
question = payload.question
|
||||
if not pdf_url or not question:
|
||||
response = models.PdfAnswerResponse(error="Missing pdfUrl or question")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||
|
||||
filename = os.path.basename(pdf_url.split("?")[0])
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
response = models.PdfAnswerResponse(error="Invalid pdf file")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||
|
||||
pdf_path = safe_join(OUTPUT_DIR, filename)
|
||||
if pdf_path is None or not os.path.exists(pdf_path):
|
||||
response = models.PdfAnswerResponse(error="PDF not found")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 404
|
||||
|
||||
doc = convert_pdf_to_text_editor_document(pdf_path)
|
||||
|
||||
pages = doc.document.pages if doc else []
|
||||
snippets: list[str] = []
|
||||
for page in pages:
|
||||
for elem in page.text_elements:
|
||||
text = elem.text
|
||||
if text:
|
||||
snippets.append(str(text))
|
||||
if not snippets:
|
||||
response = models.PdfAnswerResponse(error="No readable text in PDF")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||
|
||||
# Normalize and limit context
|
||||
context = " ".join(snippets)
|
||||
context = " ".join(context.split()) # normalize whitespace
|
||||
max_context = 10000
|
||||
if len(context) > max_context:
|
||||
context = context[:max_context]
|
||||
|
||||
model_name = SMART_MODEL
|
||||
system_prompt = pdf_qa_system_prompt() + "\nReturn JSON matching the provided schema."
|
||||
user_prompt = f"Question: {question}\n\nPDF text:\n{context}"
|
||||
messages = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=user_prompt),
|
||||
]
|
||||
response = run_ai(
|
||||
model_name,
|
||||
messages,
|
||||
models.PdfAnswer,
|
||||
tag="pdf_answer",
|
||||
max_tokens=220,
|
||||
)
|
||||
answer = response.answer.strip()
|
||||
title_like = re.match(r"^why pdfs|^minimalist|^author:", answer, re.IGNORECASE)
|
||||
normalized_answer = re.sub(r"\s+", " ", answer).strip().lower()
|
||||
normalized_context = re.sub(r"\s+", " ", context).strip().lower()
|
||||
copied_context = bool(normalized_answer) and normalized_answer in normalized_context
|
||||
if title_like or copied_context:
|
||||
response = models.PdfAnswerResponse(error="AI answer was invalid or echoed the source text.")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 500
|
||||
|
||||
response = models.PdfAnswerResponse(answer=answer, mode="model")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions", methods=["POST"])
|
||||
def create_session():
|
||||
"""Create a new AI session via the Java backend."""
|
||||
create_session_request = _json_body(models.CreateSessionRequest)
|
||||
|
||||
# Doc type should ideally come from chat router; fall back to detection if needed
|
||||
detection_method = "provided"
|
||||
if create_session_request.doc_type in ("miscellaneous", "other", "document", "unknown", ""):
|
||||
# Fall back to detection if doc_type not provided by chat router
|
||||
logger.info("[SESSION] No doc_type provided, running fallback detection")
|
||||
confidence_threshold = 0.7 # Only accept matches with 70%+ confidence
|
||||
detected_type, confidence = detect_document_type(
|
||||
create_session_request.prompt, confidence_threshold=confidence_threshold
|
||||
)
|
||||
if confidence >= confidence_threshold:
|
||||
logger.info(
|
||||
"[SESSION] Detected doc_type=%s from prompt (confidence=%.2f)",
|
||||
detected_type,
|
||||
confidence,
|
||||
)
|
||||
create_session_request.doc_type = detected_type
|
||||
detection_method = "fallback_detection"
|
||||
else:
|
||||
logger.info(
|
||||
"[SESSION] Detection confidence too low (%.2f < %.2f), using 'other'",
|
||||
confidence,
|
||||
confidence_threshold,
|
||||
)
|
||||
create_session_request.doc_type = "other"
|
||||
detection_method = "fallback_low_confidence"
|
||||
else:
|
||||
logger.info(
|
||||
"[SESSION] Using doc_type=%s from chat router",
|
||||
create_session_request.doc_type,
|
||||
)
|
||||
|
||||
result = _java_request_json(
|
||||
"POST",
|
||||
"/api/v1/ai/create/sessions",
|
||||
create_session_request,
|
||||
models.JavaCreateSessionResponse,
|
||||
)
|
||||
session_id = result.session_id
|
||||
logger.info(
|
||||
"[SESSION] Created session %s doc_type=%s method=%s",
|
||||
session_id,
|
||||
create_session_request.doc_type,
|
||||
detection_method,
|
||||
)
|
||||
# Track session creation in PostHog
|
||||
safe_doc_type = re.sub(r"[^a-zA-Z0-9_]+", "", (create_session_request.doc_type or "").lower())
|
||||
has_html_template = (_DEFAULT_TEMPLATES_DIR / f"{safe_doc_type}.html").exists()
|
||||
analytics.track_session_created(
|
||||
user_id=session_id,
|
||||
session_id=session_id,
|
||||
doc_type=create_session_request.doc_type or "unknown",
|
||||
template_id=create_session_request.template_id,
|
||||
has_template=has_html_template,
|
||||
)
|
||||
|
||||
response = models.CreateSessionResponse(
|
||||
session_id=session_id,
|
||||
doc_type=create_session_request.doc_type,
|
||||
detection_method=detection_method,
|
||||
)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/outline", methods=["POST"])
|
||||
def update_outline(session_id: str):
|
||||
"""Update session with approved outline."""
|
||||
payload = _json_body(models.UpdateOutlineRequest)
|
||||
_java_request_json(
|
||||
"POST",
|
||||
f"/api/v1/ai/create/sessions/{session_id}/outline",
|
||||
payload,
|
||||
models.AISession,
|
||||
)
|
||||
response = models.SuccessResponse(success=True)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/draft", methods=["POST"])
|
||||
def update_draft(session_id: str):
|
||||
"""Update session with approved draft sections."""
|
||||
payload = _json_body(models.UpdateDraftRequest)
|
||||
_java_request_json(
|
||||
"POST",
|
||||
f"/api/v1/ai/create/sessions/{session_id}/draft",
|
||||
payload,
|
||||
models.AISession,
|
||||
)
|
||||
response = models.SuccessResponse(success=True)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/template", methods=["POST"])
|
||||
def update_template(session_id: str):
|
||||
"""Update session template selection."""
|
||||
payload = _json_body(models.UpdateTemplateRequest)
|
||||
_java_request_json(
|
||||
"POST",
|
||||
f"/api/v1/ai/create/sessions/{session_id}/template",
|
||||
payload,
|
||||
models.AISession,
|
||||
)
|
||||
response = models.SuccessResponse(success=True)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/reprompt", methods=["POST"])
|
||||
def reprompt_session(session_id: str):
|
||||
"""Update session with new prompt."""
|
||||
payload = _json_body(models.RepromptRequest)
|
||||
_java_request_json(
|
||||
"POST",
|
||||
f"/api/v1/ai/create/sessions/{session_id}/reprompt",
|
||||
payload,
|
||||
models.AISession,
|
||||
)
|
||||
response = models.SuccessResponse(success=True)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/stream", methods=["POST"])
|
||||
def create_stream(session_id: str):
|
||||
payload = _json_body(models.CreateStreamRequest)
|
||||
session = _fetch_ai_session(session_id)
|
||||
prompt = session.prompt_latest or session.prompt_initial or ""
|
||||
doc_type = session.doc_type or "other"
|
||||
template_id = session.template_id
|
||||
outline_text = session.outline_text or ""
|
||||
outline_filename = session.outline_filename
|
||||
constraints = session.outline_constraints
|
||||
draft_sections = session.draft_sections
|
||||
logo_base64 = payload.theme.logo_base64 if payload.theme else None
|
||||
theme = payload.theme.css_overrides() if payload.theme else None
|
||||
logger.info(
|
||||
"[STREAM] session_id=%s has_theme=%s has_logo=%s",
|
||||
session_id,
|
||||
bool(theme),
|
||||
bool(logo_base64),
|
||||
)
|
||||
base_html = payload.base_html or session.polished_html or None
|
||||
handler = PDFGenerator(
|
||||
session_id=session_id,
|
||||
phase=payload.phase,
|
||||
prompt=prompt,
|
||||
doc_type=doc_type,
|
||||
template_id=template_id,
|
||||
outline_text=outline_text,
|
||||
outline_filename=outline_filename,
|
||||
constraints=constraints,
|
||||
draft_sections=draft_sections,
|
||||
update_session=_update_ai_session,
|
||||
theme=theme,
|
||||
logo_base64=logo_base64,
|
||||
base_html=base_html,
|
||||
instructions=payload.additional_instructions,
|
||||
)
|
||||
|
||||
return Response(
|
||||
stream_with_context(handler.generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/fields", methods=["POST"])
|
||||
def fill_fields(session_id: str):
|
||||
logger.info("[AI create] fill_fields session_id=%s", session_id)
|
||||
session = _fetch_ai_session(session_id)
|
||||
|
||||
payload = _json_body(models.FillFieldsRequest)
|
||||
fields = payload.fields
|
||||
extra_prompt = payload.extra_prompt
|
||||
if not isinstance(fields, list):
|
||||
return jsonify({"error": "Fields must be a list"}), 400
|
||||
|
||||
prompt = session.prompt_latest or session.prompt_initial or ""
|
||||
if extra_prompt:
|
||||
prompt = f"{prompt}\n{extra_prompt}"
|
||||
doc_type = session.doc_type or "other"
|
||||
constraints = session.outline_constraints
|
||||
|
||||
filled = generate_field_values(prompt, doc_type, fields, constraints)
|
||||
response = models.FillFieldsResponse(fields=filled)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/generate_section", methods=["POST"])
|
||||
def generate_section():
|
||||
"""Generate content for a single section based on a custom prompt."""
|
||||
payload = _json_body(models.GenerateSectionRequest)
|
||||
_session_id = payload.session_id
|
||||
section_label = payload.section_label
|
||||
section_index = payload.section_index
|
||||
custom_prompt = payload.custom_prompt
|
||||
document_prompt = payload.document_prompt
|
||||
document_type = payload.doc_type
|
||||
existing_sections = payload.existing_sections
|
||||
|
||||
logger.info(
|
||||
"[AI] generate_section label=%s custom_prompt=%s", section_label, custom_prompt[:50] if custom_prompt else ""
|
||||
)
|
||||
|
||||
# Build context from existing sections (don't truncate - AI needs full context for calculations)
|
||||
sections_context = "\n".join([f"- {sec.label}: {sec.value}" for sec in existing_sections if sec.value])
|
||||
system_prompt = section_fill_system_prompt(
|
||||
document_type,
|
||||
document_prompt[:500],
|
||||
sections_context,
|
||||
section_label,
|
||||
custom_prompt,
|
||||
)
|
||||
|
||||
messages = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=f"Generate content for: {section_label}"),
|
||||
]
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.SectionContent,
|
||||
tag="generate_section",
|
||||
max_tokens=500,
|
||||
)
|
||||
response = models.GenerateSectionResponse(content=parsed.content.strip(), section_index=section_index)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
def _run_sections_batch(
|
||||
batch: list[tuple[int, str]],
|
||||
document_prompt: str,
|
||||
additional_prompt: str | None,
|
||||
system_prompt: str,
|
||||
) -> dict[int, str]:
|
||||
sections_list = "\n".join([f"{i + 1}. {label}" for i, label in batch])
|
||||
user_prompt = (
|
||||
f"User's document request:\n{document_prompt}\n\n"
|
||||
"Generate ONLY these sections (leave others unchanged):\n"
|
||||
f"{sections_list}"
|
||||
)
|
||||
if additional_prompt:
|
||||
user_prompt = f"{user_prompt}\n\nAdditional instructions:\n{additional_prompt}"
|
||||
messages = [
|
||||
models.ChatMessage(role="system", content=system_prompt),
|
||||
models.ChatMessage(role="user", content=user_prompt),
|
||||
]
|
||||
parsed = run_ai(
|
||||
SMART_MODEL,
|
||||
messages,
|
||||
models.LLMGenerateAllSectionsResponse,
|
||||
tag="generate_all_sections",
|
||||
max_tokens=model_max_tokens(SMART_MODEL),
|
||||
)
|
||||
return {section.index - 1: section.value for section in parsed.sections if section.value}
|
||||
|
||||
|
||||
@app.route("/api/generate_all_sections", methods=["POST"])
|
||||
def generate_all_sections():
|
||||
"""Generate content for selected sections."""
|
||||
generation_start = time.time()
|
||||
payload = _json_body(models.GenerateAllSectionsRequest)
|
||||
_session_id = payload.session_id
|
||||
document_prompt = payload.document_prompt
|
||||
document_type = payload.doc_type
|
||||
current_sections = payload.sections
|
||||
only_indices = payload.only_indices # Optional: only generate for these indices
|
||||
additional_prompt = payload.additional_prompt # Optional: extra instructions from user
|
||||
|
||||
# If onlyIndices is provided, only generate for those sections
|
||||
if only_indices is not None:
|
||||
indices_to_generate = set(only_indices)
|
||||
else:
|
||||
indices_to_generate = set(range(len(current_sections)))
|
||||
|
||||
logger.info(
|
||||
"[AI] generate_all_sections doc_type=%s total_sections=%d generating=%d",
|
||||
document_type,
|
||||
len(current_sections),
|
||||
len(indices_to_generate),
|
||||
)
|
||||
|
||||
if not current_sections:
|
||||
response = models.GenerateAllSectionsResponse(error="No sections provided")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True)), 400
|
||||
|
||||
if not indices_to_generate:
|
||||
# Nothing to generate, return sections as-is
|
||||
response = models.GenerateAllSectionsResponse(sections=current_sections)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
# Build the section labels list (only for sections we're generating)
|
||||
section_labels = [section.label for section in current_sections]
|
||||
|
||||
# Build prompt only for sections we want to generate
|
||||
sections_to_generate = [(i, section_labels[i]) for i in sorted(indices_to_generate) if i < len(current_sections)]
|
||||
system_prompt = generate_all_sections_system_prompt(document_type)
|
||||
|
||||
# Batch to avoid hitting model output token limits — each batch is a separate API call.
|
||||
batch_size = 3
|
||||
batches = [sections_to_generate[i : i + batch_size] for i in range(0, len(sections_to_generate), batch_size)]
|
||||
|
||||
generated_content: dict[int, str] = {}
|
||||
failed_indices: list[int] = []
|
||||
|
||||
for batch in batches:
|
||||
try:
|
||||
generated_content.update(_run_sections_batch(batch, document_prompt, additional_prompt, system_prompt))
|
||||
except Exception as batch_err:
|
||||
logger.warning(
|
||||
"[AI] generate_all_sections batch failed (%s), retrying each section individually",
|
||||
batch_err,
|
||||
)
|
||||
for item in batch:
|
||||
try:
|
||||
generated_content.update(
|
||||
_run_sections_batch([item], document_prompt, additional_prompt, system_prompt)
|
||||
)
|
||||
except Exception as single_err:
|
||||
logger.error(
|
||||
"[AI] generate_all_sections failed for section index=%d label=%r: %s",
|
||||
item[0],
|
||||
item[1],
|
||||
single_err,
|
||||
)
|
||||
failed_indices.append(item[0])
|
||||
|
||||
# Build final sections list, keeping existing content for non-generated sections
|
||||
filled_sections = []
|
||||
for i, section in enumerate(current_sections):
|
||||
label = section.label
|
||||
if i in generated_content:
|
||||
# Use newly generated content
|
||||
filled_sections.append(models.DraftSection(label=label, value=generated_content[i]))
|
||||
else:
|
||||
# Keep existing content
|
||||
filled_sections.append(models.DraftSection(label=label, value=section.value))
|
||||
|
||||
generation_duration = (time.time() - generation_start) * 1000
|
||||
|
||||
# Track section generation
|
||||
analytics.track_event(
|
||||
user_id=_session_id or "unknown",
|
||||
event_name="sections_generated",
|
||||
properties={
|
||||
"session_id": _session_id,
|
||||
"doc_type": document_type,
|
||||
"total_sections": len(current_sections),
|
||||
"generated_sections": len(indices_to_generate),
|
||||
"generation_time_ms": generation_duration,
|
||||
"has_additional_prompt": bool(additional_prompt),
|
||||
},
|
||||
)
|
||||
|
||||
response = models.GenerateAllSectionsResponse(
|
||||
sections=filled_sections,
|
||||
incomplete_section_indices=failed_indices if failed_indices else None,
|
||||
)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/output/<path:filename>", methods=["GET"])
|
||||
def serve_output_file(filename: str):
|
||||
"""Serve generated PDF files and stored assets."""
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
try:
|
||||
response = send_from_directory(OUTPUT_DIR, filename, mimetype=mime_type or "application/pdf")
|
||||
file_size = response.headers.get("Content-Length", "unknown")
|
||||
logger.info("[SERVE] Serving file=%s size=%s bytes mime=%s", filename, file_size, mime_type)
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET"
|
||||
return response
|
||||
except NotFound:
|
||||
logger.warning("[SERVE] File not found: %s", filename)
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
@app.route("/api/versions/<user_id>", methods=["GET"])
|
||||
def list_versions(user_id: str):
|
||||
response = models.VersionsResponse(versions=load_versions(user_id))
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/assets/upload", methods=["POST"])
|
||||
def upload_asset():
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": "Missing file"}), 400
|
||||
|
||||
_, ext = os.path.splitext(file.filename or "")
|
||||
ext = ext.lower()
|
||||
if ext not in {".png", ".jpg", ".jpeg", ".gif"}:
|
||||
return jsonify({"error": "Unsupported file type"}), 400
|
||||
|
||||
asset_id = f"{uuid.uuid4().hex}{ext}"
|
||||
output_path = os.path.join(ASSETS_DIR, asset_id)
|
||||
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||
file.save(output_path)
|
||||
|
||||
response = models.UploadAssetResponse(
|
||||
asset_id=asset_id,
|
||||
asset_url=f"/output/assets/{asset_id}",
|
||||
)
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
@app.route("/api/templates/previews", methods=["GET"])
|
||||
def list_preview_templates():
|
||||
"""Return sorted list of preview template stem names."""
|
||||
stems = sorted(p.stem for p in _DEFAULT_TEMPLATES_DIR.glob("*_preview.html"))
|
||||
return jsonify({"templates": stems})
|
||||
|
||||
|
||||
@app.route("/api/templates/preview-html", methods=["GET"])
|
||||
def get_template_preview_html():
|
||||
"""Return preview template HTML with default theme injected.
|
||||
|
||||
Query param: template — stem name (e.g. 'invoice_preview' or 'invoice_preview.html').
|
||||
The frontend will override CSS vars via JS for live color preview.
|
||||
"""
|
||||
payload = _json_body(models.PreviewTemplateHtmlRequest, "GET")
|
||||
template_param = payload.template
|
||||
if not template_param:
|
||||
return jsonify({"error": "Missing template parameter"}), 400
|
||||
|
||||
# Strip .html extension if present
|
||||
stem = template_param.removesuffix(".html")
|
||||
if "_preview" not in stem:
|
||||
return jsonify({"error": "Template must be a preview template (name must contain '_preview')"}), 400
|
||||
|
||||
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
|
||||
if not html_path.exists():
|
||||
return jsonify({"error": f"Template not found: {stem}"}), 404
|
||||
|
||||
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Inject default theme so vars are defined; frontend will override via JS
|
||||
html = inject_theme(html, None)
|
||||
return Response(html, mimetype="text/html")
|
||||
|
||||
|
||||
@app.route("/api/templates/render-preview", methods=["POST"])
|
||||
def render_preview_to_pdf():
|
||||
"""Render a preview template to PDF with the given theme.
|
||||
|
||||
Body: { template: str, theme: dict | None }
|
||||
Returns: PDF file download.
|
||||
"""
|
||||
payload = _json_body(models.RenderPreviewRequest)
|
||||
template_param = payload.template
|
||||
theme = payload.theme.css_overrides() if payload.theme else None
|
||||
|
||||
if not template_param:
|
||||
return jsonify({"error": "Missing template parameter"}), 400
|
||||
|
||||
stem = template_param.removesuffix(".html")
|
||||
if "_preview" not in stem:
|
||||
return jsonify({"error": "Template must be a preview template"}), 400
|
||||
|
||||
html_path = _DEFAULT_TEMPLATES_DIR / f"{stem}.html"
|
||||
if not html_path.exists():
|
||||
return jsonify({"error": f"Template not found: {stem}"}), 404
|
||||
|
||||
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||
html = inject_theme(html, theme)
|
||||
|
||||
job_id = f"preview/{uuid.uuid4().hex}"
|
||||
result = compile_html_to_pdf(html, job_id, log_errors=True)
|
||||
|
||||
if result.pdf_path and os.path.exists(result.pdf_path):
|
||||
return send_file(result.pdf_path, mimetype="application/pdf", as_attachment=True, download_name=f"{stem}.pdf")
|
||||
|
||||
return jsonify({"error": result.error or "PDF generation failed"}), 500
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
response = models.HealthResponse(status="ok", engine="puppeteer")
|
||||
return jsonify(response.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001, debug=FLASK_DEBUG)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from config import FAST_MODEL, SMART_MODEL
|
||||
from llm_utils import run_ai
|
||||
from models import ChatMessage, IntentCheckResponse, IntentClassification, MissingQuestionsResponse
|
||||
from prompts import brief_missing_info_system_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIEF_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"resume": {
|
||||
"field_order": [
|
||||
"name",
|
||||
"contact",
|
||||
"location",
|
||||
"target_role",
|
||||
"summary",
|
||||
"work_history",
|
||||
"education",
|
||||
"skills",
|
||||
"achievements",
|
||||
"links",
|
||||
"constraints",
|
||||
],
|
||||
"labels": {
|
||||
"name": ["name", "full name"],
|
||||
"contact": ["contact", "contact info", "contact information", "email/phone"],
|
||||
"location": ["location", "city/country"],
|
||||
"target_role": ["target role", "role", "title", "headline"],
|
||||
"summary": ["summary", "objective", "about"],
|
||||
"work_history": ["experience", "work history", "roles"],
|
||||
"education": ["education", "studies"],
|
||||
"skills": ["skills", "stack"],
|
||||
"achievements": ["achievements", "certifications", "awards"],
|
||||
"links": ["links", "profiles", "linkedin/github"],
|
||||
"constraints": ["constraints", "tone/length/style"],
|
||||
},
|
||||
"questions": {
|
||||
"name": "What's your name as you'd like it on the page?",
|
||||
"contact": "How can someone reach you (email/phone)?",
|
||||
"location": "Where are you based (or remote)?",
|
||||
"target_role": "What role/title and industry are you aiming for?",
|
||||
"summary": "Give me a 1–2 sentence summary about you.",
|
||||
"work_history": "Recent roles: company, title, dates, location, and a few bullets with impact.",
|
||||
"education": "Degree(s), school, and graduation year?",
|
||||
"skills": "Key skills/stack (tech + relevant soft skills)?",
|
||||
"achievements": "Awards/certifications/major achievements?",
|
||||
"links": "Any LinkedIn/GitHub/portfolio links?",
|
||||
"constraints": "Any tone/length constraints (ATS, one-page, etc.)?",
|
||||
},
|
||||
"intro": "Hey! To build a strong resume, you can paste your old resume or just dump everything you remember—name, how to reach you, where you're based, what you're aiming for, your roles, education, skills, links. Share whatever you have and I'll work with it.",
|
||||
},
|
||||
"invoice": {
|
||||
"field_order": [
|
||||
"your_business",
|
||||
"client",
|
||||
"issue_date",
|
||||
"due_date",
|
||||
"line_items",
|
||||
"currency",
|
||||
"payment_terms",
|
||||
"notes",
|
||||
"constraints",
|
||||
],
|
||||
"labels": {
|
||||
"your_business": ["your business", "seller", "from"],
|
||||
"client": ["client", "bill to"],
|
||||
"issue_date": ["issue date", "invoice date"],
|
||||
"due_date": ["due date"],
|
||||
"line_items": ["line items", "services/items"],
|
||||
"currency": ["currency"],
|
||||
"payment_terms": ["payment terms"],
|
||||
"notes": ["notes"],
|
||||
"constraints": ["constraints", "layout/style"],
|
||||
},
|
||||
"questions": {
|
||||
"your_business": "Who is issuing the invoice (business name + contact)?",
|
||||
"client": "Who is being billed (name + contact)?",
|
||||
"issue_date": "Invoice issue date?",
|
||||
"due_date": "Due date?",
|
||||
"line_items": "Line items with description, qty, rate, tax (if any)?",
|
||||
"currency": "Currency?",
|
||||
"payment_terms": "Payment terms and payment methods?",
|
||||
"notes": "Notes to include (late fees, thank you, PO #)?",
|
||||
"constraints": "Branding/layout preferences?",
|
||||
},
|
||||
"intro": "I'll draft an accurate invoice if I know who is billing, who is paying, and the line items. Paste an old invoice or list the details.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def classify_intent_with_llm(prompt: str, history: list[ChatMessage], has_pdf: bool) -> IntentClassification:
|
||||
"""
|
||||
Use a small model to classify intent instead of brittle regex.
|
||||
|
||||
Returns a dict like:
|
||||
{
|
||||
"docType": "invoice|resume|contract|letter|report|form|document",
|
||||
"action": "new|edit|question",
|
||||
"wantsPdf": bool,
|
||||
"hasEnoughInfo": bool,
|
||||
"missingFields": [str],
|
||||
"notes": str
|
||||
}
|
||||
"""
|
||||
system = (
|
||||
"You classify user requests about documents. "
|
||||
"docType must be one of: academic, agenda, brochure, business_card, case_study, checklist, "
|
||||
"contract, creative, datasheet, document, flyer, invoice, letter, manual, menu, minutes, newsletter, "
|
||||
"one_pager, poster, presentation, press_release, proposal, recipe, report, resume, timeline, whitepaper. "
|
||||
"action: 'new' (make/generate), 'edit' (modify existing), 'question' (asking about it). "
|
||||
"wantsPdf: true if they expect/gave permission to generate a PDF. "
|
||||
"hasEnoughInfo: true if there is enough info to proceed without fabricating data. "
|
||||
"missingFields: key details still needed (e.g., for invoice: seller, client, line items; resume: name, contact, work). "
|
||||
"notes: short free-form note. "
|
||||
"Return the classification using the provided schema."
|
||||
)
|
||||
|
||||
conversation = [ChatMessage(role="system", content=system)]
|
||||
# Trim history to keep request small
|
||||
trimmed_history = history[-6:] if len(history) > 6 else history
|
||||
conversation.extend(trimmed_history)
|
||||
conversation.append(ChatMessage(role="user", content=prompt))
|
||||
|
||||
parsed = run_ai(
|
||||
FAST_MODEL,
|
||||
conversation,
|
||||
IntentClassification,
|
||||
tag="intent_classify",
|
||||
max_tokens=20000,
|
||||
)
|
||||
result = parsed
|
||||
logger.info(
|
||||
"[INTENT] llm_classify doc_type=%s action=%s wantsPdf=%s hasEnoughInfo=%s missing=%s notes=%s",
|
||||
result.doc_type,
|
||||
result.action,
|
||||
result.wants_pdf,
|
||||
result.has_enough_info,
|
||||
result.missing_fields,
|
||||
(result.notes or "")[:120],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _preprocess_intent(
|
||||
prompt: str,
|
||||
history: list[ChatMessage],
|
||||
has_pdf: bool,
|
||||
) -> IntentCheckResponse:
|
||||
"""
|
||||
Lightweight intent classifier used by /api/intent/check.
|
||||
|
||||
It is intentionally heuristic-only to avoid extra model calls. The goal is to
|
||||
decide whether we should proceed with PDF generation and whether it's OK to
|
||||
fabricate placeholder content when the user explicitly asks for it.
|
||||
"""
|
||||
llm = classify_intent_with_llm(prompt, history, has_pdf)
|
||||
return IntentCheckResponse(
|
||||
wants_pdf=llm.wants_pdf,
|
||||
has_enough_info=llm.has_enough_info,
|
||||
document_type=llm.doc_type.value,
|
||||
missing_fields=llm.missing_fields,
|
||||
)
|
||||
|
||||
|
||||
def _extract_structured_fields(text: str, schema: dict[str, Any]) -> dict[str, str]:
|
||||
"""Naively parse user text to pull schema fields."""
|
||||
found: dict[str, str] = {}
|
||||
lower = text.lower()
|
||||
|
||||
if schema.get("field_order") == BRIEF_SCHEMAS["resume"]["field_order"]:
|
||||
work_matches = re.findall(
|
||||
r"(?:experience|work history|role|company|position)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||
text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if work_matches:
|
||||
found["work_history"] = "\n".join(work_matches[:3])
|
||||
education_matches = re.findall(
|
||||
r"(?:education|degree)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||
text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if education_matches:
|
||||
found["education"] = "\n".join(education_matches[:2])
|
||||
skills_match = re.search(r"(?:skills|stack)\s*[:\-]\s*(.+)", text, flags=re.IGNORECASE)
|
||||
if skills_match:
|
||||
found["skills"] = skills_match.group(1).strip()
|
||||
|
||||
for field, labels in schema.get("labels", {}).items():
|
||||
for label in labels:
|
||||
pattern = rf"{label}\s*[:\-]\s*(.+?)(?=\n[A-Z][a-zA-Z ]+[:\-]|\Z)"
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
found[field] = match.group(1).strip()
|
||||
break
|
||||
if not found.get("summary") and len(lower) < 200:
|
||||
found["summary"] = text.strip()
|
||||
return {k: v for k, v in found.items() if v}
|
||||
|
||||
|
||||
def _format_missing_message(
|
||||
doc_type: str,
|
||||
schema: dict[str, Any],
|
||||
collected: dict[str, str],
|
||||
missing: list[str],
|
||||
preface: str | None = None,
|
||||
) -> str:
|
||||
"""Fallback text asking the user for missing fields."""
|
||||
intro = schema.get("intro") or f"Need a few details to finish your {doc_type}."
|
||||
lines = [intro]
|
||||
if preface:
|
||||
lines.append(preface)
|
||||
if collected:
|
||||
lines.append("Already have:")
|
||||
for field, value in collected.items():
|
||||
label = schema.get("labels", {}).get(field, [field])[0]
|
||||
lines.append(f"- {label}: {value}")
|
||||
if missing:
|
||||
lines.append("Still need:")
|
||||
questions = schema.get("questions", {})
|
||||
for field in missing[:4]:
|
||||
ask = questions.get(field) or f"{field}?"
|
||||
lines.append(f"- {ask}")
|
||||
lines.append("Partial info is fine—share whatever you remember.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _ai_missing_message(
|
||||
doc_type: str,
|
||||
schema: dict[str, Any],
|
||||
collected: dict[str, str],
|
||||
missing: list[str],
|
||||
) -> str | None:
|
||||
"""Let the model craft clarifying questions when available."""
|
||||
if not missing:
|
||||
return None
|
||||
|
||||
collected_lines = [
|
||||
f"- {schema.get('labels', {}).get(field, [field])[0]}: {value}" for field, value in collected.items()
|
||||
]
|
||||
missing_labels = [schema.get("labels", {}).get(field, [field])[0] for field in missing]
|
||||
user_text = "We already have:\n" + "\n".join(collected_lines) if collected_lines else "We have nothing yet."
|
||||
user_text += "\nNeed to ask for: " + ", ".join(missing_labels)
|
||||
if not collected_lines:
|
||||
user_text += "\nInvite them to paste an old resume or dump all details if they have them."
|
||||
|
||||
system_prompt = brief_missing_info_system_prompt(doc_type) + "\nReturn JSON that matches the provided schema."
|
||||
response = run_ai(
|
||||
SMART_MODEL,
|
||||
[
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
ChatMessage(role="user", content=user_text),
|
||||
],
|
||||
MissingQuestionsResponse,
|
||||
tag="missing_questions",
|
||||
max_tokens=400,
|
||||
)
|
||||
return response.message
|
||||
|
||||
|
||||
def gather_brief(
|
||||
doc_type: str,
|
||||
prompt: str,
|
||||
history: list[ChatMessage],
|
||||
has_pdf: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Determine whether we have enough structured details to generate.
|
||||
Returns needsInfo + a formatted message when details are missing, or a structured brief.
|
||||
"""
|
||||
classifier = classify_intent_with_llm(prompt, history, has_pdf)
|
||||
if classifier:
|
||||
doc_type = classifier.doc_type.value
|
||||
logger.info(
|
||||
"[BRIEF] using llm doc_type=%s missing=%s hasEnoughInfo=%s wantsPdf=%s",
|
||||
doc_type,
|
||||
classifier.missing_fields,
|
||||
classifier.has_enough_info,
|
||||
classifier.wants_pdf,
|
||||
)
|
||||
|
||||
schema = BRIEF_SCHEMAS.get(doc_type)
|
||||
if not schema:
|
||||
return {"needsInfo": False, "structured_brief": None, "collected": {}, "missing": []}
|
||||
|
||||
user_texts = [entry.content for entry in history if entry.role == "user" and isinstance(entry.content, str)]
|
||||
user_texts.append(prompt or "")
|
||||
combined_text = "\n".join(user_texts)
|
||||
collected = _extract_structured_fields(combined_text, schema)
|
||||
missing = [field for field in schema.get("field_order", []) if field not in collected]
|
||||
if classifier and classifier.missing_fields:
|
||||
# If the model provided missing fields, respect that list.
|
||||
missing = classifier.missing_fields or missing
|
||||
|
||||
def has_minimum_resume(data: dict[str, str]) -> bool:
|
||||
has_name = bool(data.get("name"))
|
||||
has_core = any(data.get(key) for key in ["work_history", "education", "skills", "contact", "target_role"])
|
||||
return has_name and has_core
|
||||
|
||||
def has_minimum_invoice(data: dict[str, str]) -> bool:
|
||||
has_parties = data.get("your_business") and data.get("client")
|
||||
has_items = bool(data.get("line_items"))
|
||||
return bool(has_parties and has_items)
|
||||
|
||||
has_minimum = True
|
||||
if doc_type == "resume":
|
||||
has_minimum = has_minimum_resume(collected)
|
||||
elif doc_type == "invoice":
|
||||
has_minimum = has_minimum_invoice(collected)
|
||||
|
||||
# Never block generation - always proceed with what we have
|
||||
# Users can generate documents even with missing information
|
||||
if False: # Disabled - never block generation
|
||||
preface = None
|
||||
if doc_type == "resume" and not has_minimum:
|
||||
preface = (
|
||||
"I only have a tiny bit so far. I need at least your name plus one of: contact, "
|
||||
"a role snippet, education, skills, or target role."
|
||||
)
|
||||
if doc_type == "invoice" and not has_minimum:
|
||||
preface = "Need who is billing, who is paying, and the line items so I don't invent details."
|
||||
logger.info(
|
||||
"[BRIEF] gating doc_type=%s missing=%s has_minimum=%s",
|
||||
doc_type,
|
||||
missing,
|
||||
has_minimum,
|
||||
)
|
||||
message = _ai_missing_message(doc_type, schema, collected, missing) or _format_missing_message(
|
||||
doc_type, schema, collected, missing, preface=preface
|
||||
)
|
||||
return {
|
||||
"needsInfo": True,
|
||||
"message": message,
|
||||
"collected": collected,
|
||||
"missing": missing,
|
||||
}
|
||||
|
||||
# If fields are missing, let downstream generation know which areas to fill in plausibly
|
||||
# This allows generation to proceed even with missing information
|
||||
fabrication_hint = ""
|
||||
if missing:
|
||||
missing_labels = [schema.get("labels", {}).get(field, [field])[0] for field in missing[:5]]
|
||||
fabrication_hint = (
|
||||
"\n\nIf details are absent, invent plausible, clearly fictional details for: "
|
||||
+ ", ".join(missing_labels)
|
||||
+ "."
|
||||
)
|
||||
|
||||
structured_lines = []
|
||||
for field in schema.get("field_order", []):
|
||||
value = collected.get(field)
|
||||
if value:
|
||||
label = schema.get("labels", {}).get(field, [field])[0]
|
||||
structured_lines.append(f"{label}: {value}")
|
||||
structured_brief = "\n".join(structured_lines)
|
||||
if fabrication_hint:
|
||||
structured_brief = (structured_brief + fabrication_hint).strip()
|
||||
# Always include the full user text - it contains all the details
|
||||
# For detailed prompts, this ensures nothing is lost
|
||||
if combined_text.strip():
|
||||
if structured_brief:
|
||||
structured_brief = (
|
||||
structured_brief + "\n\nCOMPLETE USER INPUT (use ALL information from this):\n" + combined_text
|
||||
).strip()
|
||||
else:
|
||||
# If extraction found nothing, use the full text as the brief
|
||||
structured_brief = combined_text.strip()
|
||||
|
||||
return {
|
||||
"needsInfo": False,
|
||||
"structured_brief": structured_brief or None,
|
||||
"collected": collected,
|
||||
"missing": missing,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["gather_brief", "BRIEF_SCHEMAS", "_preprocess_intent"]
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from config import FAST_MODEL
|
||||
from format_prompts import FORMAT_PROMPTS
|
||||
from llm_utils import run_ai
|
||||
from models import ChatMessage, ChatRouteRequest, ChatRouteResponse
|
||||
from prompts import chat_route_system_prompt
|
||||
|
||||
|
||||
def classify_chat_route(request: ChatRouteRequest) -> ChatRouteResponse:
|
||||
"""
|
||||
Classify a chat message to route it to edit vs create workflows.
|
||||
|
||||
Returns ChatRouteResponse.
|
||||
"""
|
||||
# Build list of available document types for detection
|
||||
available_types = sorted(list(FORMAT_PROMPTS.keys()))
|
||||
types_list = ", ".join(available_types)
|
||||
system_prompt = chat_route_system_prompt(types_list)
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=system_prompt),
|
||||
*request.history[-6:],
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=(
|
||||
"Context:\n"
|
||||
f"- has_files={request.has_files}\n"
|
||||
f"- has_editable_html={request.has_editable_html}\n"
|
||||
f"- has_create_session={request.has_create_session}\n"
|
||||
f"- has_edit_session={request.has_edit_session}\n"
|
||||
f"- last_route={request.last_route}\n\n"
|
||||
f"- request_title={request.request_title}\n"
|
||||
f"- title_context={request.title_context.model_dump() if request.title_context else None}\n\n"
|
||||
f"Message: {request.message}"
|
||||
),
|
||||
),
|
||||
]
|
||||
result = run_ai(
|
||||
FAST_MODEL,
|
||||
messages,
|
||||
ChatRouteResponse,
|
||||
tag="chat_route",
|
||||
log_label="chat-route",
|
||||
log_exchange=True,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,250 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
import platformdirs
|
||||
from dotenv import load_dotenv
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_openai import ChatOpenAI
|
||||
from posthog import Posthog
|
||||
from posthog.ai.langchain import CallbackHandler
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _get_log_path() -> Path:
|
||||
# Allow explicit override
|
||||
if os.environ["STIRLING_LOG_PATH"]:
|
||||
return Path(os.environ["STIRLING_LOG_PATH"])
|
||||
|
||||
# Check if running in Tauri desktop mode
|
||||
is_tauri = os.environ["STIRLING_PDF_TAURI_MODE"].lower() == "true"
|
||||
|
||||
if is_tauri:
|
||||
# Use OS-native log directory via platformdirs
|
||||
# On Mac: ~/Library/Logs/Stirling-PDF/
|
||||
# On Windows: %LOCALAPPDATA%/Stirling-PDF/Logs/
|
||||
# On Linux: ~/.local/state/Stirling-PDF/log/
|
||||
return Path(platformdirs.user_log_dir("Stirling-PDF"))
|
||||
|
||||
# Server/Docker mode: ./logs/
|
||||
return Path("./logs")
|
||||
|
||||
|
||||
LOG_PATH = _get_log_path()
|
||||
LOG_PATH.mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE = LOG_PATH / "docgen.log"
|
||||
|
||||
# Create formatters
|
||||
console_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(thread)d] %(levelname)-5s %(name)s - %(message)s", datefmt="%H:%M:%S.%f"
|
||||
)
|
||||
file_formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(thread)d] %(message)s")
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler with daily rotation, keeping 14 days
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
LOG_FILE,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=14,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging to: {LOG_FILE}")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||
ASSETS_DIR = os.path.join(OUTPUT_DIR, "assets")
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
TEMPLATE_DIR = os.path.join(BASE_DIR, "templates")
|
||||
TEMPLATE_DB_PATH = os.path.join(DATA_DIR, "user_templates.json")
|
||||
VERSIONS_DB_PATH = os.path.join(DATA_DIR, "versions.json")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(TEMPLATE_DIR, exist_ok=True)
|
||||
|
||||
OPENAI_API_KEY = os.environ["STIRLING_OPENAI_API_KEY"]
|
||||
OPENAI_BASE_URL = os.environ["STIRLING_OPENAI_BASE_URL"]
|
||||
ANTHROPIC_API_KEY = os.environ["STIRLING_ANTHROPIC_API_KEY"]
|
||||
JAVA_BACKEND_URL = os.environ["STIRLING_JAVA_BACKEND_URL"]
|
||||
JAVA_BACKEND_API_KEY = os.environ["STIRLING_JAVA_BACKEND_API_KEY"]
|
||||
JAVA_REQUEST_TIMEOUT_SECONDS = float(os.environ["STIRLING_JAVA_REQUEST_TIMEOUT_SECONDS"])
|
||||
|
||||
if not OPENAI_API_KEY and not ANTHROPIC_API_KEY:
|
||||
raise RuntimeError(
|
||||
"Either STIRLING_OPENAI_API_KEY or STIRLING_ANTHROPIC_API_KEY is required to start the AI backend."
|
||||
)
|
||||
SMART_MODEL = os.environ["STIRLING_SMART_MODEL"]
|
||||
FAST_MODEL = os.environ["STIRLING_FAST_MODEL"]
|
||||
|
||||
# GPT-5 reasoning effort configuration
|
||||
# Supported values: minimal, low, medium, high, xhigh
|
||||
# - minimal: Fastest (GPT-5 only)
|
||||
# - low: Speed focused
|
||||
# - medium: Default balance
|
||||
# - high: Quality focused
|
||||
# - xhigh: Maximum quality (GPT-5.2 Pro/Thinking only)
|
||||
SMART_MODEL_REASONING_EFFORT = os.environ["STIRLING_SMART_MODEL_REASONING_EFFORT"]
|
||||
FAST_MODEL_REASONING_EFFORT = os.environ["STIRLING_FAST_MODEL_REASONING_EFFORT"]
|
||||
|
||||
# GPT-5 text verbosity configuration
|
||||
# Supported values: minimal, low, medium, high
|
||||
# Controls output length and detail level
|
||||
SMART_MODEL_TEXT_VERBOSITY = os.environ["STIRLING_SMART_MODEL_TEXT_VERBOSITY"]
|
||||
FAST_MODEL_TEXT_VERBOSITY = os.environ["STIRLING_FAST_MODEL_TEXT_VERBOSITY"]
|
||||
|
||||
FLASK_DEBUG = os.environ["STIRLING_FLASK_DEBUG"] == "1"
|
||||
STREAMING_ENABLED = os.environ["STIRLING_AI_STREAMING"].lower() not in {"0", "false", "no"}
|
||||
if OPENAI_BASE_URL and "ollama" in OPENAI_BASE_URL and not os.environ["STIRLING_AI_STREAMING"]:
|
||||
STREAMING_ENABLED = False
|
||||
PREVIEW_MAX_INFLIGHT = int(os.environ["STIRLING_AI_PREVIEW_MAX_INFLIGHT"])
|
||||
AI_REQUEST_TIMEOUT_SECONDS = float(os.environ["STIRLING_AI_REQUEST_TIMEOUT"])
|
||||
AI_RAW_DEBUG = os.environ["STIRLING_AI_RAW_DEBUG"].lower() not in {"", "0", "false", "no"}
|
||||
AI_MESSAGES_LOG_PATH = LOG_PATH / "ai_messages"
|
||||
AI_MESSAGES_LOG_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def model_max_tokens(model_name: str) -> int:
|
||||
"""
|
||||
Output token limit for a given model.
|
||||
|
||||
This is used by a few routes to avoid provider defaults that are too small for
|
||||
structured outputs. All values can be overridden via env vars.
|
||||
"""
|
||||
if os.environ["STIRLING_AI_MAX_TOKENS"]:
|
||||
return int(os.environ["STIRLING_AI_MAX_TOKENS"])
|
||||
|
||||
if model_name == SMART_MODEL:
|
||||
return int(os.environ["STIRLING_SMART_MODEL_MAX_TOKENS"])
|
||||
if model_name == FAST_MODEL:
|
||||
return int(os.environ["STIRLING_FAST_MODEL_MAX_TOKENS"])
|
||||
if model_name.startswith("claude"):
|
||||
return int(os.environ["STIRLING_CLAUDE_MAX_TOKENS"])
|
||||
|
||||
return int(os.environ["STIRLING_DEFAULT_MODEL_MAX_TOKENS"])
|
||||
|
||||
|
||||
# PostHog Analytics Configuration
|
||||
POSTHOG_API_KEY = os.environ["STIRLING_POSTHOG_API_KEY"]
|
||||
if not POSTHOG_API_KEY:
|
||||
raise RuntimeError("STIRLING_POSTHOG_API_KEY is required to start the AI backend.")
|
||||
POSTHOG_HOST = os.environ["STIRLING_POSTHOG_HOST"]
|
||||
|
||||
# Initialize PostHog client
|
||||
POSTHOG_CLIENT = Posthog(
|
||||
project_api_key=POSTHOG_API_KEY,
|
||||
host=POSTHOG_HOST,
|
||||
)
|
||||
logger.info(f"PostHog analytics enabled: host={POSTHOG_HOST}")
|
||||
|
||||
POSTHOG_CALLBACK = CallbackHandler(client=POSTHOG_CLIENT)
|
||||
|
||||
|
||||
def get_chat_model(
|
||||
model_name: str,
|
||||
streaming: bool = False,
|
||||
max_tokens: int | None = None,
|
||||
model_kwargs: dict | None = None,
|
||||
callbacks: list | None = None,
|
||||
):
|
||||
# Add PostHog callback if enabled and not already in callbacks
|
||||
if callbacks is None or POSTHOG_CALLBACK not in callbacks:
|
||||
callbacks = [POSTHOG_CALLBACK] if callbacks is None else [*callbacks, POSTHOG_CALLBACK]
|
||||
|
||||
# Check if this is a Claude model
|
||||
if model_name.startswith("claude"):
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY not set")
|
||||
|
||||
kwargs = {
|
||||
"model": model_name,
|
||||
"anthropic_api_key": ANTHROPIC_API_KEY,
|
||||
"streaming": streaming,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
if callbacks:
|
||||
kwargs["callbacks"] = callbacks
|
||||
|
||||
return ChatAnthropic(**kwargs)
|
||||
|
||||
# OpenAI/GPT models
|
||||
kwargs = {"model": model_name, "api_key": OPENAI_API_KEY, "streaming": streaming}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
# Build GPT-5 specific parameters if not provided
|
||||
if model_kwargs is None and model_name.startswith("gpt-5"):
|
||||
# Determine which settings to use based on model
|
||||
if model_name == SMART_MODEL:
|
||||
reasoning_effort = SMART_MODEL_REASONING_EFFORT
|
||||
text_verbosity = SMART_MODEL_TEXT_VERBOSITY
|
||||
elif model_name == FAST_MODEL:
|
||||
reasoning_effort = FAST_MODEL_REASONING_EFFORT
|
||||
text_verbosity = FAST_MODEL_TEXT_VERBOSITY
|
||||
else:
|
||||
# Default for other GPT-5 variants
|
||||
reasoning_effort = "medium"
|
||||
text_verbosity = "medium"
|
||||
|
||||
# Pass as explicit parameters instead of model_kwargs to avoid warnings
|
||||
kwargs["reasoning"] = {"effort": reasoning_effort}
|
||||
kwargs["text"] = {"verbosity": text_verbosity}
|
||||
elif model_kwargs:
|
||||
# If custom model_kwargs provided, pass them through
|
||||
kwargs["model_kwargs"] = model_kwargs
|
||||
|
||||
if callbacks:
|
||||
kwargs["callbacks"] = callbacks
|
||||
return ChatOpenAI(**kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OUTPUT_DIR",
|
||||
"ASSETS_DIR",
|
||||
"DATA_DIR",
|
||||
"TEMPLATE_DIR",
|
||||
"TEMPLATE_DB_PATH",
|
||||
"VERSIONS_DB_PATH",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"JAVA_BACKEND_URL",
|
||||
"JAVA_BACKEND_API_KEY",
|
||||
"JAVA_REQUEST_TIMEOUT_SECONDS",
|
||||
"SMART_MODEL",
|
||||
"get_chat_model",
|
||||
"FAST_MODEL",
|
||||
"SMART_MODEL_REASONING_EFFORT",
|
||||
"FAST_MODEL_REASONING_EFFORT",
|
||||
"SMART_MODEL_TEXT_VERBOSITY",
|
||||
"FAST_MODEL_TEXT_VERBOSITY",
|
||||
"FLASK_DEBUG",
|
||||
"STREAMING_ENABLED",
|
||||
"PREVIEW_MAX_INFLIGHT",
|
||||
"AI_REQUEST_TIMEOUT_SECONDS",
|
||||
"AI_RAW_DEBUG",
|
||||
"AI_MESSAGES_LOG_PATH",
|
||||
"POSTHOG_API_KEY",
|
||||
"POSTHOG_HOST",
|
||||
"POSTHOG_CLIENT",
|
||||
"POSTHOG_CALLBACK",
|
||||
"model_max_tokens",
|
||||
]
|
||||
@@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Advisor Agreement</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Divider ── */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
margin: 6pt 0;
|
||||
}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 130pt auto;
|
||||
row-gap: 2pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Parties ── */
|
||||
.parties-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.party-label {
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-accent, #2563eb);
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
|
||||
.party-name {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 600;
|
||||
color: var(--theme-heading, #111827);
|
||||
margin-bottom: 2pt;
|
||||
}
|
||||
|
||||
.party-detail {
|
||||
font-size: 8pt;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Legal sections ── */
|
||||
.legal-section {
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.legal-section-heading {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-heading, #111827);
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
|
||||
.legal-section-body {
|
||||
font-size: 9pt;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── Sub-section ── */
|
||||
.legal-subsection {
|
||||
margin-top: 6pt;
|
||||
margin-bottom: 6pt;
|
||||
margin-left: 10pt;
|
||||
}
|
||||
|
||||
.legal-subsection-heading {
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-heading, #111827);
|
||||
margin-bottom: 2pt;
|
||||
}
|
||||
|
||||
/* ── Key-value rows (equity details) ── */
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130pt auto;
|
||||
row-gap: 2pt;
|
||||
margin-top: 4pt;
|
||||
}
|
||||
|
||||
.kv-label {
|
||||
font-size: 8.5pt;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.kv-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Signature block ── */
|
||||
.sig-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20pt;
|
||||
margin-top: 16pt;
|
||||
}
|
||||
|
||||
.sig-col {
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.sig-party-label {
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-accent, #2563eb);
|
||||
margin-bottom: 20pt;
|
||||
}
|
||||
|
||||
.sig-line {
|
||||
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||
margin-bottom: 3pt;
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
.sig-field-label {
|
||||
font-size: 7.5pt;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.legal-section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.sig-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||
.sig-col { break-inside: avoid; page-break-inside: avoid; }
|
||||
.parties-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||
.kv-row { break-inside: avoid; page-break-inside: avoid; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">{{AGREEMENT_TITLE}}</div>
|
||||
<div class="doc-subtitle">Advisor Agreement</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Reference:</span> <span class="meta-value">{{AGREEMENT_REF}}</span>
|
||||
<span class="meta-label">Effective Date:</span> <span class="meta-value">{{EFFECTIVE_DATE}}</span>
|
||||
<span class="meta-label">Governing Law:</span> <span class="meta-value">{{GOVERNING_LAW}}</span>
|
||||
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">{{JURISDICTION}}</span>
|
||||
<span class="meta-label">Classification:</span> <span class="meta-value">{{CONFIDENTIALITY_LABEL}}</span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Parties -->
|
||||
<div class="section-heading">Parties</div>
|
||||
<div class="parties-grid">
|
||||
<div>
|
||||
<div class="party-label">Company</div>
|
||||
<div class="party-name">{{COMPANY_LEGAL_NAME}}</div>
|
||||
<div class="party-detail">{{COMPANY_ENTITY_TYPE}}</div>
|
||||
<div class="party-detail">Reg. No.: {{COMPANY_REG_NO}}</div>
|
||||
<div class="party-detail">{{COMPANY_ADDRESS}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="party-label">Advisor</div>
|
||||
<div class="party-name">{{ADVISOR_LEGAL_NAME}}</div>
|
||||
<div class="party-detail">{{ADVISOR_ENTITY_TYPE}}</div>
|
||||
<div class="party-detail">Reg. No.: {{ADVISOR_REG_NO}}</div>
|
||||
<div class="party-detail">{{ADVISOR_ADDRESS}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal sections -->
|
||||
<div class="section-heading">Agreement</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">1. Appointment</div>
|
||||
<div class="legal-section-body">The Company appoints the Advisor to provide advisory services to the Company, and the Advisor accepts such appointment, subject to the terms of this Agreement.
|
||||
|
||||
<strong>Role:</strong> {{ADVISOR_ROLE_TITLE}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">2. Services</div>
|
||||
<div class="legal-section-body">{{SERVICES_DESCRIPTION_TEXT}}</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">2.1 Duties</div>
|
||||
<div class="legal-section-body">{{ADVISOR_DUTIES_ITEMS}}</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">2.2 Communications</div>
|
||||
<div class="legal-section-body">{{MEETING_CADENCE_TEXT}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">3. Term</div>
|
||||
<div class="legal-section-body">{{TERM_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">4. Compensation</div>
|
||||
<div class="legal-section-body">{{COMPENSATION_OVERVIEW_TEXT}}</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.1 Cash Compensation</div>
|
||||
<div class="legal-section-body">{{CASH_COMPENSATION_TEXT}}</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.2 Equity Compensation</div>
|
||||
<div class="legal-section-body">{{EQUITY_COMPENSATION_TEXT}}</div>
|
||||
<div class="kv-row">
|
||||
<span class="kv-label">Equity type:</span> <span class="kv-value">{{EQUITY_TYPE_TEXT}}</span>
|
||||
<span class="kv-label">Equity amount:</span> <span class="kv-value">{{EQUITY_AMOUNT_TEXT}}</span>
|
||||
<span class="kv-label">Exercise / issue price:</span> <span class="kv-value">{{EQUITY_EXERCISE_PRICE_TEXT}}</span>
|
||||
<span class="kv-label">Vesting:</span> <span class="kv-value">{{VESTING_TEXT}}</span>
|
||||
<span class="kv-label">Post-termination exercise:</span> <span class="kv-value">{{POST_TERMINATION_EXERCISE_TEXT}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.3 Expenses</div>
|
||||
<div class="legal-section-body">{{EXPENSES_TEXT}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">5. Confidentiality</div>
|
||||
<div class="legal-section-body">{{CONFIDENTIALITY_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">6. Intellectual Property and Work Product</div>
|
||||
<div class="legal-section-body">{{IP_ASSIGNMENT_TEXT}}</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">6.1 Works Made for Hire</div>
|
||||
<div class="legal-section-body">{{WORKS_MADE_FOR_HIRE_TEXT}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">7. Conflicts of Interest</div>
|
||||
<div class="legal-section-body">{{CONFLICTS_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">8. Non-Solicitation</div>
|
||||
<div class="legal-section-body">{{NON_SOLICIT_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">9. Independent Contractor Status</div>
|
||||
<div class="legal-section-body">{{INDEPENDENT_STATUS_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">10. Warranties</div>
|
||||
<div class="legal-section-body">{{WARRANTY_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">11. Limitation of Liability</div>
|
||||
<div class="legal-section-body">{{LIABILITY_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">12. Indemnity</div>
|
||||
<div class="legal-section-body">{{INDEMNITY_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">13. Termination</div>
|
||||
<div class="legal-section-body">{{TERMINATION_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">14. Notices</div>
|
||||
<div class="legal-section-body">{{NOTICES_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">15. Assignment</div>
|
||||
<div class="legal-section-body">{{ASSIGNMENT_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">16. Amendments</div>
|
||||
<div class="legal-section-body">{{AMENDMENTS_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">17. Entire Agreement</div>
|
||||
<div class="legal-section-body">{{ENTIRE_AGREEMENT_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedules -->
|
||||
<div class="section-heading">Schedules</div>
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-body">{{SCHEDULES_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Signatures -->
|
||||
<div class="section-heading">Signatures</div>
|
||||
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||
<div class="sig-grid">
|
||||
<div class="sig-col">
|
||||
<div class="sig-party-label">For the Company</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Signature</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Name / Title: {{COMPANY_SIGNATORY}}</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||
</div>
|
||||
<div class="sig-col">
|
||||
<div class="sig-party-label">For the Advisor</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Signature</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Name / Title: {{ADVISOR_SIGNATORY}}</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Date: {{SIGNATURE_DATE}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Advisor Agreement — Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
--theme-primary: #1e3a5f;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-secondary: #475569;
|
||||
--theme-bg: #ffffff;
|
||||
--theme-surface: #f8fafc;
|
||||
--theme-border: #e2e8f0;
|
||||
--theme-text: #1a1a1a;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-heading: #111827;
|
||||
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 20mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||
.document { max-width: 170mm; margin: 0 auto; }
|
||||
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||
.doc-header { text-align: center; margin-bottom: 8pt; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||
.meta-grid { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.parties-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin-bottom: 8pt; }
|
||||
.party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 3pt; }
|
||||
.party-name { font-size: 9.5pt; font-weight: 600; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||
.party-detail { font-size: 8pt; color: var(--theme-text-muted); line-height: 1.5; }
|
||||
.legal-section { margin-bottom: 8pt; page-break-inside: avoid; }
|
||||
.legal-section-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||
.legal-section-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); }
|
||||
.legal-subsection { margin-top: 6pt; margin-bottom: 6pt; margin-left: 10pt; }
|
||||
.legal-subsection-heading { font-size: 9pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 2pt; }
|
||||
.kv-row { display: grid; grid-template-columns: 130pt auto; row-gap: 2pt; margin-top: 4pt; }
|
||||
.kv-label { font-size: 8.5pt; font-weight: 600; color: var(--theme-text-muted); }
|
||||
.kv-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 16pt; }
|
||||
.sig-col { font-size: 8.5pt; }
|
||||
.sig-party-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 20pt; }
|
||||
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .legal-section { page-break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">Advisor Agreement</div>
|
||||
<div class="doc-subtitle">Advisor Agreement</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Reference:</span> <span class="meta-value">Ref: ADV-2026-01</span>
|
||||
<span class="meta-label">Effective Date:</span> <span class="meta-value">2 February 2026</span>
|
||||
<span class="meta-label">Governing Law:</span> <span class="meta-value">England and Wales</span>
|
||||
<span class="meta-label">Jurisdiction:</span> <span class="meta-value">Courts of England and Wales</span>
|
||||
<span class="meta-label">Classification:</span> <span class="meta-value">Confidential</span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="section-heading">Parties</div>
|
||||
<div class="parties-grid">
|
||||
<div>
|
||||
<div class="party-label">Company</div>
|
||||
<div class="party-name">Sterling Systems Group Ltd</div>
|
||||
<div class="party-detail">A private limited company</div>
|
||||
<div class="party-detail">Reg. No.: 12345678</div>
|
||||
<div class="party-detail">100 Kingfisher Way, London, EC2V 7AB, United Kingdom</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="party-label">Advisor</div>
|
||||
<div class="party-name">Harbourview Strategy Partners Ltd</div>
|
||||
<div class="party-detail">A private limited company</div>
|
||||
<div class="party-detail">Reg. No.: 87654321</div>
|
||||
<div class="party-detail">8 Riverside Walk, London, SE1 9AB, United Kingdom</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">Agreement</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">1. Appointment</div>
|
||||
<div class="legal-section-body">The Company appoints the Advisor to provide advisory services to the Company, and the Advisor accepts such appointment, subject to the terms of this Agreement.
|
||||
|
||||
<strong>Role:</strong> Strategic Advisor — Go-to-Market and Partnerships</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">2. Services</div>
|
||||
<div class="legal-section-body">The Advisor will provide strategic guidance and introductions to support the Company's go-to-market execution and partnership development. The Advisor will not have authority to bind the Company and will provide services in an advisory capacity only.</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">2.1 Duties</div>
|
||||
<div class="legal-section-body">Provide strategic input on target segments, pricing, and positioning.
|
||||
Facilitate introductions to prospective partners and key customers, where appropriate.
|
||||
Review major partnership proposals and provide feedback on terms and structure.
|
||||
Participate in periodic advisory calls and ad hoc consultations as reasonably requested.</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">2.2 Communications</div>
|
||||
<div class="legal-section-body">The parties anticipate one scheduled call per month (30–60 minutes), plus reasonable email support, with additional sessions by mutual agreement.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">3. Term</div>
|
||||
<div class="legal-section-body">This Agreement commences on the Effective Date and continues for twelve (12) months unless earlier terminated in accordance with Section 13. The parties may extend the term by written agreement.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">4. Compensation</div>
|
||||
<div class="legal-section-body">In consideration for the Services, the Company will provide the compensation described below, subject to the terms of this Agreement and any applicable equity plan documents.</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.1 Cash Compensation</div>
|
||||
<div class="legal-section-body">No cash compensation will be paid unless agreed in writing in advance for specific projects.</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.2 Equity Compensation</div>
|
||||
<div class="legal-section-body">The Company will grant the Advisor an equity award under the Company's equity incentive plan, subject to board/committee approval and the definitive equity grant documentation.</div>
|
||||
<div class="kv-row">
|
||||
<span class="kv-label">Equity type:</span> <span class="kv-value">Non-qualified stock options (or equivalent plan award)</span>
|
||||
<span class="kv-label">Equity amount:</span> <span class="kv-value">Options representing 0.25% of fully diluted share capital (on grant date basis)</span>
|
||||
<span class="kv-label">Exercise / issue price:</span> <span class="kv-value">Exercise price per the plan / fair market value on grant date</span>
|
||||
<span class="kv-label">Vesting:</span> <span class="kv-value">24 months vesting with monthly vesting, subject to continued service as advisor</span>
|
||||
<span class="kv-label">Post-termination exercise:</span> <span class="kv-value">90 days to exercise vested options following termination (unless plan states otherwise)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">4.3 Expenses</div>
|
||||
<div class="legal-section-body">Reasonable, pre-approved out-of-pocket expenses will be reimbursed at cost upon receipt of valid supporting documentation.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">5. Confidentiality</div>
|
||||
<div class="legal-section-body">Each party shall keep confidential all non-public information disclosed by the other in connection with this Agreement and shall use such information solely to perform its obligations or exercise its rights under this Agreement.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">6. Intellectual Property and Work Product</div>
|
||||
<div class="legal-section-body">To the extent the Advisor creates any work product, inventions, discoveries, or materials specifically for the Company in the course of providing the Services ("Work Product"), the Advisor hereby assigns to the Company all right, title, and interest in such Work Product upon creation, subject to any pre-existing materials retained by the Advisor.</div>
|
||||
<div class="legal-subsection">
|
||||
<div class="legal-subsection-heading">6.1 Works Made for Hire</div>
|
||||
<div class="legal-section-body">Where permitted by law, Work Product shall be deemed "works made for hire" for the Company. To the extent not so deemed, the Advisor assigns all rights in and to the Work Product to the Company as set out above.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">7. Conflicts of Interest</div>
|
||||
<div class="legal-section-body">The Advisor will avoid conflicts of interest and will promptly disclose any actual or potential conflict relating to the Services. The Advisor will not use the Company's confidential information to benefit any third party.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">8. Non-Solicitation</div>
|
||||
<div class="legal-section-body">During the term of this Agreement and for six (6) months thereafter, the Advisor shall not knowingly solicit for employment any employee of the Company with whom the Advisor had material contact in connection with the Services, except through general advertisements not targeted at such employees.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">9. Independent Contractor Status</div>
|
||||
<div class="legal-section-body">The Advisor is an independent contractor and not an employee, worker, agent, or partner of the Company. The Advisor is responsible for all taxes and statutory obligations arising from compensation under this Agreement.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">10. Warranties</div>
|
||||
<div class="legal-section-body">The Advisor warrants that the Services will be performed with reasonable skill and care. Except as expressly stated, all warranties are excluded to the fullest extent permitted by law.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">11. Limitation of Liability</div>
|
||||
<div class="legal-section-body">Neither party shall be liable for indirect or consequential damages. Each party's aggregate liability arising out of or in connection with this Agreement shall not exceed the total value of compensation paid or payable under this Agreement, except where liability cannot be limited by law.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">12. Indemnity</div>
|
||||
<div class="legal-section-body">Each party will indemnify the other against third-party claims arising from its gross negligence or wilful misconduct in connection with this Agreement, subject to the limitation of liability set out herein.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">13. Termination</div>
|
||||
<div class="legal-section-body">Either party may terminate this Agreement for convenience upon fourteen (14) days' written notice. Either party may terminate immediately for material breach if not cured within fourteen (14) days of written notice. Upon termination, unvested equity (if any) will cease to vest in accordance with the applicable plan and grant documents.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">14. Notices</div>
|
||||
<div class="legal-section-body">Notices must be in writing and delivered by email and/or registered post to the addresses set out in the Parties section (or as updated by notice).</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">15. Assignment</div>
|
||||
<div class="legal-section-body">Neither party may assign this Agreement without the prior written consent of the other, except to an affiliate or in connection with a merger or sale of substantially all assets.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">16. Amendments</div>
|
||||
<div class="legal-section-body">Any amendment to this Agreement must be in writing and signed by both parties.</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-heading">17. Entire Agreement</div>
|
||||
<div class="legal-section-body">This Agreement constitutes the entire agreement between the parties regarding its subject matter and supersedes all prior discussions and understandings.</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">Schedules</div>
|
||||
<div class="legal-section">
|
||||
<div class="legal-section-body">Schedule 1 — Scope of Services and Deliverables (if applicable)
|
||||
Schedule 2 — Equity Award Details (plan, grant date, vesting, and exercise terms)</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">Signatures</div>
|
||||
<div class="legal-section-body">IN WITNESS WHEREOF, the parties have caused this Agreement to be executed by their duly authorised representatives.</div>
|
||||
<div class="sig-grid">
|
||||
<div class="sig-col">
|
||||
<div class="sig-party-label">For the Company</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Signature</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Name / Title: Alexandra Moore, Director</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Date: 2 February 2026</div>
|
||||
</div>
|
||||
<div class="sig-col">
|
||||
<div class="sig-party-label">For the Advisor</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Signature</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Name / Title: Priya Shah, Director</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Date: 2 February 2026</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Report</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Divider ── */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
margin: 6pt 0;
|
||||
}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110pt auto;
|
||||
row-gap: 2pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Text sections ── */
|
||||
.text-section {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4pt;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── Findings table ── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8pt;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.data-table thead tr {
|
||||
background: var(--theme-primary, #1e3a5f);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
padding: 5pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-table tbody tr:nth-child(even) {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
padding: 4pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* ── Risk level badges ── */
|
||||
.risk-high {
|
||||
display: inline-block;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
font-weight: 700;
|
||||
font-size: 7.5pt;
|
||||
padding: 1pt 5pt;
|
||||
border-radius: 2pt;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-medium {
|
||||
display: inline-block;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-weight: 700;
|
||||
font-size: 7.5pt;
|
||||
padding: 1pt 5pt;
|
||||
border-radius: 2pt;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-low {
|
||||
display: inline-block;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-weight: 700;
|
||||
font-size: 7.5pt;
|
||||
padding: 1pt 5pt;
|
||||
border-radius: 2pt;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Sign-off ── */
|
||||
.signoff-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20pt;
|
||||
margin-top: 14pt;
|
||||
}
|
||||
|
||||
.signoff-col {
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.signoff-label {
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-accent, #2563eb);
|
||||
margin-bottom: 18pt;
|
||||
}
|
||||
|
||||
.sig-line {
|
||||
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||
margin-bottom: 3pt;
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
.sig-field-label {
|
||||
font-size: 7.5pt;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.data-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
.data-table tbody tr { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">{{REPORT_TITLE}}</div>
|
||||
<div class="doc-subtitle">{{ORG_NAME}} · Ref: {{AUDIT_REF}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Audit Period:</span> <span class="meta-value">{{AUDIT_PERIOD}}</span>
|
||||
<span class="meta-label">Prepared By:</span> <span class="meta-value">{{PREPARED_BY}}</span>
|
||||
<span class="meta-label">Report Date:</span> <span class="meta-value">{{REPORT_DATE}}</span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Executive Summary -->
|
||||
<div class="section-heading">Executive Summary</div>
|
||||
<div class="text-section">{{EXECUTIVE_SUMMARY_TEXT}}</div>
|
||||
|
||||
<!-- Scope and Objectives -->
|
||||
<div class="section-heading">Scope and Objectives</div>
|
||||
<div class="text-section">{{SCOPE_TEXT}}</div>
|
||||
|
||||
<!-- Methodology -->
|
||||
<div class="section-heading">Methodology</div>
|
||||
<div class="text-section">{{METHODOLOGY_TEXT}}</div>
|
||||
|
||||
<!-- Findings -->
|
||||
<div class="section-heading">Findings</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:30%">Finding</th>
|
||||
<th style="width:12%">Risk Level</th>
|
||||
<th style="width:25%">Recommendation</th>
|
||||
<th style="width:28%">Management Response</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{FINDINGS_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Conclusion -->
|
||||
<div class="section-heading">Conclusion</div>
|
||||
<div class="text-section">{{CONCLUSION_TEXT}}</div>
|
||||
|
||||
<!-- Sign-off -->
|
||||
<div class="section-heading">Sign-Off</div>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">{{SIGN_DATE}}</span>
|
||||
</div>
|
||||
<div class="signoff-grid">
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Lead Auditor</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">{{AUDITOR_NAME}}</div>
|
||||
<div class="sig-field-label">{{AUDITOR_TITLE}}</div>
|
||||
</div>
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Reviewed By</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Authorised Signatory</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Report</title>
|
||||
<style>
|
||||
:root {
|
||||
--theme-primary: #1e3a5f;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-secondary: #475569;
|
||||
--theme-bg: #ffffff;
|
||||
--theme-surface: #f8fafc;
|
||||
--theme-border: #e2e8f0;
|
||||
--theme-text: #1a1a1a;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-heading: #111827;
|
||||
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 20mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||
.document { max-width: 170mm; margin: 0 auto; }
|
||||
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||
.doc-header-text { flex: 1; text-align: center; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.6; margin-bottom: 4pt; }
|
||||
.findings-table { width: 100%; border-collapse: collapse; margin-bottom: 8pt; font-size: 8pt; }
|
||||
.findings-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||
.findings-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||
.findings-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||
.findings-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||
.risk-high { display: inline-block; background: #fee2e2; color: #991b1b; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||
.risk-medium { display: inline-block; background: #fef3c7; color: #92400e; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||
.risk-low { display: inline-block; background: #dcfce7; color: #166534; font-weight: 700; font-size: 7.5pt; padding: 1pt 5pt; border-radius: 2pt; white-space: nowrap; }
|
||||
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||
.signoff-col { font-size: 8.5pt; }
|
||||
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .findings-table tbody tr { page-break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<div class="top-bar"></div>
|
||||
<div class="doc-header">
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">Audit Report</div>
|
||||
<div class="doc-subtitle">Vantage Capital Partners Ltd</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Audit Period:</span> <span class="meta-value">1 October 2025 – 31 January 2026</span>
|
||||
<span class="meta-label">Prepared By:</span> <span class="meta-value">Dominic Hartley, Head of Internal Audit</span>
|
||||
<span class="meta-label">Report Date:</span> <span class="meta-value">14 February 2026</span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="section-heading">Executive Summary</div>
|
||||
<div class="text-section">This internal audit was conducted to assess the effectiveness of Vantage Capital Partners' procurement and supplier management framework across all UK business units. The audit covered the period 1 October 2025 to 31 January 2026 and encompassed purchase order processes, supplier onboarding controls, contract management and invoice approval workflows.
|
||||
|
||||
Overall, the procurement function operates with a reasonable control environment. However, the audit identified three significant findings and two areas of improvement, as detailed below. Management has acknowledged the findings and proposed remediation actions with target completion dates. The overall audit opinion is Partially Satisfactory.</div>
|
||||
|
||||
<div class="section-heading">Scope and Objectives</div>
|
||||
<div class="text-section">The audit was scoped to cover: (1) purchase order authorisation and segregation of duties; (2) supplier due diligence and onboarding procedures; (3) contract register completeness and renewal monitoring; (4) three-way matching of purchase orders, delivery notes and invoices; and (5) compliance with the Group Procurement Policy (v3.1, updated September 2025).
|
||||
|
||||
Out of scope: IT systems procurement governed by the Technology Steering Committee and capital expenditure projects above £500,000 (subject to separate Board approval processes).
|
||||
|
||||
The objectives were to provide assurance that procurement activities are conducted with appropriate controls, value for money is achieved, and risks of fraud and error are adequately mitigated.</div>
|
||||
|
||||
<div class="section-heading">Methodology</div>
|
||||
<div class="text-section">The audit was conducted in accordance with the Chartered Institute of Internal Auditors (CIIA) Standards. Fieldwork comprised: structured interviews with the Head of Finance Operations, two Procurement Managers and the Legal Counsel; walkthrough testing of the purchase-to-pay process; review of a random sample of 45 purchase orders raised between October 2025 and January 2026; analysis of the supplier register and contract database; and examination of invoice approval audit logs from the Oracle Fusion ERP system.</div>
|
||||
|
||||
<div class="section-heading">Findings</div>
|
||||
<table class="findings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5%">#</th>
|
||||
<th style="width:30%">Finding</th>
|
||||
<th style="width:12%">Risk Level</th>
|
||||
<th style="width:25%">Recommendation</th>
|
||||
<th style="width:28%">Management Response</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>11 of 45 purchase orders sampled (24%) lacked a second approver for values between £25,000–£100,000, contrary to the Group Procurement Policy requirement for dual authorisation above £10,000.</td>
|
||||
<td><span class="risk-high">High</span></td>
|
||||
<td>Enforce dual-authorisation controls within Oracle Fusion for all POs above £10,000 and implement automated escalation alerts for non-compliance.</td>
|
||||
<td>Agreed. Finance Operations will reconfigure Oracle Fusion approval workflows by 31 March 2026. A mandatory awareness communication will be issued to all budget holders by 28 February 2026. Owner: Head of Finance Operations.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>The supplier register contained 38 active suppliers with expired due diligence documentation (company credit checks and Modern Slavery Act questionnaires older than 24 months).</td>
|
||||
<td><span class="risk-high">High</span></td>
|
||||
<td>Implement an annual supplier re-accreditation cycle and introduce automated expiry alerts within the supplier management module.</td>
|
||||
<td>Agreed. Procurement will conduct a full supplier re-accreditation exercise by 30 April 2026. Automated alerts will be configured in the ERP system by 31 March 2026. Owner: Group Procurement Manager.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>17 contracts (22% of the active contract register) had no documented renewal or expiry review scheduled, creating a risk of inadvertent auto-renewal on unfavourable terms.</td>
|
||||
<td><span class="risk-medium">Medium</span></td>
|
||||
<td>Assign a contract owner to each active contract and establish a 90-day advance review process within the contract management system.</td>
|
||||
<td>Agreed. Legal Counsel will update the contract register and assign ownership by 31 March 2026. A 90-day review process will be embedded in the contract management system by 30 April 2026. Owner: Legal Counsel.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>Invoice coding errors were identified in 6% of sampled invoices, with amounts incorrectly posted to cost centres due to the absence of a mandatory cost-centre validation rule in Oracle Fusion.</td>
|
||||
<td><span class="risk-low">Low</span></td>
|
||||
<td>Introduce a mandatory cost-centre validation rule at invoice entry stage and provide refresher training to accounts payable staff.</td>
|
||||
<td>Agreed. IT will implement the validation rule by 28 February 2026. Training sessions will be arranged for accounts payable by 14 March 2026. Owner: Head of Finance Operations.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-heading">Conclusion</div>
|
||||
<div class="text-section">The audit concludes that the procurement and supplier management function operates with a partially satisfactory control environment. The two high-risk findings relating to purchase order authorisation and supplier due diligence represent the most significant risks and require prompt remediation. Management has responded constructively and the agreed actions, if implemented to the stated deadlines, should substantially improve the control framework. Internal Audit will conduct a follow-up review in July 2026 to verify implementation of all agreed actions.</div>
|
||||
|
||||
<div class="section-heading">Sign-Off</div>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">14 February 2026</span>
|
||||
</div>
|
||||
<div class="signoff-grid">
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Lead Auditor</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Dominic Hartley</div>
|
||||
<div class="sig-field-label">Head of Internal Audit</div>
|
||||
</div>
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Reviewed By</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Authorised Signatory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Board Minutes</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Divider ── */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
margin: 6pt 0;
|
||||
}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110pt auto;
|
||||
row-gap: 2pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Attendance tables ── */
|
||||
.attendance-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.attendance-table thead tr {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.attendance-table thead th {
|
||||
padding: 4pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-border, #e2e8f0);
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.attendance-table tbody td {
|
||||
padding: 3pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
}
|
||||
|
||||
/* ── Agenda items ── */
|
||||
.agenda-item {
|
||||
margin-bottom: 10pt;
|
||||
padding-left: 8pt;
|
||||
border-left: 2pt solid var(--theme-accent, #2563eb);
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.agenda-heading {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-heading, #111827);
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
|
||||
.agenda-body {
|
||||
font-size: 9pt;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
white-space: pre-line;
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
|
||||
.resolution-box {
|
||||
font-size: 8.5pt;
|
||||
font-weight: 600;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
border: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
padding: 4pt 6pt;
|
||||
margin-top: 4pt;
|
||||
}
|
||||
|
||||
/* ── Resolutions summary table ── */
|
||||
.resolutions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.resolutions-table thead tr {
|
||||
background: var(--theme-primary, #1e3a5f);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.resolutions-table thead th {
|
||||
padding: 5pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resolutions-table tbody tr:nth-child(even) {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.resolutions-table tbody td {
|
||||
padding: 4pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* ── Sign-off ── */
|
||||
.signoff-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20pt;
|
||||
margin-top: 14pt;
|
||||
}
|
||||
|
||||
.signoff-col {
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.signoff-label {
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-accent, #2563eb);
|
||||
margin-bottom: 18pt;
|
||||
}
|
||||
|
||||
.sig-line {
|
||||
border-bottom: 0.75pt solid var(--theme-text, #1a1a1a);
|
||||
margin-bottom: 3pt;
|
||||
height: 14pt;
|
||||
}
|
||||
|
||||
.sig-field-label {
|
||||
font-size: 7.5pt;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.agenda-item { break-inside: avoid; page-break-inside: avoid; }
|
||||
.signoff-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||
.resolutions-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
.agenda-item { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">{{DOC_TITLE}}</div>
|
||||
<div class="doc-subtitle">{{ORG_NAME}} · Ref: {{DOC_REF}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">{{MEETING_DATE}}</span>
|
||||
<span class="meta-label">Time:</span> <span class="meta-value">{{MEETING_TIME}}</span>
|
||||
<span class="meta-label">Location:</span> <span class="meta-value">{{MEETING_LOCATION}}</span>
|
||||
<span class="meta-label">Chair:</span> <span class="meta-value">{{CHAIR_NAME}}</span>
|
||||
<span class="meta-label">Secretary:</span> <span class="meta-value">{{SECRETARY_NAME}}</span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Present -->
|
||||
<div class="section-heading">Attendance</div>
|
||||
<table class="attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">Name</th>
|
||||
<th style="width:35%">Title / Role</th>
|
||||
<th style="width:25%">Organisation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{PRESENT_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Apologies -->
|
||||
<div class="section-heading">Apologies</div>
|
||||
<table class="attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">Name</th>
|
||||
<th style="width:60%">Title / Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{APOLOGIES_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Matters and Business -->
|
||||
<div class="section-heading">Matters and Business of the Meeting</div>
|
||||
{{AGENDA_ITEMS}}
|
||||
|
||||
<!-- Resolutions Summary -->
|
||||
<div class="section-heading">Resolutions Summary</div>
|
||||
<table class="resolutions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:12%">Ref</th>
|
||||
<th style="width:60%">Resolution</th>
|
||||
<th style="width:28%">Carried By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{RESOLUTIONS_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Approval -->
|
||||
<div class="section-heading">Approval</div>
|
||||
<p style="font-size:8.5pt; margin-bottom:10pt;">These minutes are a true and accurate record of the proceedings of the above meeting.</p>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date Approved:</span> <span class="meta-value">{{APPROVAL_DATE}}</span>
|
||||
</div>
|
||||
<div class="signoff-grid">
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Chair</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">{{CHAIR_SIGN_NAME}}</div>
|
||||
</div>
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Secretary</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">{{SECRETARY_SIGN_NAME}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Board Minutes</title>
|
||||
<style>
|
||||
:root {
|
||||
--theme-primary: #1e3a5f;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-secondary: #475569;
|
||||
--theme-bg: #ffffff;
|
||||
--theme-surface: #f8fafc;
|
||||
--theme-border: #e2e8f0;
|
||||
--theme-text: #1a1a1a;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-heading: #111827;
|
||||
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 20mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||
.document { max-width: 170mm; margin: 0 auto; }
|
||||
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||
.doc-header-text { flex: 1; text-align: center; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||
hr { border: none; border-top: 0.5pt solid var(--theme-border); margin: 6pt 0; }
|
||||
.meta-grid { display: grid; grid-template-columns: 110pt auto; row-gap: 2pt; margin-bottom: 8pt; }
|
||||
.meta-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||
.meta-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.attendance-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||
.attendance-table thead tr { background: var(--theme-surface); }
|
||||
.attendance-table thead th { padding: 4pt 6pt; text-align: left; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-border); font-size: 8pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.attendance-table tbody td { padding: 3pt 6pt; border-bottom: 0.3pt solid var(--theme-border); }
|
||||
.agenda-item { margin-bottom: 10pt; padding-left: 8pt; border-left: 2pt solid var(--theme-accent); page-break-inside: avoid; }
|
||||
.agenda-heading { font-size: 9.5pt; font-weight: 700; color: var(--theme-heading); margin-bottom: 3pt; }
|
||||
.agenda-body { font-size: 9pt; line-height: 1.6; color: var(--theme-text); margin-bottom: 4pt; }
|
||||
.resolution-box { font-size: 8.5pt; font-weight: 600; color: var(--theme-primary); background: var(--theme-surface); border: 0.5pt solid var(--theme-border); padding: 4pt 6pt; margin-top: 4pt; }
|
||||
.resolutions-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||
.resolutions-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||
.resolutions-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; }
|
||||
.resolutions-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||
.resolutions-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||
.signoff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20pt; margin-top: 14pt; }
|
||||
.signoff-col { font-size: 8.5pt; }
|
||||
.signoff-label { font-size: 8pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--theme-accent); margin-bottom: 18pt; }
|
||||
.sig-line { border-bottom: 0.75pt solid var(--theme-text); margin-bottom: 3pt; height: 14pt; }
|
||||
.sig-field-label { font-size: 7.5pt; color: var(--theme-text-muted); margin-bottom: 10pt; }
|
||||
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .agenda-item { page-break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<div class="top-bar"></div>
|
||||
<div class="doc-header">
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">Board Minutes</div>
|
||||
<div class="doc-subtitle">Hartwell Group plc</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">12 March 2026</span>
|
||||
<span class="meta-label">Time:</span> <span class="meta-value">09:00 – 11:30</span>
|
||||
<span class="meta-label">Location:</span> <span class="meta-value">Boardroom, 14 Cavendish Square, London W1G 0PH</span>
|
||||
<span class="meta-label">Chair:</span> <span class="meta-value">Dame Catherine Ashworth, Non-Executive Chair</span>
|
||||
<span class="meta-label">Secretary:</span> <span class="meta-value">Richard Tewkesbury, Company Secretary</span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="section-heading">Attendance</div>
|
||||
<table class="attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">Name</th>
|
||||
<th style="width:35%">Title / Role</th>
|
||||
<th style="width:25%">Organisation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Dame Catherine Ashworth</td><td>Non-Executive Chair</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Jonathan Pemberton</td><td>Chief Executive Officer</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Fiona Caldecott</td><td>Chief Financial Officer</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Marcus Osei-Bonsu</td><td>Chief Operating Officer</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Dr Priya Venkataraman</td><td>Non-Executive Director</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Alastair Drummond</td><td>Senior Independent Director</td><td>Hartwell Group plc</td></tr>
|
||||
<tr><td>Richard Tewkesbury</td><td>Company Secretary</td><td>Hartwell Group plc</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-heading">Apologies</div>
|
||||
<table class="attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">Name</th>
|
||||
<th style="width:60%">Title / Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Helena Forsythe</td><td>Non-Executive Director</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-heading">Matters and Business of the Meeting</div>
|
||||
|
||||
<div class="agenda-item">
|
||||
<div class="agenda-heading">1. Apologies and Conflicts of Interest</div>
|
||||
<div class="agenda-body">The Chair noted apologies received from Helena Forsythe. No conflicts of interest were declared with respect to items on the agenda. The Chair confirmed that a quorum was present and the meeting was duly constituted.</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-item">
|
||||
<div class="agenda-heading">2. Minutes of Previous Meeting</div>
|
||||
<div class="agenda-body">The minutes of the Board meeting held on 12 February 2026 (Ref: BM-2026-02) were reviewed and confirmed as a true and accurate record.</div>
|
||||
<div class="resolution-box">Resolution: The Board approved the minutes of the meeting held on 12 February 2026 as a true and accurate record. Proposed by J. Pemberton; seconded by F. Caldecott. Carried unanimously.</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-item">
|
||||
<div class="agenda-heading">3. Chief Executive's Report</div>
|
||||
<div class="agenda-body">Jonathan Pemberton presented the CEO's report for the period ending 28 February 2026. Revenue performance was 4.2% ahead of budget, driven by stronger-than-anticipated demand in the Infrastructure Services division. The Board noted ongoing recruitment challenges in the Technology division and requested a detailed workforce plan be presented at the April meeting. A tender submission for the Northern Rail Framework contract, valued at approximately £42m over four years, was confirmed as proceeding on schedule.</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-item">
|
||||
<div class="agenda-heading">4. Financial Performance and Treasury Update</div>
|
||||
<div class="agenda-body">Fiona Caldecott presented the management accounts for February 2026. Group EBITDA stood at £8.3m against a budget of £7.9m. Net debt reduced to £14.1m, reflecting strong cash conversion. The Board approved the revised cash flow forecast for Q2 2026. Fiona noted that the revolving credit facility renewal negotiations with Barclays were progressing well and heads of terms were expected by end of March.</div>
|
||||
<div class="resolution-box">Resolution: The Board approved the revised Q2 2026 cash flow forecast as presented. Carried unanimously.</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-item">
|
||||
<div class="agenda-heading">5. Any Other Business</div>
|
||||
<div class="agenda-body">Alastair Drummond raised the matter of the forthcoming AGM scheduled for 22 May 2026. Richard Tewkesbury confirmed that notice documents are on track for dispatch by 10 April 2026. The Chair thanked all Directors for their contributions and confirmed the next scheduled Board meeting.</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">Resolutions Summary</div>
|
||||
<table class="resolutions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:12%">Ref</th>
|
||||
<th style="width:60%">Resolution</th>
|
||||
<th style="width:28%">Carried By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>R-01</td><td>Approval of minutes of meeting held 12 February 2026</td><td>Unanimous</td></tr>
|
||||
<tr><td>R-02</td><td>Approval of revised Q2 2026 cash flow forecast</td><td>Unanimous</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-heading">Approval</div>
|
||||
<p style="font-size:8.5pt; margin-bottom:10pt;">These minutes are a true and accurate record of the proceedings of the above meeting.</p>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date Approved:</span> <span class="meta-value">9 April 2026</span>
|
||||
</div>
|
||||
<div class="signoff-grid">
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Chair</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Dame Catherine Ashworth</div>
|
||||
</div>
|
||||
<div class="signoff-col">
|
||||
<div class="signoff-label">Secretary</div>
|
||||
<div class="sig-line"></div>
|
||||
<div class="sig-field-label">Richard Tewkesbury</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Budget Proposal</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10pt;
|
||||
margin: 8pt 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 9pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Text sections ── */
|
||||
.text-section {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4pt;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.items-table thead tr {
|
||||
background: var(--theme-primary, #1e3a5f);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.items-table thead th {
|
||||
padding: 5pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.items-table thead th.num { text-align: right; }
|
||||
|
||||
.items-table tbody tr:nth-child(even) {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.items-table tbody td {
|
||||
padding: 4pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.items-table tbody td.num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Net summary ── */
|
||||
.totals-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.totals-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 8.5pt;
|
||||
min-width: 160pt;
|
||||
}
|
||||
|
||||
.totals-table td { padding: 2pt 6pt; }
|
||||
|
||||
.totals-table td:first-child {
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.totals-table td:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totals-table .total-row td {
|
||||
font-weight: 700;
|
||||
font-size: 10pt;
|
||||
border-top: 1pt solid var(--theme-primary, #1e3a5f);
|
||||
padding-top: 4pt;
|
||||
color: var(--theme-heading, #111827);
|
||||
}
|
||||
|
||||
.totals-table .net-row td {
|
||||
font-weight: 700;
|
||||
font-size: 10pt;
|
||||
color: var(--theme-accent, #2563eb);
|
||||
}
|
||||
|
||||
/* ── Approval block ── */
|
||||
.approval-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120pt auto;
|
||||
row-gap: 3pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.approval-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.approval-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.totals-wrapper { break-inside: avoid; page-break-inside: avoid; }
|
||||
.totals-table { break-inside: avoid; page-break-inside: avoid; }
|
||||
.approval-grid { break-inside: avoid; page-break-inside: avoid; }
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">{{PROPOSAL_TITLE}}</div>
|
||||
<div class="doc-subtitle">{{COMPANY_NAME}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<div class="info-label">Budget Period</div>
|
||||
<div class="info-value">{{BUDGET_PERIOD}}</div>
|
||||
<div class="info-label" style="margin-top:4pt">Prepared By</div>
|
||||
<div class="info-value">{{PREPARED_BY}}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="info-label">Prepared Date</div>
|
||||
<div class="info-value">{{PREPARED_DATE}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Executive Summary -->
|
||||
<div class="section-heading">Executive Summary</div>
|
||||
<div class="text-section">{{EXECUTIVE_SUMMARY_TEXT}}</div>
|
||||
|
||||
<!-- Revenue Projections -->
|
||||
<div class="section-heading">Revenue Projections</div>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Revenue Stream</th>
|
||||
<th>Description</th>
|
||||
<th class="num" style="width:60pt">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{REVENUE_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-wrapper">
|
||||
<table class="totals-table">
|
||||
<tbody>
|
||||
<tr class="total-row"><td>Total Revenue:</td><td>{{TOTAL_REVENUE}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Expense Breakdown -->
|
||||
<div class="section-heading">Expense Breakdown</div>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th class="num" style="width:60pt">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{EXPENSE_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-wrapper">
|
||||
<table class="totals-table">
|
||||
<tbody>
|
||||
<tr class="total-row"><td>Total Expenses:</td><td>{{TOTAL_EXPENSES}}</td></tr>
|
||||
<tr class="net-row"><td>Net Surplus / (Deficit):</td><td>{{NET_SURPLUS}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assumptions -->
|
||||
<div class="section-heading">Assumptions</div>
|
||||
<div class="text-section">{{ASSUMPTIONS_TEXT}}</div>
|
||||
|
||||
<!-- Approval -->
|
||||
<div class="section-heading">Approval</div>
|
||||
<div class="approval-grid">
|
||||
<span class="approval-label">Approver Name:</span> <span class="approval-value">{{APPROVER_NAME}}</span>
|
||||
<span class="approval-label">Approver Title:</span> <span class="approval-value">{{APPROVER_TITLE}}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Budget Proposal</title>
|
||||
<style>
|
||||
:root {
|
||||
--theme-primary: #1e3a5f;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-secondary: #475569;
|
||||
--theme-bg: #ffffff;
|
||||
--theme-surface: #f8fafc;
|
||||
--theme-border: #e2e8f0;
|
||||
--theme-text: #1a1a1a;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-heading: #111827;
|
||||
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 20mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||
.document { max-width: 170mm; margin: 0 auto; }
|
||||
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||
.doc-header-text { flex: 1; text-align: center; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt; margin: 8pt 0; }
|
||||
.info-label { font-weight: 600; font-size: 8.5pt; color: var(--theme-text-muted); }
|
||||
.info-value { font-size: 9pt; color: var(--theme-text); }
|
||||
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 6pt; font-size: 8.5pt; }
|
||||
.items-table thead tr { background: var(--theme-primary); color: #fff; }
|
||||
.items-table thead th { padding: 5pt 6pt; text-align: left; font-weight: 600; white-space: nowrap; }
|
||||
.items-table thead th.num { text-align: right; }
|
||||
.items-table tbody tr:nth-child(even) { background: var(--theme-surface); }
|
||||
.items-table tbody td { padding: 4pt 6pt; border-bottom: 0.3pt solid var(--theme-border); vertical-align: top; }
|
||||
.items-table tbody td.num { text-align: right; white-space: nowrap; }
|
||||
.totals-wrapper { display: flex; justify-content: flex-end; margin-bottom: 10pt; }
|
||||
.totals-table { border-collapse: collapse; font-size: 8.5pt; min-width: 160pt; }
|
||||
.totals-table td { padding: 2pt 6pt; }
|
||||
.totals-table td:first-child { color: var(--theme-text-muted); }
|
||||
.totals-table td:last-child { text-align: right; }
|
||||
.totals-table .total-row td { font-weight: 700; font-size: 10pt; border-top: 1pt solid var(--theme-primary); padding-top: 4pt; color: var(--theme-heading); }
|
||||
.totals-table .net-row td { font-weight: 700; font-size: 10pt; color: var(--theme-accent); }
|
||||
.approval-grid { display: grid; grid-template-columns: 120pt auto; row-gap: 3pt; margin-bottom: 8pt; }
|
||||
.approval-label { font-weight: 600; color: var(--theme-text-muted); font-size: 8.5pt; }
|
||||
.approval-value { font-size: 8.5pt; color: var(--theme-text); }
|
||||
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<div class="top-bar"></div>
|
||||
<div class="doc-header">
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">Budget Proposal</div>
|
||||
<div class="doc-subtitle">Crestwood Technologies Ltd</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<div class="info-label">Budget Period</div>
|
||||
<div class="info-value">1 April 2026 – 31 March 2027</div>
|
||||
<div class="info-label" style="margin-top:4pt">Prepared By</div>
|
||||
<div class="info-value">Rachel Thornton, Head of Finance</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="info-label">Prepared Date</div>
|
||||
<div class="info-value">19 February 2026</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-heading">Executive Summary</div>
|
||||
<div class="text-section">This proposal outlines the projected revenue and expenditure for the Product Engineering Division for the financial year ending 31 March 2027. Following strong performance in FY 2025/26, the division is seeking approval for an 18% increase in operating budget to accelerate product development, expand the engineering team, and invest in cloud infrastructure to support anticipated customer growth of 35%.
|
||||
|
||||
The net surplus projection of £412,000 reflects disciplined cost management alongside planned investment in headcount and tooling. All figures are presented in GBP and have been reviewed by the CFO.</div>
|
||||
<div class="section-heading">Revenue Projections</div>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Revenue Stream</th>
|
||||
<th>Description</th>
|
||||
<th class="num" style="width:60pt">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>SaaS Subscriptions</td>
|
||||
<td>Recurring licence revenue — existing & new customers</td>
|
||||
<td class="num">£2,800,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Professional Services</td>
|
||||
<td>Implementation, training, and consultancy</td>
|
||||
<td class="num">£480,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marketplace Add-ons</td>
|
||||
<td>Third-party integrations and premium modules</td>
|
||||
<td class="num">£165,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Support Contracts</td>
|
||||
<td>Priority and enterprise support tiers</td>
|
||||
<td class="num">£95,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-wrapper">
|
||||
<table class="totals-table">
|
||||
<tbody>
|
||||
<tr class="total-row"><td>Total Revenue:</td><td>£3,540,000</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section-heading">Expense Breakdown</div>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th class="num" style="width:60pt">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Headcount</td>
|
||||
<td>Salaries, NI, and pension — 34 FTEs (incl. 4 new hires)</td>
|
||||
<td class="num">£1,980,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cloud Infrastructure</td>
|
||||
<td>AWS hosting, storage, and data services</td>
|
||||
<td class="num">£420,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software & Tooling</td>
|
||||
<td>Dev tools, SaaS licences, security platforms</td>
|
||||
<td class="num">£215,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marketing</td>
|
||||
<td>Demand generation, events, content production</td>
|
||||
<td class="num">£180,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>T&E</td>
|
||||
<td>Business travel, client entertainment, conferences</td>
|
||||
<td class="num">£88,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Contingency</td>
|
||||
<td>5% operational reserve</td>
|
||||
<td class="num">£165,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-wrapper">
|
||||
<table class="totals-table">
|
||||
<tbody>
|
||||
<tr class="total-row"><td>Total Expenses:</td><td>£3,048,000</td></tr>
|
||||
<tr class="net-row"><td>Net Surplus / (Deficit):</td><td>£492,000</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section-heading">Assumptions</div>
|
||||
<div class="text-section">1. Revenue growth of 35% year-on-year is based on confirmed pipeline and contracted renewals constituting 72% of the total revenue target.
|
||||
2. Four engineering hires are assumed to be on-boarded by Q1 FY 2026/27; salaries reflect current market rates for mid-senior software engineers in the UK.
|
||||
3. AWS costs include a committed-use discount of 18% negotiated under the existing enterprise agreement.
|
||||
4. All figures exclude capital expenditure, which is budgeted separately under the IT CapEx programme.
|
||||
5. Exchange rate risk on USD-denominated tooling licences is hedged at the treasury level and not reflected here.</div>
|
||||
<div class="section-heading">Approval</div>
|
||||
<div class="approval-grid">
|
||||
<span class="approval-label">Approver Name:</span> <span class="approval-value">Jonathan Crestwood</span>
|
||||
<span class="approval-label">Approver Title:</span> <span class="approval-value">Chief Financial Officer</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Case Study</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80pt auto;
|
||||
row-gap: 4pt;
|
||||
margin: 10pt 0;
|
||||
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
border-bottom: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
padding: 8pt 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 700;
|
||||
font-size: 9pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 9pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Body text ── */
|
||||
.text-section {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4pt;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── Testimonial block ── */
|
||||
.testimonial-block {
|
||||
border-left: 3pt solid var(--theme-accent, #2563eb);
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
padding: 8pt 10pt;
|
||||
margin: 6pt 0;
|
||||
border-radius: 0 3pt 3pt 0;
|
||||
}
|
||||
|
||||
.testimonial-quote {
|
||||
font-size: 9pt;
|
||||
font-style: italic;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4pt;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.testimonial-attribution {
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.testimonial-block { break-inside: avoid; page-break-inside: avoid; }
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">{{CASE_STUDY_TITLE}}</div>
|
||||
<div class="doc-subtitle">Case Study</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Client:</span> <span class="meta-value">{{CLIENT_NAME}}</span>
|
||||
<span class="meta-label">Industry:</span> <span class="meta-value">{{INDUSTRY}}</span>
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">{{DATE}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Client Overview -->
|
||||
<div class="section-heading">Client Overview</div>
|
||||
<div class="text-section">{{CLIENT_OVERVIEW_TEXT}}</div>
|
||||
|
||||
<!-- Challenge -->
|
||||
<div class="section-heading">Challenge</div>
|
||||
<div class="text-section">{{CHALLENGE_TEXT}}</div>
|
||||
|
||||
<!-- Solution -->
|
||||
<div class="section-heading">Solution</div>
|
||||
<div class="text-section">{{SOLUTION_TEXT}}</div>
|
||||
|
||||
<!-- Implementation -->
|
||||
<div class="section-heading">Implementation</div>
|
||||
<div class="text-section">{{IMPLEMENTATION_TEXT}}</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="section-heading">Results</div>
|
||||
<div class="text-section">{{RESULTS_TEXT}}</div>
|
||||
|
||||
<!-- Testimonial -->
|
||||
<div class="section-heading">Client Testimonial</div>
|
||||
<div class="testimonial-block">
|
||||
<div class="testimonial-quote">{{TESTIMONIAL_TEXT}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Conclusion -->
|
||||
<div class="section-heading">Conclusion</div>
|
||||
<div class="text-section">{{CONCLUSION_TEXT}}</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Case Study</title>
|
||||
<style>
|
||||
:root {
|
||||
--theme-primary: #1e3a5f;
|
||||
--theme-accent: #2563eb;
|
||||
--theme-secondary: #475569;
|
||||
--theme-bg: #ffffff;
|
||||
--theme-surface: #f8fafc;
|
||||
--theme-border: #e2e8f0;
|
||||
--theme-text: #1a1a1a;
|
||||
--theme-text-muted: #6b7280;
|
||||
--theme-heading: #111827;
|
||||
--theme-font: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@page { size: A4; margin: 20mm; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--theme-font); font-size: 10pt; color: var(--theme-text); background: var(--theme-bg); line-height: 1.4; }
|
||||
.document { max-width: 170mm; margin: 0 auto; }
|
||||
.top-bar { height: 3pt; background: var(--theme-accent); margin-bottom: 10pt; }
|
||||
.doc-header { display: flex; align-items: center; gap: 12pt; margin-bottom: 8pt; }
|
||||
.doc-header-text { flex: 1; text-align: center; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: var(--theme-primary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3pt; }
|
||||
.doc-subtitle { font-size: 9pt; color: var(--theme-text-muted); }
|
||||
.meta-grid { display: grid; grid-template-columns: 80pt auto; row-gap: 4pt; margin: 10pt 0; border-top: 0.5pt solid var(--theme-border); border-bottom: 0.5pt solid var(--theme-border); padding: 8pt 0; }
|
||||
.meta-label { font-weight: 700; font-size: 9pt; color: var(--theme-text); }
|
||||
.meta-value { font-size: 9pt; color: var(--theme-text); }
|
||||
.section-heading { font-size: 10pt; font-weight: 700; color: var(--theme-primary); border-bottom: 1pt solid var(--theme-accent); padding-bottom: 2pt; margin: 10pt 0 6pt; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.text-section { font-size: 8.5pt; color: var(--theme-text); line-height: 1.5; margin-bottom: 4pt; white-space: pre-line; }
|
||||
.testimonial-block { border-left: 3pt solid var(--theme-accent); background: var(--theme-surface); padding: 8pt 10pt; margin: 6pt 0; border-radius: 0 3pt 3pt 0; }
|
||||
.testimonial-quote { font-size: 9pt; font-style: italic; color: var(--theme-text); line-height: 1.6; margin-bottom: 4pt; white-space: pre-line; }
|
||||
.testimonial-attribution { font-size: 8pt; font-weight: 700; color: var(--theme-primary); }
|
||||
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
<div class="top-bar"></div>
|
||||
<div class="doc-header">
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">Case Study</div>
|
||||
<div class="doc-subtitle">Stirling PDF</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div style="font-size: 11pt; font-weight: 600; color: var(--theme-heading); margin: 8pt 0 10pt;">How Nexus Cloud Reduced Invoice Processing Time by 74%</div>
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Client:</span> <span class="meta-value">Nexus Cloud Ltd</span>
|
||||
<span class="meta-label">Industry:</span> <span class="meta-value">Financial Technology (FinTech)</span>
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">February 2026</span>
|
||||
</div>
|
||||
<div class="section-heading">Client Overview</div>
|
||||
<div class="text-section">Nexus Cloud Ltd is a London-based financial technology company providing accounts-payable automation software to mid-market businesses across the UK and Ireland. Founded in 2017, the company employs 210 staff and processes over 4 million invoices annually on behalf of its 320 enterprise clients. Following a Series B funding round in 2024, Nexus Cloud entered a rapid growth phase, scaling its customer base by 45% in eighteen months.</div>
|
||||
<div class="section-heading">Challenge</div>
|
||||
<div class="text-section">Rapid customer growth exposed significant bottlenecks in Nexus Cloud's internal document management and PDF generation infrastructure. The legacy system — a patchwork of third-party libraries and manual export workflows — was unable to scale with demand. Key problems included:
|
||||
|
||||
- Invoice and statement generation times averaging 8.2 seconds per document, causing SLA breaches during peak periods
|
||||
- No support for branded, client-configurable document templates, resulting in high-value customers receiving generic output
|
||||
- A manual quality-assurance step requiring three finance staff to review approximately 600 documents per day
|
||||
- An inability to generate documents programmatically via API, blocking integrations with downstream ERP systems
|
||||
|
||||
The backlog had grown to 12,000 unprocessed documents at peak, and customer satisfaction scores related to document delivery had fallen to 61%.</div>
|
||||
<div class="section-heading">Solution</div>
|
||||
<div class="text-section">Nexus Cloud partnered with Stirling PDF to deploy a fully integrated document generation and processing pipeline. The solution centred on Stirling PDF's AI document creation capabilities, enabling Nexus Cloud to:
|
||||
|
||||
- Define and version-control a library of 24 branded document templates covering invoices, statements, remittance advice, and credit notes
|
||||
- Generate PDF documents programmatically via a REST API with average end-to-end latency of 2.1 seconds
|
||||
- Apply client-specific theming — including custom fonts, colour schemes, and logos — at the point of generation without manual intervention
|
||||
- Route completed documents directly to Nexus Cloud's distribution service via webhook, eliminating the manual QA step for standard document types</div>
|
||||
<div class="section-heading">Implementation</div>
|
||||
<div class="text-section">The implementation was completed in three phases over eight weeks:
|
||||
|
||||
Phase 1 (Weeks 1–3): Template Migration. Nexus Cloud's design team worked with Stirling PDF's onboarding engineers to convert the 24 legacy document designs into Stirling PDF's HTML-based template format. All templates were validated against a sample of 500 historical documents.
|
||||
|
||||
Phase 2 (Weeks 4–6): API Integration. Nexus Cloud's engineering team integrated the Stirling PDF generation API into their existing invoice processing microservice. A staging environment with synthetic load testing at 200 concurrent requests confirmed that latency remained below 2.5 seconds at the 95th percentile.
|
||||
|
||||
Phase 3 (Weeks 7–8): Parallel Running and Cutover. Both systems ran in parallel for two weeks. A total of 38,000 documents were validated side-by-side. No material discrepancies were identified. The legacy system was decommissioned on 14 October 2025.</div>
|
||||
<div class="section-heading">Results</div>
|
||||
<div class="text-section">Within 90 days of full deployment, Nexus Cloud reported the following outcomes:
|
||||
|
||||
- Document generation time reduced from 8.2 seconds to 2.1 seconds — a 74% improvement
|
||||
- Manual QA headcount reallocated from document review to higher-value tasks; estimated annual saving of £87,000 in operational cost
|
||||
- Zero SLA breaches in November and December 2025 (versus 14 in the same period the prior year)
|
||||
- Customer satisfaction score for document delivery increased from 61% to 89%
|
||||
- 12 enterprise clients activated custom-branded templates within the first 60 days, generating an estimated £34,000 in incremental upsell revenue for Nexus Cloud
|
||||
- The document backlog was eliminated within the first week of operation</div>
|
||||
<div class="section-heading">Client Testimonial</div>
|
||||
<div class="testimonial-block">
|
||||
<div class="testimonial-quote">"Switching to Stirling PDF was one of the smoothest technology transitions we have made. The onboarding team understood our constraints from day one, and the API integration took our engineers less than a week. The impact on our document throughput and customer feedback has been immediate and measurable. We are already planning to extend the platform to cover our client-facing reporting suite in Q2 2026."</div>
|
||||
<div class="testimonial-attribution">— Marcus Webb, VP of Engineering, Nexus Cloud Ltd</div>
|
||||
</div>
|
||||
<div class="section-heading">Conclusion</div>
|
||||
<div class="text-section">Nexus Cloud's experience demonstrates how organisations experiencing rapid growth can use Stirling PDF to eliminate document processing bottlenecks without sacrificing quality or brand consistency. By replacing a fragile legacy stack with a scalable, API-first generation platform, Nexus Cloud recovered significant operational capacity, improved customer satisfaction, and created new commercial opportunities through branded document customisation. The project delivered full return on investment within eleven weeks of go-live.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Committee Agenda</title>
|
||||
<style>
|
||||
/* ── Theme variables (replaced at generation time) ── */
|
||||
{{THEME_CSS}}
|
||||
|
||||
/* ── Print / page setup ── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font, 'Helvetica Neue', Arial, sans-serif);
|
||||
font-size: 10pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
background: var(--theme-bg, #ffffff);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document {
|
||||
max-width: 170mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
{{HEADER_CSS}}
|
||||
|
||||
/* ── Divider ── */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 0.5pt solid var(--theme-border, #e2e8f0);
|
||||
margin: 6pt 0;
|
||||
}
|
||||
|
||||
/* ── Meta grid ── */
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 110pt auto;
|
||||
row-gap: 2pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-muted, #6b7280);
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
}
|
||||
|
||||
/* ── Section heading ── */
|
||||
.section-heading {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-accent, #2563eb);
|
||||
padding-bottom: 2pt;
|
||||
margin: 10pt 0 6pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Attendees table ── */
|
||||
.attendees-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.attendees-table thead tr {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.attendees-table thead th {
|
||||
padding: 4pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
border-bottom: 1pt solid var(--theme-border, #e2e8f0);
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.attendees-table tbody td {
|
||||
padding: 3pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
}
|
||||
|
||||
/* ── Agenda items table ── */
|
||||
.agenda-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.agenda-table thead tr {
|
||||
background: var(--theme-primary, #1e3a5f);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.agenda-table thead th {
|
||||
padding: 5pt 6pt;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agenda-table tbody tr:nth-child(even) {
|
||||
background: var(--theme-surface, #f8fafc);
|
||||
}
|
||||
|
||||
.agenda-table tbody td {
|
||||
padding: 4pt 6pt;
|
||||
border-bottom: 0.3pt solid var(--theme-border, #e2e8f0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.agenda-table .item-num {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary, #1e3a5f);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Text sections ── */
|
||||
.text-section {
|
||||
font-size: 8.5pt;
|
||||
color: var(--theme-text, #1a1a1a);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4pt;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ── Page-break controls ── */
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.section-heading { break-after: avoid; page-break-after: avoid; }
|
||||
.agenda-table tbody tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
|
||||
/* ── Print overrides ── */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.document { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document">
|
||||
|
||||
<div class="top-bar"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
{{LOGO_BLOCK}}
|
||||
<div class="doc-header-text">
|
||||
<div class="doc-title">Agenda</div>
|
||||
<div class="doc-subtitle">{{COMMITTEE_NAME}} · {{ORG_NAME}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="meta-grid">
|
||||
<span class="meta-label">Date:</span> <span class="meta-value">{{MEETING_DATE}}</span>
|
||||
<span class="meta-label">Time:</span> <span class="meta-value">{{MEETING_TIME}}</span>
|
||||
<span class="meta-label">Location:</span> <span class="meta-value">{{MEETING_LOCATION}}</span>
|
||||
<span class="meta-label">Chair:</span> <span class="meta-value">{{CHAIR_NAME}}</span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Expected Attendees -->
|
||||
<div class="section-heading">Expected Attendees</div>
|
||||
<table class="attendees-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">Name</th>
|
||||
<th style="width:35%">Title / Role</th>
|
||||
<th style="width:25%">Organisation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ATTENDEES_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Agenda Items -->
|
||||
<div class="section-heading">Agenda Items</div>
|
||||
<table class="agenda-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:8%">#</th>
|
||||
<th style="width:47%">Topic</th>
|
||||
<th style="width:25%">Presenter</th>
|
||||
<th style="width:20%">Time Allotted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{AGENDA_ROWS}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Documents / Papers Circulated -->
|
||||
<div class="section-heading">Documents and Papers Circulated</div>
|
||||
<div class="text-section">{{DOCUMENTS_TEXT}}</div>
|
||||
|
||||
<!-- Any Other Business -->
|
||||
<div class="section-heading">Any Other Business (AOB)</div>
|
||||
<div class="text-section">{{AOB_TEXT}}</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user