Compare commits

...
Author SHA1 Message Date
Anthony Stirling 2b173a030e text fixing 2025-12-03 10:48:13 +00:00
Anthony Stirling c5525d8676 Merge remote-tracking branch 'origin/V2' into latex2 2025-12-03 09:07:42 +00:00
Reece Browne c3456adc2b Print with embed (#5109) 2025-12-02 13:56:28 +00:00
EthanHealy01 179b569769 Chore/v2/onboarding flow cleanup (#5065) 2025-12-02 12:40:20 +00:00
341adaa07d Grandpa Fix (#5030)
PR to address inactive accounts (invited/pending activation) not being
grandfathered during migration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:38 +00:00
feebfe82fa Reduce JWT Logs (#5108)
Removed logging in some areas and changed level from `WARN` -> `DEBUG`
to reduce verbosity

Closes #5089

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:17 +00:00
ConnorYohandCopilot Autofix powered by AI 1e72416d55 Added file endpoint for license files and easy upload in admin UI (#5055)
<img width="698" height="240" alt="image"
src="https://github.com/user-attachments/assets/f0161e5f-e2ed-44c1-bdd1-93fab46f756b"
/>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-29 19:35:50 +00:00
Anthony Stirling 959d14f075 Bump version from 2.0.1 to 2.0.2 2025-11-29 19:29:58 +00:00
Reece BrowneandClaude 8f6fcee428 Fix: Only block export when signatures are placed but not applied (#5084)
Previously, activating signature placement mode would immediately set
signaturesApplied=false, blocking export even when no signatures were
actually placed on the PDF. This caused the "unapplied signatures"
warning to appear incorrectly after clicking "Apply Signatures".

Changes:
- Remove signaturesApplied=false from activateDrawMode and
activateSignaturePlacementMode in SignatureContext
- Add signaturesApplied=false to onAnnotationEvent handler in
SignatureAPIBridge when event.type === 'create'
- Now signatures are only marked as unapplied when actually placed

This ensures:
- Users can activate placement mode without triggering export warning
- Export is only blocked when signatures are actually placed but not
applied
- After applying signatures, users can immediately export without
warning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 19:29:30 +00:00
Reece BrowneandAnthony Stirling 651f17f1c6 Save signatures to server (#5080)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-11-29 19:29:06 +00:00
Anthony Stirling fde449e738 Use frontend translations for OCR language picker (#5051)
## Summary
- revert OCR ui-data endpoint to only expose language codes without
backend labels
- translate OCR language options on the frontend using existing lang.*
keys with locale-aware fallbacks

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928ae7c85448328a0d2660a0c021b22)
2025-11-29 16:05:15 +00:00
Anthony Stirling 85e9121745 desktop fix debian (#5068)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:43 +00:00
Anthony Stirling 12f1fd485e Audit viewer making api call when shouldnt (#5069)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:29 +00:00
Anthony Stirling b49e8a2355 tauri remote connection fix (#5070)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:19 +00:00
Anthony StirlingandReece Browne d908bc6785 Swagger fixes (#5071)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-11-29 16:03:57 +00:00
Anthony Stirling 4ae79d92ae Fix email invite/ allow non auth and table refresh issues (#5076)
# Description of Changes

- Show warning when email invite fails but user is created
  - Auto-refresh user/team tables after modifications
  - Fix invite email URLs to use frontend URL instead of backend
  - Support anonymous SMTP for local development
---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:03:44 +00:00
Balázs Szücs 85d9b5b83d feat(viewer): Add interactive link layer with (basic) internal/external navigation support (#5077)
# Description of Changes

Added a new `LinkLayer` component to the PDF viewer that renders
clickable overlays for PDF link annotations, enabling both internal page
navigation and external URL links.

- Created `LinkLayer.tsx` component that extracts link annotations using
the EmbedPDF annotation API with fallback to direct PDF document access
- Implemented scale-aware positioning to maintain accurate link hotspots
at different zoom levels
- Added support for internal navigation (GoTo actions) using smooth
scrolling and external links (URI actions) opening in new tabs
- Integrated accessibility features with proper ARIA labels and keyboard
navigation
- Modified `LocalEmbedPDF.tsx` to always register the annotation plugin
(even when editing is disabled) to enable reading existing link
annotations
- Updated `ReactRoutingController.java` and test formatting for code
style consistency


**Key features:**
- Multi-source annotation detection (annotation API → document API →
page API fallback)
- Navigation lock to prevent race conditions
- React performance optimizations (useMemo, useCallback)
- TypeScript type safety for PDF actions and destinations



This does not address support for Attachment links. Sadly, that does not
seem to be possible with EmbedPDF


<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/8a04d15a-79b5-46b6-af8b-3d27246581a7"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/eeb39ca7-a114-4bd5-a4eb-9e8a27331297"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/1ee6bba3-d233-4a11-bf1a-1b56696265e1"
/>


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-29 12:53:26 +00:00
Reece Browne 058a81d554 Improved language select (#5062) 2025-11-28 22:49:23 +00:00
Reece Browne e4c6ce5836 Cookie consent banner and footer on login screen (#5042) 2025-11-28 16:24:10 +00:00
ConnorYoh 250979e271 Desktop Self-hosted guidance improvements (#5060)
Removed timeout when waiting for backend port to allow for slow backend
spinups



<img width="1185" height="951" alt="image"
src="https://github.com/user-attachments/assets/badaf8e5-611d-44aa-aca2-7c1c906c2019"
/>
<img width="1213" height="1533" alt="image"
src="https://github.com/user-attachments/assets/ce78b67a-07e0-4c23-9087-5de0c5f203c6"
/>
<img width="1207" height="1202" alt="image"
src="https://github.com/user-attachments/assets/c6e5b4c5-9cc3-4973-a634-3b7aa1e1dd34"
/>
2025-11-28 15:55:37 +00:00
James Brunton 731743b618 Don't block desktop app on backend starting up (#5041)
# Description of Changes
Start bundled backend instantly on startup of app and don't wait on it
being fully up to spawn app. This is techincally wasteful curently on
self-hosted mode where everything runs remotely, but in the future we'll
probably route simple operations to the local machine regardless of
connection, and it stops unnecessary waiting in the offline mode.
2025-11-27 15:54:35 +00:00
ConnorYoh 04c4aec0d8 Disable admin plan section when no login (#5039)
Admin plan section matches other admin sections

<img width="1013" height="629" alt="image"
src="https://github.com/user-attachments/assets/39e9fad7-461c-491d-99cb-4b140292f2f4"
/>
<img width="730" height="595" alt="image"
src="https://github.com/user-attachments/assets/b26354d2-5401-40b3-8ca7-3b48b26b644e"
/>
2025-11-27 13:57:04 +00:00
Anthony Stirling f63df148ad Create LICENSE 2025-11-27 11:14:15 +00:00
Anthony Stirling 6f0be94bd6 Update LICENSE with proprietary directory details
Added licensing information for proprietary directories.
2025-11-27 11:11:53 +00:00
Anthony Stirling e47ed13be8 Builds custom Jar (#5029)
# Description of Changes

Change jar files to contain frontend if provided with param, else
doesnt... add release artifact -server version which wont have frontend

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-26 17:21:42 +00:00
Reece Browne a62c8b54cf Chore/v2/hide banner in onboard (#5032)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-26 17:19:48 +00:00
Reece Browne 1e34038b1e various cookie banner fixes (#5027)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-26 15:57:04 +00:00
Anthony Stirling d4765938a8 publish GHAs (#5026)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-26 15:22:02 +00:00
Anthony Stirling 476e1505ee Merge pull request #4826 from Stirling-Tools/V2-translations
Recreate translation files
2025-11-26 15:04:15 +00:00
James Brunton d3491ecccf Remove JSON translation files 2025-11-26 14:13:54 +00:00
James Brunton cddd5e7d15 Convert V2 translations to Toml 2025-11-26 14:13:54 +00:00
James Brunton f4c9becce2 Move translation files to V2 2025-11-26 14:06:52 +00:00
James Brunton 7629d89356 Fix login loop on self-hosted desktop (#5022)
# Description of Changes
Fix #5017 

Changes the handling of jwt tokens to be stored in local storage as well
as OS keyring so the rest of the app knows that you're logged in.
2025-11-26 13:51:12 +00:00
ConnorYoh 9fdb5295cb Added posthog variables directly to code (#5024) 2025-11-26 12:32:02 +00:00
EthanHealy01 21657a3fa6 fix the text color for the first time setup with default credentials (#5023)
# Description of Changes

Fix for this:
<img width="584" height="203" alt="Screenshot 2025-11-26 at 11 58 17 AM"
src="https://github.com/user-attachments/assets/fb5ea4ae-e42a-407a-a890-933d9f6db829"
/>

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-26 12:14:04 +00:00
James Brunton bc11875081 Fix CORS issues in desktop app (#5019)
# Description of Changes
In the 2.0.0 release version, the frontend can't connect to the backend
on my machine because all the network requests 403. I think this is
because of CORS issues, and supposedly these will be fixed by using a
different Spring function, which is more lenient on URL schemes (needs
to allow `tauri://localhost` here, which isn't a standard URL)
2025-11-26 10:02:38 +00:00
Reece Browne 31b3219169 Detect backend down (#5010)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 22:10:38 +00:00
d8a99fcb07 Use proper Windows APIs for checking/setting default app (#5000)
# Description of Changes
Use proper Windows APIs for checking/setting default app

---------

Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-11-25 21:31:02 +00:00
Reece Browne 8016d271aa Handle non-pdf gracefully in viewer (#5004)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 21:27:47 +00:00
Anthony Stirling 8f1bef7f46 translationsNew (#5015)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 21:25:16 +00:00
e6db57e031 grandfather users (#4984)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
2025-11-25 21:23:32 +00:00
Reece Browne daf749e6be File in context indication (#4990)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 20:28:24 +00:00
Reece Browne 53d167eda5 Chore/v2/translation fixes (#5011)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 20:03:47 +00:00
d74856f675 Remove "Download for Desktop" and "Security Check" slides from desktop app onboarding (#5012)
# Description of Changes

- Title^ + I changed the text for set as default to make it more visible

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-25 19:56:09 +00:00
f8386843d4 Bug/v2/onboarding slides fix (#5005)
# Description of Changes

- Stop onboarding from appearing before logging in
- Also (should've done this in a different PR) fixed a small bug in
settings when changing logo

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-25 18:45:40 +00:00
ConnorYoh 2277a94c91 Added default supabase and stripe public variables (#5009)
Added to codebase
VITE_SUPABASE_URL
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY
VITE_STRIPE_PUBLISHABLE_KEY
2025-11-25 17:58:38 +00:00
ConnorYoh 3b8b539efc Feature/v2/stripeorsupabaseNotEnabled (#5006)
Removed current plan section from static plan to match connected version

stripe publishable key not required to show plans or checkout in hosted
version

lazy load plans when needed not on load
2025-11-25 17:09:41 +00:00
Anthony Stirling 7253b9fa6d translations and login page (#5008)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 16:39:21 +00:00
EthanHealy01 9a1f89486d center login section (#5007)
# Description of Changes


<img width="2056" height="1084" alt="Screenshot 2025-11-25 at 3 54
11 PM"
src="https://github.com/user-attachments/assets/32c0b69a-4799-49f2-be8b-d75bba5e6aac"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 16:25:58 +00:00
EthanHealy01 a6614e1bfb UI/allow logo selection (#4982)
# Description of Changes

- Allow switching between logos in-app using the same section in
settings

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 15:22:14 +00:00
Reece Browne ae5b1a4b02 consolidate english language to use gb (#5002)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 15:15:44 +00:00
ConnorYoh 9fcaace8ed Make sure license gets pushed and saved for default license replacements (#5001) 2025-11-25 14:58:33 +00:00
ConnorYoh 2ab7945130 Capture emaill when the license is default or invalid (#4998)
Stripe checkout captures email if the license is the default or invalid.
2025-11-25 14:33:19 +00:00
a8db2fda18 Feature/onboarding slides (#4955)
# Description of Changes

- Added onboarding slides/upgrade banner conditions for all the
following cases
  - 'licensed'
  - 'no-login-user-under-limit-no-license'
  - 'no-login-admin-under-limit-no-license'
  - 'no-login-user-over-limit-no-license'
  - 'no-login-admin-over-limit-no-license'
  - 'login-user-under-limit-no-license'
  - 'login-admin-under-limit-no-license'
  - 'login-user-over-limit-no-license'
  - 'login-admin-over-limit-no-license';


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-25 13:45:02 +00:00
James Brunton 80f2980755 Fix backend issues in desktop app (#4995)
# Description of Changes
Fixes two distinct but related issues in the backend of the desktop app:
- Correctly shows tools as unavaialable when the backend doesn't have
the dependencies or has disabled them etc. (same as web version - this
primarily didn't work on desktop because the app spawns before the
backend is running)
- Fixes infinite re-rendering issues caused by the app polling whether
the backend is healthy or not
2025-11-25 13:15:30 +00:00
James Brunton 2d8b0ff08c Various fixes for desktop (#4976)
# Description of Changes
- ~Force classic logo~
- Refer to email instead of username in SaaS sign in flow
- Allow drag-and-drop files into desktop app
- Convert terminology & icons from upload/download to open/save in
desktop version
2025-11-25 13:11:13 +00:00
Anthony Stirling 991e03970e Fix settings loading (#4997)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-25 12:30:44 +00:00
James Brunton 64d343b765 Add SSO login options to desktop app (#4954)
# Description of Changes
Add SSO login options to desktop app
2025-11-25 11:56:25 +00:00
stirlingbot[bot] 2534c532b7 🌐 [V2] Sync Translations + Update README Progress Table (#4946)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation for the **V2 branch**. Below are the
details of the changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-11-25 10:01:38 +00:00
Anthony Stirling 8757a80fda Update tauri-build.yml (#4978)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-24 23:31:09 +00:00
James Brunton d599a07a66 Automatically switch to Viewer when going to exactly 1 file (#4973)
# Description of Changes
Automatically switch to Viewer when going to exactly 1 file
2025-11-24 17:09:41 +00:00
Reece Browne 861e4394df Bug/v2/fix rtl (#4958) 2025-11-24 16:57:36 +00:00
ConnorYohandConnor Yoh 5d18184e46 V2 Payment Features (#4974)
* Added ability to add seats to enterprise
* first logged in date on people page
* Remove Premium config section				
* Cleanup add seat flow					
* Shrink numbers in plan 					
* Make editing text a server feature in the highlights
* default to dollar pricing				
* clear checkout logic when crash				
* Recongnise location and find pricing			
* Payment successful page

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-24 16:38:07 +00:00
EthanHealy01 050408639b revert to classic logo (#4971)
# Description of Changes
 
- Revert to classic logo

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-24 15:04:42 +00:00
Anthony StirlingandEthanHealy01 b0397da19e PDF Text editor (#4724)
## Summary
- add a `PdfJsonConversionService` that serializes PDF text, fonts, and
metadata to JSON and rebuilds a PDF from the same structure
- expose REST endpoints for `/pdf/json` and `/json/pdf` conversions
using the existing convert API infrastructure
- define JSON model classes capturing document metadata, font
information, and positioned text elements

## Testing
- `./gradlew spotlessApply` *(fails: plugin
org.springframework.boot:3.5.4 unavailable in build environment)*
- `./gradlew build` *(fails: plugin org.springframework.boot:3.5.4
unavailable in build environment)*

------
https://chatgpt.com/codex/tasks/task_b_68f8e98d94ac8328a0e499e541528b6f

---------

Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
2025-11-24 14:15:02 +00:00
Reece Browne d42065e338 Feature/v2/add image (#4956)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-24 14:00:43 +00:00
696 changed files with 282650 additions and 302359 deletions
+4 -2
View File
@@ -306,8 +306,10 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
+504 -247
View File
@@ -1,4 +1,4 @@
name: Test Installers Build
name: Multi-OS Tauri Releases
on:
workflow_dispatch:
@@ -6,7 +6,23 @@ on:
test_mode:
description: "Run in test mode (skip release step)"
required: false
default: "false"
default: "true"
type: choice
options:
- "true"
- "false"
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
push:
branches: [main, V2, V2-demo, V2-master]
release:
types: [created]
@@ -14,288 +30,500 @@ permissions:
contents: read
jobs:
read_versions:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: '21'
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
# ✅ Get version from Gradle
- name: Get version number
id: versionNumber
run: |
echo "Running gradlew printVersion..."
./gradlew printVersion --quiet
VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "Extracted version: $VERSION"
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
# ✅ Get Mac-specific version from Gradle
- name: Get version number mac
id: versionNumberMac
- name: Determine build matrix
id: set-matrix
run: |
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
build-portable:
needs: read_versions
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename binaries
run: |
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling${{ matrix.file_suffix }}-binaries
path: |
./binaries/*
sign_verify-portable:
needs: [build-portable, read_versions]
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "with-login-"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling-${{ matrix.file_suffix }}signed
path: |
./*
!cosign.*
build-installers:
needs: read_versions
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
# Install Windows dependencies
- name: Install WiX Toolset
if: matrix.os == 'windows-latest'
run: |
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
.\wix.exe /install /quiet
# Build installer
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: true
BROWSER_OPEN: true
- name: Set up JDK (x86_64)
if: matrix.os == 'macos-latest'
run: |
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
mkdir -p zulu17
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
- name: Verify JDK architecture
if: matrix.os == 'macos-latest'
run: file $JAVA_HOME/bin/java
- name: Build project and run jpackage (x86_64)
if: matrix.os == 'macos-latest'
run: arch -x86_64 ./gradlew jpackageMacX64
# Rename and collect artifacts based on OS
- name: Prepare artifacts
id: prepare
shell: bash
run: |
ls -lah ./build/jpackage/
mkdir ./binaries
if [ "${{ matrix.os }}" = "windows-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
- name: Display structure of downloaded files
run: ls -R ./binaries
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}binaries
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
build-jars:
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
variant:
- name: "default"
disable_security: true
build_frontend: true
file_suffix: ""
- name: "with-login"
disable_security: false
build_frontend: true
file_suffix: "-with-login"
- name: "server-only"
disable_security: true
build_frontend: false
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
name: ${{ matrix.platform }}binaries
java-version: "21"
distribution: "temurin"
- name: Display structure of downloaded files
run: ls -R
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Build JAR
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
- name: Rename JAR
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
ls -la app/core/build/libs/
mkdir -p ./jar-dist
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
- name: Upload JAR artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
retention-days: 1
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on release or V2-master
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# Find only the files we need to sign
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host "[ERROR] Signing failed for $($file.Name)"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release:
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [read_versions, sign_verify, sign_verify-portable]
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
needs: [determine-matrix, build, build-jars]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -305,14 +533,43 @@ jobs:
with:
egress-policy: audit
- name: Download signed artifacts
- name: Download all Tauri artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (default)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-server
path: ./artifacts/jars
- name: Display structure of downloaded files
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
run: ls -R ./artifacts
- name: Upload binaries to Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.read_versions.outputs.version }}
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
files: |
./*signed/*
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*.dmg
./artifacts/**/*.deb
./artifacts/**/*.AppImage
draft: false
prerelease: false
+244
View File
@@ -0,0 +1,244 @@
name: Push Docker Image - V2 Branch
on:
workflow_dispatch:
push:
branches:
- V2-master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Install cosign
if: github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for latest (V2-master branch - production)
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V2-demo branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
- name: Generate tags for latest-fat (V2-master branch - production)
id: meta-fat
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V2-demo branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for ultra-lite (V2-master branch - production)
id: meta-lite
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (V2-demo branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign ultra-lite images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
+455 -100
View File
@@ -2,85 +2,445 @@ name: Release Artifacts
on:
workflow_dispatch:
release:
types: [created]
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
push:
branches: [main, V2, V2-demo]
permissions:
contents: read
jobs:
build:
determine-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Rename binaries
- name: Determine build matrix
id: set-matrix
run: |
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
- name: Debug build artifacts
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
echo "Current Directory: $(pwd)"
ls -R ./build/libs
ls -R ./build/launch4j
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# Find only the files we need to sign (not build scripts)
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path (set by DigiCert action)
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
Write-Host "DigiCert KeyLocker action may not have run correctly"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
# Use smctl to sign with keypair alias
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
# Check if output contains "FAILED" even with exit code 0
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host ""
Write-Host "[ERROR] Signing failed for $($file.Name)"
Write-Host "[ERROR] smctl returned success but output indicates failure"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: binaries${{ matrix.file_suffix }}
path: |
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 30
sign_verify:
needs: build
runs-on: ubuntu-latest
if: success()
strategy:
fail-fast: false
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -90,7 +450,8 @@ jobs:
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: binaries${{ matrix.file_suffix }}
name: Stirling-PDF-${{ matrix.name }}
- name: Display structure of downloaded files
run: ls -R
@@ -101,80 +462,74 @@ jobs:
run: cosign generate-key-pair
- name: Sign and generate attestations
shell: bash
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Sign all artifacts for this platform
for file in *; do
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
echo "Signing: $file"
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Sign the artifact
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature "${file}.sig" \
"$file"
cosign verify-blob \
--key ./cosign.pub \
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Generate attestation
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation "${file}.intoto.jsonl" \
"$file"
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
# Verify the signature
cosign verify-blob \
--key ./cosign.pub \
--signature "${file}.sig" \
"$file"
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
echo "✅ Signed and verified: $file"
fi
done
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signed${{ matrix.file_suffix }}
name: Stirling-PDF-${{ matrix.name }}-signed
path: |
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
*
!cosign.key
!cosign.pub
retention-days: 30
release:
needs: [build, sign_verify]
needs: [determine-matrix, build, sign_verify]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
permissions:
contents: write
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download signed artifacts
- name: Download all signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: signed${{ matrix.file_suffix }}
pattern: Stirling-PDF-*-signed
path: ./artifacts
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Create GitHub Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.build.outputs.version }}
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
files: |
./libs/Stirling-PDF*
./launch4j/Stirling-PDF-Server*
files: ./artifacts/**/*
draft: false
prerelease: false
+7 -8
View File
@@ -92,8 +92,6 @@ jobs:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
@@ -180,7 +178,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -190,7 +188,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -225,7 +223,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -314,7 +312,8 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
@@ -323,7 +322,7 @@ jobs:
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
@@ -514,7 +513,7 @@ jobs:
fi
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest'
if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
Write-Host "Verifying Windows code signatures..."
+31 -2
View File
@@ -17,8 +17,8 @@ local.properties
version.properties
#### Stirling-PDF Files ###
pipeline/watchedFolders/
pipeline/finishedFolders/
pipeline/
!pipeline/.gitkeep
customFiles/
configs/
watchedFolders/
@@ -31,8 +31,23 @@ exampleYmlFiles/stirling/
/testing/file_snapshots
SwaggerDoc.json
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
.gradle
.gradle-home
.lock
# External tool builders
@@ -213,3 +228,17 @@ node_modules/
test_batch.json
*.backup.*.json
frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
scripts/pdf-collection/
**/tmp/
*.backup
# Type3 development data
docs/type3/signatures/
# Type3 sample PDFs (development only)
**/type3/samples/
+4
View File
@@ -6,6 +6,10 @@ Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) |
| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) |
| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) |
| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) |
| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) |
| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) |
| Arabic (العربية) (ar_AR) | ![87%](https://geps.dev/progress/87) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![86%](https://geps.dev/progress/86) |
| Basque (Euskara) (eu_ES) | ![86%](https://geps.dev/progress/86) |
| Bulgarian (Български) (bg_BG) | ![86%](https://geps.dev/progress/86) |
| Catalan (Català) (ca_CA) | ![85%](https://geps.dev/progress/85) |
| Croatian (Hrvatski) (hr_HR) | ![86%](https://geps.dev/progress/86) |
| Czech (Česky) (cs_CZ) | ![84%](https://geps.dev/progress/84) |
| Danish (Dansk) (da_DK) | ![85%](https://geps.dev/progress/85) |
| Dutch (Nederlands) (nl_NL) | ![85%](https://geps.dev/progress/85) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) |
| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) |
| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) |
| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) |
| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) |
| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) |
| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) |
| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) |
| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) |
| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) |
| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) |
| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) |
| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) |
| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) |
| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) |
| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) |
| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) |
| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) |
| French (Français) (fr_FR) | ![85%](https://geps.dev/progress/85) |
| German (Deutsch) (de_DE) | ![86%](https://geps.dev/progress/86) |
| Greek (Ελληνικά) (el_GR) | ![86%](https://geps.dev/progress/86) |
| Hindi (हिंदी) (hi_IN) | ![86%](https://geps.dev/progress/86) |
| Hungarian (Magyar) (hu_HU) | ![86%](https://geps.dev/progress/86) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![85%](https://geps.dev/progress/85) |
| Irish (Gaeilge) (ga_IE) | ![86%](https://geps.dev/progress/86) |
| Italian (Italiano) (it_IT) | ![85%](https://geps.dev/progress/85) |
| Japanese (日本語) (ja_JP) | ![86%](https://geps.dev/progress/86) |
| Korean (한국어) (ko_KR) | ![86%](https://geps.dev/progress/86) |
| Norwegian (Norsk) (no_NB) | ![86%](https://geps.dev/progress/86) |
| Persian (فارسی) (fa_IR) | ![86%](https://geps.dev/progress/86) |
| Polish (Polski) (pl_PL) | ![86%](https://geps.dev/progress/86) |
| Portuguese (Português) (pt_PT) | ![86%](https://geps.dev/progress/86) |
| Portuguese Brazilian (Português) (pt_BR) | ![86%](https://geps.dev/progress/86) |
| Romanian (Română) (ro_RO) | ![85%](https://geps.dev/progress/85) |
| Russian (Русский) (ru_RU) | ![86%](https://geps.dev/progress/86) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![86%](https://geps.dev/progress/86) |
| Simplified Chinese (简体中文) (zh_CN) | ![87%](https://geps.dev/progress/87) |
| Slovakian (Slovensky) (sk_SK) | ![86%](https://geps.dev/progress/86) |
| Slovenian (Slovenščina) (sl_SI) | ![86%](https://geps.dev/progress/86) |
| Spanish (Español) (es_ES) | ![86%](https://geps.dev/progress/86) |
| Swedish (Svenska) (sv_SE) | ![86%](https://geps.dev/progress/86) |
| Thai (ไทย) (th_TH) | ![86%](https://geps.dev/progress/86) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) |
| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) |
| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) |
| Traditional Chinese (繁體中文) (zh_TW) | ![87%](https://geps.dev/progress/87) |
| Turkish (Türkçe) (tr_TR) | ![86%](https://geps.dev/progress/86) |
| Ukrainian (Українська) (uk_UA) | ![86%](https://geps.dev/progress/86) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![86%](https://geps.dev/progress/86) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
+2
View File
@@ -37,6 +37,8 @@ dependencies {
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
@@ -336,6 +336,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
addEndpointToGroup("Convert", "pdf-to-json");
addEndpointToGroup("Convert", "json-to-pdf");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -471,6 +473,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Analysis",
description =
"""
Document analysis and information extraction services for content intelligence and insights.
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details.
Use these endpoints to understand what's inside a document without changing it.
This endpoint group provides analytical capabilities to understand document structure,
extract information, and generate insights from PDF content for automated processing.
Common use cases:
Document inventory management and content audit for compliance verification
• Quality assurance workflows and business intelligence analytics
• Migration planning, accessibility evaluation, and document forensics
Business applications:
• Legal discovery, financial document review, and healthcare records analysis
• Academic research, government processing, and publishing optimization
Operational scenarios:
• Large-scale profiling, migration assessment, and performance optimization
• Automated quality control and content strategy development
Target users: Data analysts, QA teams, administrators, and business intelligence
professionals requiring detailed document insights.
Typical uses:
• Get page counts and dimensions for layout or print rules
• List fonts and annotations to spot compatibility issues
• Inspect form fields before deciding how to fill or modify them
Pull metadata and security settings for audits or reports
""")
public @interface AnalysisApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Convert",
description =
"""
Document format transformation services for cross-platform compatibility and workflow integration.
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.).
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
This endpoint group enables transformation between various formats, supporting
diverse business workflows and system integrations for mixed document ecosystems.
Common use cases:
Legacy system integration, document migration, and cross-platform sharing
• Archive standardization, publishing preparation, and content adaptation
• Accessibility compliance and mobile-friendly document preparation
Business applications:
• Enterprise content management, digital publishing, and educational platforms
• Legal document processing, healthcare interoperability, and government standardization
Integration scenarios:
• API-driven pipelines, automated workflow preparation, and batch conversions
• Real-time format adaptation for user requests
Target users: System integrators, content managers, digital archivists, and
organizations requiring flexible document format interoperability.
Typical uses:
• Turn PDFs into Word or text for editing
• Convert office files, images, HTML, or email (EML) into PDFs
• Create PDF/A for long-term archiving
Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse
""")
public @interface ConvertApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Filter",
description =
"""
Document content filtering and search operations for information discovery and organization.
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images.
Use these endpoints as a "pre-check" step to decide what to do with a file next.
This endpoint group enables intelligent content discovery and organization within
document collections for content-based processing and information extraction.
Common use cases:
Legal discovery, research organization, and compliance auditing
• Content moderation, academic research, and business intelligence
• Quality assurance and content validation workflows
Business applications:
• Contract analysis, financial review, and healthcare records organization
• Government processing, educational curation, and IP protection
Workflow scenarios:
• Large-scale processing, automated classification, and information extraction
• Document preparation for further processing or analysis
Target users: Legal professionals, researchers, compliance officers, and
organizations requiring intelligent document content discovery and organization.
Typical uses:
• Reject files that are too big or too small
• Detect image-only PDFs that should go through OCR
• Ensure a document has enough pages before it enters a workflow
Check orientation of pages before printing or merging
""")
public @interface FilterApi {}
@@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "General",
description =
"""
Core PDF processing operations for fundamental document manipulation workflows.
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages.
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
This endpoint group provides essential PDF functionality that forms the foundation
of most document processing workflows across various industries.
Common use cases:
Document preparation for archival systems and content organization
• File preparation for distribution, accessibility compliance, and batch processing
• Document consolidation for reporting and legal compliance workflows
Typical applications:
• Content management, publishing workflows, and educational content distribution
• Business process automation and archive management
Target users: Content managers, document processors, and organizations requiring
reliable foundational PDF manipulation capabilities.
Typical uses:
• Split a large PDF into smaller files (by pages, chapters, or size)
• Merge several PDFs into one report or pack
• Rotate or reorder pages before sending or archiving
Turn a multi-page document into one long scrolling page
""")
public @interface GeneralApi {}
@@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Misc",
description =
"""
Specialized utilities and supplementary tools for enhanced document processing workflows.
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more.
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
This endpoint group provides utility operations that support core document processing
tasks and address specific workflow needs in real-world scenarios.
Common use cases:
Document optimization for bandwidth-limited environments and storage cost management
Document repair, content extraction, and validation for quality assurance
Accessibility improvement and custom processing for specialized needs
Business applications:
• Web publishing optimization, email attachment management, and archive efficiency
• Mobile compatibility, print production, and legacy document recovery
Operational scenarios:
• Batch processing, quality control, and performance optimization
• Troubleshooting and recovery of problematic documents
Target users: System administrators, document specialists, and organizations requiring
specialized document processing and optimization tools.
Typical uses:
• Repair a damaged PDF or remove blank pages
• Run OCR on scanned PDFs so they become searchable
Compress large PDFs for email or web download
Extract embedded images or scans
Add page numbers, stamps, or overlay an image (e.g. logo, seal)
Update PDF metadata (title, author, etc.)
""")
public @interface MiscApi {}
@@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Pipeline",
description =
"""
Automated document processing workflows for complex multi-stage business operations.
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself.
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
This endpoint group enables organizations to create sophisticated document processing
workflows that combine multiple operations into streamlined, repeatable processes.
Common use cases:
• Invoice processing, legal document review, and healthcare records standardization
• Government processing, educational content preparation, and publishing automation
• Contract lifecycle management and approval processes
Business applications:
• Automated compliance reporting, large-scale migration, and quality assurance
• Archive preparation, content delivery, and document approval workflows
Operational scenarios:
• Scheduled batch processing and event-driven document processing
• Multi-department coordination and business system integration
Target users: Business process managers, IT automation specialists, and organizations
requiring consistent, repeatable document processing workflows.
Typical uses:
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.)
• Normalise documents before they enter an archive
• Wrap a complex document flow behind a single API call for your own apps
""")
public @interface PipelineApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Security",
description =
"""
Document security and protection services for confidential and sensitive content.
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing.
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
This endpoint group provides essential security operations for organizations handling
sensitive documents and materials requiring controlled access.
Common use cases:
Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
• Contract security for business transactions
Business applications:
• Document authentication, confidential sharing, and secure archiving
• Content watermarking, access control, and privacy protection through redaction
Industry scenarios:
• Legal discovery, medical records exchange, financial audit documentation
• Enterprise policy enforcement and data governance
Target users: Legal professionals, healthcare administrators, compliance officers,
government agencies, and enterprises handling sensitive content.
Typical uses:
• Add or remove a password on a PDF
• Redact personal or confidential information (manually or automatically)
• Validate or remove digital signatures
Sanitize a PDF to strip scripts and embedded content
""")
public @interface SecurityApi {}
@@ -41,7 +41,7 @@ public class CustomPDFDocumentFactory {
// Memory thresholds and limits
private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
// Files smaller than this threshold are loaded entirely in memory for better performance.
// These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
// No temp files are created for document data, reducing I/O operations but consuming more
@@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -37,6 +38,9 @@ public class JobExecutorService {
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
@@ -98,11 +102,17 @@ public class JobExecutorService {
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Generate base UUID
String baseJobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
// Store the scoped job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
request.setAttribute("jobId", scopedJobKey);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
@@ -116,11 +126,13 @@ public class JobExecutorService {
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(jobId);
log.debug("Added job ID {} to user session", jobId);
userJobIds.add(scopedJobKey);
log.debug("Added scoped job ID {} to user session", scopedJobKey);
}
}
String jobId = scopedJobKey;
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
@@ -149,17 +161,31 @@ public class JobExecutorService {
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
final String capturedJobIdForQueue = jobId;
Supplier<Object> wrappedWork =
() -> {
try {
// Set jobId in ThreadLocal context for the queued job
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
log.debug(
"Set jobId {} in JobContext for queued job execution",
capturedJobIdForQueue);
Object result = work.get();
processJobResult(jobId, result);
processJobResult(capturedJobIdForQueue, result);
return result;
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
"Error executing queued job {}: {}",
capturedJobIdForQueue,
e.getMessage(),
e);
taskManager.setError(capturedJobIdForQueue, e.getMessage());
throw e;
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
};
@@ -171,21 +197,36 @@ public class JobExecutorService {
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
// Capture the jobId for the async thread
final String capturedJobId = jobId;
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
"Running async job {} with timeout {} ms",
capturedJobId,
timeoutToUse);
// Set jobId in ThreadLocal context for the async thread
stirling.software.common.util.JobContext.setJobId(capturedJobId);
log.debug(
"Set jobId {} in JobContext for async execution",
capturedJobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
processJobResult(capturedJobId, result);
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
});
@@ -194,6 +235,10 @@ public class JobExecutorService {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Make jobId available to downstream components on the worker thread
stirling.software.common.util.JobContext.setJobId(jobId);
log.debug("Set jobId {} in JobContext for sync execution", jobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
@@ -213,6 +258,8 @@ public class JobExecutorService {
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
} finally {
stirling.software.common.util.JobContext.clear();
}
}
}
@@ -466,8 +513,23 @@ public class JobExecutorService {
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
String currentJobId = stirling.software.common.util.JobContext.getJobId();
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
java.util.concurrent.CompletableFuture.supplyAsync(
() -> {
if (currentJobId != null) {
stirling.software.common.util.JobContext.setJobId(currentJobId);
}
try {
return supplier.get();
} finally {
if (currentJobId != null) {
stirling.software.common.util.JobContext.clear();
}
}
},
executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
@@ -483,4 +545,18 @@ public class JobExecutorService {
throw new Exception("Execution was interrupted", e);
}
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
}
@@ -0,0 +1,42 @@
package stirling.software.common.service;
import java.util.Optional;
/**
* Service interface for managing job ownership and access control. Implementations can provide
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
*/
public interface JobOwnershipService {
/**
* Get the current authenticated user's identifier.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
Optional<String> getCurrentUserId();
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
String createScopedJobKey(String jobId);
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
boolean validateJobAccess(String scopedJobKey);
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
String extractJobId(String scopedJobKey);
}
@@ -0,0 +1,21 @@
package stirling.software.common.service;
import java.io.IOException;
/**
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
* module to provide authenticated users access to their personal signatures.
*/
public interface PersonalSignatureServiceInterface {
/**
* Get a personal signature from the user's folder. Only checks personal folder, not shared
* folder.
*
* @param username Username of the signature owner
* @param fileName Signature filename
* @return Personal signature image bytes
* @throws IOException If file not found or read error
*/
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
}
@@ -0,0 +1,18 @@
package stirling.software.common.util;
/** Thread-local context for passing job ID across async boundaries */
public class JobContext {
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
public static void setJobId(String jobId) {
CURRENT_JOB_ID.set(jobId);
}
public static String getJobId() {
return CURRENT_JOB_ID.get();
}
public static void clear() {
CURRENT_JOB_ID.remove();
}
}
@@ -96,6 +96,7 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getOcrMyPdfSessionLimit();
case CFF_CONVERTER -> 1;
};
long timeoutMinutes =
@@ -150,6 +151,7 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes();
case CFF_CONVERTER -> 5L;
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
});
@@ -302,7 +304,8 @@ public class ProcessExecutor {
TESSERACT,
QPDF,
GHOSTSCRIPT,
OCR_MY_PDF
OCR_MY_PDF,
CFF_CONVERTER
}
@Setter
@@ -7,23 +7,103 @@ public class RequestUriUtils {
}
public static boolean isStaticResource(String contextPath, String requestURI) {
return requestURI.startsWith(contextPath + "/css/")
|| requestURI.startsWith(contextPath + "/fonts/")
|| requestURI.startsWith(contextPath + "/js/")
|| requestURI.endsWith(contextPath + "robots.txt")
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|| requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/error")
|| requestURI.startsWith(contextPath + "/favicon")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
|| requestURI.endsWith(".txt")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// API routes are never static except for the public status endpoint
if (normalizedUri.startsWith("/api/")) {
return normalizedUri.startsWith("/api/v1/info/status");
}
// Well-known static asset directories (backend + React build artifacts)
if (normalizedUri.startsWith("/css/")
|| normalizedUri.startsWith("/fonts/")
|| normalizedUri.startsWith("/js/")
|| normalizedUri.startsWith("/images/")
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
|| normalizedUri.startsWith("/og_images/")) {
return true;
}
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
return true;
}
// Login/error pages remain public
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
|| normalizedUri.endsWith(".ico")
|| normalizedUri.endsWith(".txt")
|| normalizedUri.endsWith(".webmanifest")
|| normalizedUri.endsWith(".js")
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// APIs are never treated as frontend routes
if (normalizedUri.startsWith("/api/")) {
return false;
}
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/invite",
"/pipeline",
"/pdfjs",
"/pdfjs-legacy",
"/fonts",
"/images",
"/files",
"/css",
"/js",
"/swagger",
"/v1/api-docs",
"/actuator"
};
for (String prefix : backendOnlyPrefixes) {
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
return false;
}
}
if (normalizedUri.isBlank()) {
return false;
}
// Allow root and any extensionless path (React Router will handle these)
return !normalizedUri.contains(".");
}
public static boolean isTrackableResource(String requestURI) {
@@ -43,6 +123,7 @@ public class RequestUriUtils {
|| requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
@@ -78,9 +159,18 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
return requestURI.substring(contextPath.length());
}
return requestURI;
}
}
@@ -78,6 +78,23 @@ class JobExecutorServiceTest {
verify(request).setAttribute(eq("jobId"), anyString());
}
@Test
void shouldExposeJobIdInJobContextDuringSyncExecution() throws Exception {
// Given
Supplier<Object> work = stirling.software.common.util.JobContext::getJobId;
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
var requestJobIdCaptor = ArgumentCaptor.forClass(String.class);
verify(request).setAttribute(eq("jobId"), requestJobIdCaptor.capture());
assertEquals(requestJobIdCaptor.getValue(), response.getBody());
}
@Test
void shouldRunAsyncJobSuccessfully() throws Exception {
// Given
@@ -49,6 +49,27 @@ public class RequestUriUtilsTest {
"API products should not be static");
}
@Test
void testIsFrontendRoute() {
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
"API routes should not be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/register"),
"Register should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
"Pipeline should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/files/download"),
"Files path should not be treated as a frontend route");
}
@Test
void testIsStaticResourceWithContextPath() {
String contextPath = "/myapp";
@@ -83,6 +104,7 @@ public class RequestUriUtilsTest {
"/favicon.ico",
"/icon.svg",
"/image.png",
"/locales/en/translation.toml",
"/site.webmanifest",
"/app/logo.svg",
"/downloads/document.png",
+125
View File
@@ -1,5 +1,7 @@
apply plugin: 'org.springframework.boot'
import org.apache.tools.ant.taskdefs.condition.Os
repositories {
maven { url = 'https://build.shibboleth.net/maven/releases' }
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
@@ -15,6 +17,7 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -25,12 +28,14 @@ spotless {
}
yaml {
target '**/*.yml', '**/*.yaml'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -157,5 +162,125 @@ springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def frontendDir = file('../../frontend')
def frontendDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
'index.html',
'locales',
'Login',
'classic-logo',
'modern-logo',
'og_images',
'samples',
'manifest-classic.json'
]
tasks.register('npmInstall', Exec) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Install frontend dependencies'
workingDir frontendDir
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
inputs.file(new File(frontendDir, 'package.json'))
inputs.file(new File(frontendDir, 'package-lock.json'))
outputs.dir(new File(frontendDir, 'node_modules'))
// Show live output
standardOutput = System.out
errorOutput = System.err
// Skip if node_modules exists and is up-to-date
onlyIf {
def nodeModules = new File(frontendDir, 'node_modules')
if (!nodeModules.exists()) {
println "node_modules not found, will install..."
return true
}
def packageJson = new File(frontendDir, 'package.json')
def packageLock = new File(frontendDir, 'package-lock.json')
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
nodeModules.lastModified() < packageLock.lastModified()
if (isOutdated) {
println "package.json or package-lock.json changed, will reinstall..."
} else {
println "node_modules is up-to-date, skipping npm install"
}
return isOutdated
}
doFirst {
println "Installing npm dependencies in ${frontendDir}..."
}
}
tasks.register('npmBuild', Exec) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
workingDir frontendDir
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
dependsOn npmInstall
inputs.dir(new File(frontendDir, 'src'))
inputs.file(new File(frontendDir, 'package.json'))
outputs.dir(frontendDistDir)
// Show live output
standardOutput = System.out
errorOutput = System.err
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
doFirst {
println "Building frontend application for production (VITE_API_BASE_URL=/)"
}
}
tasks.register('copyFrontendAssets', Copy) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Copy frontend build to static resources'
dependsOn npmBuild
from(frontendDistDir) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
exclude 'favicon.ico' // Backend already has this
}
into resourcesStaticDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
doFirst {
println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..."
println "Backend static resources will be preserved"
}
doLast {
println "Frontend assets copied successfully!"
}
}
tasks.register('cleanFrontendAssets', Delete) {
group = 'frontend'
description = 'Remove previously generated frontend assets from static resources'
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
}
// Ensure copyFrontendAssets runs after spotless tasks
tasks.named('copyFrontendAssets').configure {
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
}
if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets
} else {
println "Frontend build disabled - JAR will be backend-only"
// When not building the UI, ensure any stale frontend assets are removed
processResources.dependsOn cleanFrontendAssets
}
bootJar.dependsOn ':common:jar'
bootJar.dependsOn ':proprietary:jar'
@@ -25,6 +25,7 @@ public class ExternalAppDepConfig {
private final String weasyprintPath;
private final String unoconvPath;
private final Map<String, List<String>> commandToGroupMapping;
private volatile boolean dependenciesChecked = false;
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
@@ -111,6 +112,10 @@ public class ExternalAppDepConfig {
}
}
public boolean isDependenciesChecked() {
return dependenciesChecked;
}
@PostConstruct
public void checkDependencies() {
// Check core dependencies
@@ -162,5 +167,7 @@ public class ExternalAppDepConfig {
}
}
endpointConfiguration.logDisabledEndpointsSummary();
dependenciesChecked = true;
log.info("Dependency checks completed");
}
}
@@ -21,6 +21,9 @@ public class SpringDocConfig {
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**",
"/api/v1/ui-data/**",
"/api/v1/proprietary/ui-data/**",
"/api/v1/info/**",
@@ -33,7 +36,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Processing API")
.description(
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
})
.build();
}
@@ -47,14 +50,17 @@ public class SpringDocConfig {
"/api/v1/admin/**",
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**")
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**")
.addOpenApiCustomizer(
openApi -> {
openApi.info(
openApi.getInfo()
.title("Stirling PDF - Admin API")
.title("Stirling PDF - Management API")
.description(
"API documentation for administrative functions, user management, and system configuration."));
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
})
.build();
}
@@ -76,7 +82,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - System API")
.description(
"API documentation for system information, UI data, and utility endpoints."));
"System information, UI metadata, job status, and file management endpoints."));
})
.build();
}
@@ -42,7 +42,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
registry.addMapping("/**")
.allowedOrigins(
.allowedOriginPatterns(
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.annotations.api.UiDataApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -58,6 +58,21 @@ public class UIDataController {
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/footer-info")
@Operation(summary = "Get public footer configuration data")
public ResponseEntity<FooterData> getFooterData() {
FooterData data = new FooterData();
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
data.setAccessibilityStatement(
applicationProperties.getLegal().getAccessibilityStatement());
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
data.setImpressum(applicationProperties.getLegal().getImpressum());
return ResponseEntity.ok(data);
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
@@ -237,6 +252,16 @@ public class UIDataController {
}
// Data classes
@Data
public static class FooterData {
private Boolean analyticsEnabled;
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
@@ -0,0 +1,225 @@
package stirling.software.SPDF.controller.api.converters;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@Slf4j
@ConvertApi
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor")
@Operation(
summary = "Convert PDF to Text Editor Format",
description =
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> convertPdfToJson(
@ModelAttribute PDFFile request,
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/text-editor/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert Text Editor Format to PDF",
description =
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool. Input:JSON Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertJsonToPdf(@ModelAttribute GeneralFile request)
throws Exception {
MultipartFile jsonFile = request.getFileInput();
if (jsonFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
byte[] pdfBytes = pdfJsonConversionService.convertJsonToPdf(jsonFile);
String originalName = jsonFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
// Generate server-side UUID for job
String baseJobId = UUID.randomUUID().toString();
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + "_metadata.json";
// Return jobId in response header for client
return ResponseEntity.ok()
.header("X-Job-Id", scopedJobKey)
.contentType(MediaType.APPLICATION_JSON)
.body(jsonBytes);
}
@PostMapping(
value = "/pdf/text-editor/partial/{jobId}",
consumes = MediaType.APPLICATION_JSON_VALUE)
@StandardPdfResponse
@Operation(
summary = "Apply incremental edits from text editor to a cached PDF",
description =
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
+ " The jobId must be obtained from the metadata extraction endpoint.")
public ResponseEntity<byte[]> exportPartialPdf(
@PathVariable String jobId,
@RequestBody PdfJsonDocument document,
@RequestParam(value = "filename", required = false) String filename)
throws Exception {
if (document == null) {
throw ExceptionUtils.createNullArgumentException("document");
}
// Validate job ownership
validateJobAccess(jobId);
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
String baseName =
(filename != null && !filename.isBlank())
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
: Optional.ofNullable(document.getMetadata())
.map(PdfJsonMetadata::getTitle)
.filter(title -> title != null && !title.isBlank())
.orElse("document");
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
}
@GetMapping(value = "/pdf/text-editor/page/{jobId}/{pageNumber}")
@Operation(
summary = "Extract single page from cached PDF for text editor",
description =
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@Operation(
summary = "Clear cached PDF document for text editor",
description =
"Manually clears a cached PDF document used by the text editor to free up server resources."
+ " Called automatically after 30 minutes. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
// Validate job ownership
validateJobAccess(jobId);
pdfJsonConversionService.clearCachedDocument(jobId);
return ResponseEntity.ok().build();
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @throws SecurityException if current user does not own the job
*/
private void validateJobAccess(String jobId) {
if (jobOwnershipService != null) {
jobOwnershipService.validateJobAccess(jobId);
}
// If jobOwnershipService is null (security disabled), allow all access
}
}
@@ -35,6 +35,7 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -43,12 +44,14 @@ public class ConfigController {
@org.springframework.beans.factory.annotation.Autowired(required = false)
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService) {
UserServiceInterface userService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.externalAppDepConfig = externalAppDepConfig;
}
@GetMapping("/app-config")
@@ -56,6 +59,9 @@ public class ConfigController {
Map<String, Object> configData = new HashMap<>();
try {
// Add dependency check status
configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked());
// Get AppConfig bean
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
@@ -196,9 +196,9 @@ public class StampController {
resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
case "korean" -> "static/fonts/malgun.ttf";
case "chinese" -> "static/fonts/SimSun.ttf";
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
case "roman" -> "static/fonts/NotoSans-Regular.ttf";
default -> "static/fonts/NotoSans-Regular.ttf";
@@ -171,9 +171,9 @@ public class WatermarkController {
resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
case "korean" -> "static/fonts/malgun.ttf";
case "chinese" -> "static/fonts/SimSun.ttf";
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
default -> "static/fonts/NotoSans-Regular.ttf";
};
@@ -25,7 +25,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
@Slf4j
public class GeneralWebController {
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -1,16 +1,19 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@Controller
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
@@ -1,48 +0,0 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -0,0 +1,84 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/general")
public class SignatureImageController {
private final SharedSignatureService sharedSignatureService;
private final PersonalSignatureServiceInterface personalSignatureService;
private final UserServiceInterface userService;
public SignatureImageController(
SharedSignatureService sharedSignatureService,
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.sharedSignatureService = sharedSignatureService;
this.personalSignatureService = personalSignatureService;
this.userService = userService;
}
/**
* Get a signature image (works for both authenticated and unauthenticated users). -
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
* community: tries shared only
*/
@GetMapping("/signatures/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
try {
byte[] imageBytes = null;
// If proprietary service available and user authenticated, try personal folder first
if (personalSignatureService != null && userService != null) {
try {
String username = userService.getCurrentUsername();
imageBytes =
personalSignatureService.getPersonalSignatureBytes(username, fileName);
} catch (Exception e) {
// Not found in personal folder or not authenticated, will try shared
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
}
}
// If not found in personal (or no personal service), try shared
if (imageBytes == null) {
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
}
// Determine content type from file extension
MediaType contentType = MediaType.IMAGE_PNG; // Default
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
contentType = MediaType.IMAGE_JPEG;
}
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
} catch (IOException e) {
log.debug("Signature not found: {}", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.model.api;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PdfJsonConversionProgress {
private int percent;
private String stage;
private String message;
private boolean complete;
private Integer current; // Current item being processed (e.g., page number)
private Integer total; // Total items to process (e.g., total pages)
public static PdfJsonConversionProgress of(int percent, String stage, String message) {
return PdfJsonConversionProgress.builder()
.percent(percent)
.stage(stage)
.message(message)
.complete(false)
.build();
}
public static PdfJsonConversionProgress of(
int percent, String stage, String message, int current, int total) {
return PdfJsonConversionProgress.builder()
.percent(percent)
.stage(stage)
.message(message)
.current(current)
.total(total)
.complete(false)
.build();
}
public static PdfJsonConversionProgress complete() {
return PdfJsonConversionProgress.builder()
.percent(100)
.stage("complete")
.message("Conversion complete")
.complete(true)
.build();
}
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -0,0 +1,22 @@
package stirling.software.SPDF.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -0,0 +1,61 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Represents a PDF annotation (comments, highlights, stamps, etc.). Annotations often contain OCR
* text layers or other metadata not visible in content streams.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonAnnotation {
/** Annotation subtype (Text, Highlight, Link, Stamp, Widget, etc.) */
private String subtype;
/** Human-readable text content of the annotation */
private String contents;
/** Annotation rectangle [x1, y1, x2, y2] */
private List<Float> rect;
/** Annotation appearance characteristics */
private String appearanceState;
/** Color components (e.g., [r, g, b] for RGB) */
private List<Float> color;
/** Annotation flags (print, hidden, etc.) */
private Integer flags;
/** For link annotations: destination or action */
private String destination;
/** For text annotations: icon name */
private String iconName;
/** Subject/title of the annotation */
private String subject;
/** Author of the annotation */
private String author;
/** Creation date (ISO 8601 format) */
private String creationDate;
/** Modification date (ISO 8601 format) */
private String modificationDate;
/** Full annotation dictionary for lossless round-tripping */
private PdfJsonCosValue rawData;
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonCosValue {
public enum Type {
NULL,
BOOLEAN,
INTEGER,
FLOAT,
NAME,
STRING,
ARRAY,
DICTIONARY,
STREAM
}
private Type type;
/**
* Holds the decoded value for primitives (boolean, integer, float, name, string). For name
* values the stored value is the PDF name literal. For string values the content is Base64
* encoded to safely transport arbitrary binaries.
*/
private Object value;
/** Reference to nested values for arrays. */
private List<PdfJsonCosValue> items;
/** Reference to nested values for dictionaries. */
private Map<String, PdfJsonCosValue> entries;
/** Stream payload when {@code type == STREAM}. */
private PdfJsonStream stream;
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonDocument {
private PdfJsonMetadata metadata;
/** Optional XMP metadata packet stored as Base64. */
private String xmpMetadata;
/** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */
private Boolean lazyImages;
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
@Builder.Default private List<PdfJsonPage> pages = new ArrayList<>();
/** Form fields (AcroForm) at document level */
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonDocumentMetadata {
private PdfJsonMetadata metadata;
/** Optional XMP metadata packet stored as Base64. */
private String xmpMetadata;
/** Indicates that images should be requested lazily via the page endpoint. */
private Boolean lazyImages;
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
@Builder.Default private List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
/** Form fields (AcroForm) at document level */
@Builder.Default private List<PdfJsonFormField> formFields = new ArrayList<>();
}
@@ -0,0 +1,96 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFont {
/** PDF resource name (e.g. F1) used as the primary identifier. */
private String id;
/** Logical page number that owns this font resource. */
private Integer pageNumber;
/** Stable UID combining page number and resource for diagnostics. */
private String uid;
/** Reported PostScript/Base font name. */
private String baseName;
/** Declared subtype in the COS dictionary. */
private String subtype;
/** Encoding dictionary or name. */
private String encoding;
/** CID system info for Type0 fonts. */
private PdfJsonFontCidSystemInfo cidSystemInfo;
/** True when the original PDF embedded the font program. */
private Boolean embedded;
/** Font program bytes (TTF/OTF/CFF/PFB) encoded as Base64. */
private String program;
/** Hint describing the font program type (ttf, otf, cff, pfb, etc.). */
private String programFormat;
/** Web-optimized font program (e.g. converted TrueType) encoded as Base64. */
private String webProgram;
/** Format hint for the webProgram payload. */
private String webProgramFormat;
/** PDF-friendly font program (e.g. converted TrueType) encoded as Base64. */
private String pdfProgram;
/** Format hint for the pdfProgram payload. */
private String pdfProgramFormat;
/** Glyph metadata for Type3 fonts to enable precise text rewrites. */
private List<PdfJsonFontType3Glyph> type3Glyphs;
/** Per-strategy synthesized font payloads for Type3 normalization. */
private List<PdfJsonFontConversionCandidate> conversionCandidates;
/** ToUnicode stream encoded as Base64 when present. */
private String toUnicode;
/** Mapped Standard 14 font name when available. */
private String standard14Name;
/** Font descriptor flags copied from the source document. */
private Integer fontDescriptorFlags;
/** Font ascent in glyph units (typically 1/1000). */
private Float ascent;
/** Font descent in glyph units (typically negative). */
private Float descent;
/** Capital height when available. */
private Float capHeight;
/** x-height when available. */
private Float xHeight;
/** Italic angle reported by the font descriptor. */
private Float italicAngle;
/** Units per em extracted from the font matrix. */
private Integer unitsPerEm;
/** Serialized COS dictionary describing the original font resource. */
private PdfJsonCosValue cosDictionary;
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontCidSystemInfo {
private String registry;
private String ordering;
private Integer supplement;
}
@@ -0,0 +1,69 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontConversionCandidate {
/** Stable identifier for the strategy that produced this candidate. */
private String strategyId;
/** Human-readable label for diagnostics and UI toggles. */
private String strategyLabel;
/** Outcome of the conversion attempt. */
private PdfJsonFontConversionStatus status;
/** Summary diagnostics or error details. */
private String message;
/** Count of glyphs successfully synthesized. */
private Integer synthesizedGlyphs;
/** Count of glyphs that could not be reproduced accurately. */
private Integer missingGlyphs;
/** Approximate width delta (in glyph units) across the test sample. */
private Double widthDelta;
/** Approximate bounding box delta (in glyph units). */
private Double bboxDelta;
/** Base64-encoded font program (typically TTF/OTF) produced by the strategy. */
private String program;
/** Format hint for {@link #program}. */
private String programFormat;
/** Web-optimized payload (e.g. TTF) for browser preview. */
private String webProgram;
/** Format for the web payload. */
private String webProgramFormat;
/** PDF-friendly payload for re-embedding during export. */
private String pdfProgram;
/** Format for the PDF payload. */
private String pdfProgramFormat;
/** Optional PNG preview of rendered glyphs (Base64). */
private String previewImage;
/** Additional structured diagnostics (JSON string). */
private String diagnostics;
/** Known unicode/codepoint coverage derived from the conversion strategy. */
private List<Integer> glyphCoverage;
}
@@ -0,0 +1,9 @@
package stirling.software.SPDF.model.json;
public enum PdfJsonFontConversionStatus {
SUCCESS,
WARNING,
FAILURE,
SKIPPED,
UNSUPPORTED
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFontType3Glyph {
/** Character code used in the content stream to reference this glyph. */
private Integer charCode;
/** PostScript glyph name, when available. */
private String glyphName;
/** Unicode code point represented by this glyph, if it can be resolved. */
private Integer unicode;
/** Raw char code used in the Type3 font encoding (0-255). */
private Integer charCodeRaw;
}
@@ -0,0 +1,66 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a PDF form field (AcroForm). */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonFormField {
/** Fully qualified field name (e.g., "form1.textfield1") */
private String name;
/** Partial field name (last component) */
private String partialName;
/** Field type (Tx=text, Btn=button, Ch=choice, Sig=signature) */
private String fieldType;
/** Field value as string */
private String value;
/** Default value */
private String defaultValue;
/** Field flags (readonly, required, multiline, etc.) */
private Integer flags;
/** Alternative field name (for accessibility) */
private String alternateFieldName;
/** Mapping name (for export) */
private String mappingName;
/** Page number where field appears (1-indexed) */
private Integer pageNumber;
/** Field rectangle [x1, y1, x2, y2] on the page */
private List<Float> rect;
/** For choice fields: list of options */
private List<String> options;
/** For choice fields: selected indices */
private List<Integer> selectedIndices;
/** For button fields: whether it's checked */
private Boolean checked;
/** Font information for text fields */
private String fontName;
private Float fontSize;
/** Full field dictionary for lossless round-tripping */
private PdfJsonCosValue rawData;
}
@@ -0,0 +1,37 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonImageElement {
private String id;
private String objectName;
private Boolean inlineImage;
private Integer nativeWidth;
private Integer nativeHeight;
private Float x;
private Float y;
private Float width;
private Float height;
private Float left;
private Float right;
private Float top;
private Float bottom;
@Builder.Default private List<Float> transform = new ArrayList<>();
private Integer zOrder;
private String imageData;
private String imageFormat;
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonMetadata {
private String title;
private String author;
private String subject;
private String keywords;
private String creator;
private String producer;
private String creationDate;
private String modificationDate;
private String trapped;
private Integer numberOfPages;
}
@@ -0,0 +1,34 @@
package stirling.software.SPDF.model.json;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonPage {
private Integer pageNumber;
private Float width;
private Float height;
private Integer rotation;
@Builder.Default private List<PdfJsonTextElement> textElements = new ArrayList<>();
@Builder.Default private List<PdfJsonImageElement> imageElements = new ArrayList<>();
@Builder.Default private List<PdfJsonAnnotation> annotations = new ArrayList<>();
/** Serialized representation of the page resources dictionary. */
private PdfJsonCosValue resources;
/** Raw content streams associated with the page, preserved for lossless round-tripping. */
@Builder.Default private List<PdfJsonStream> contentStreams = new ArrayList<>();
}
@@ -0,0 +1,20 @@
package stirling.software.SPDF.model.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonPageDimension {
private Integer pageNumber;
private Float width;
private Float height;
private Integer rotation;
}
@@ -0,0 +1,27 @@
package stirling.software.SPDF.model.json;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonStream {
/**
* A dictionary of entries that describe the stream metadata (Filter, DecodeParms, etc). Each
* entry is represented using {@link PdfJsonCosValue} so nested structures are supported.
*/
private Map<String, PdfJsonCosValue> dictionary;
/** Raw stream bytes in Base64 form. Data is stored exactly as it appeared in the source PDF. */
private String rawData;
}
@@ -0,0 +1,21 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonTextColor {
private String colorSpace;
private List<Float> components;
}
@@ -0,0 +1,41 @@
package stirling.software.SPDF.model.json;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PdfJsonTextElement {
private String text;
private String fontId;
private Float fontSize;
private Float fontMatrixSize;
private Float fontSizeInPt;
private Float characterSpacing;
private Float wordSpacing;
private Float spaceWidth;
private Integer zOrder;
private Float horizontalScaling;
private Float leading;
private Float rise;
private Float x;
private Float y;
private Float width;
private Float height;
private List<Float> textMatrix;
private PdfJsonTextColor fillColor;
private PdfJsonTextColor strokeColor;
private Integer renderingMode;
private Boolean fallbackUsed;
private List<Integer> charCodes;
}
@@ -0,0 +1,274 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSBoolean;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSFloat;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNull;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonCosValue;
import stirling.software.SPDF.model.json.PdfJsonStream;
@Slf4j
@Component
public class PdfJsonCosMapper {
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
if (stream == null) {
return null;
}
return serializeStream(
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
if (cosStream == null) {
return null;
}
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
throws IOException {
if (value == null || value.getType() == null) {
return null;
}
switch (value.getType()) {
case NULL:
return COSNull.NULL;
case BOOLEAN:
if (value.getValue() instanceof Boolean bool) {
return COSBoolean.getBoolean(bool);
}
return null;
case INTEGER:
if (value.getValue() instanceof Number number) {
return COSInteger.get(number.longValue());
}
return null;
case FLOAT:
if (value.getValue() instanceof Number number) {
return new COSFloat(number.floatValue());
}
return null;
case NAME:
if (value.getValue() instanceof String name) {
return COSName.getPDFName(name);
}
return null;
case STRING:
if (value.getValue() instanceof String encoded) {
try {
byte[] bytes = Base64.getDecoder().decode(encoded);
return new COSString(bytes);
} catch (IllegalArgumentException ex) {
log.debug("Failed to decode COSString value: {}", ex.getMessage());
}
}
return null;
case ARRAY:
COSArray array = new COSArray();
if (value.getItems() != null) {
for (PdfJsonCosValue item : value.getItems()) {
COSBase entry = deserializeCosValue(item, document);
if (entry != null) {
array.add(entry);
} else {
array.add(COSNull.NULL);
}
}
}
return array;
case DICTIONARY:
COSDictionary dictionary = new COSDictionary();
if (value.getEntries() != null) {
for (Map.Entry<String, PdfJsonCosValue> entry : value.getEntries().entrySet()) {
COSName key = COSName.getPDFName(entry.getKey());
COSBase entryValue = deserializeCosValue(entry.getValue(), document);
if (entryValue != null) {
dictionary.setItem(key, entryValue);
}
}
}
return dictionary;
case STREAM:
if (value.getStream() != null) {
return buildStreamFromModel(value.getStream(), document);
}
return null;
default:
return null;
}
}
public COSStream buildStreamFromModel(PdfJsonStream streamModel, PDDocument document)
throws IOException {
if (streamModel == null) {
return null;
}
COSStream cosStream = document.getDocument().createCOSStream();
if (streamModel.getDictionary() != null) {
for (Map.Entry<String, PdfJsonCosValue> entry :
streamModel.getDictionary().entrySet()) {
COSName key = COSName.getPDFName(entry.getKey());
COSBase value = deserializeCosValue(entry.getValue(), document);
if (value != null) {
cosStream.setItem(key, value);
}
}
}
String rawData = streamModel.getRawData();
if (rawData != null && !rawData.isBlank()) {
byte[] data;
try {
data = Base64.getDecoder().decode(rawData);
} catch (IllegalArgumentException ex) {
log.debug("Invalid base64 content stream data: {}", ex.getMessage());
data = new byte[0];
}
try (OutputStream outputStream = cosStream.createRawOutputStream()) {
outputStream.write(data);
}
cosStream.setItem(COSName.LENGTH, COSInteger.get(data.length));
} else {
cosStream.setItem(COSName.LENGTH, COSInteger.get(0));
}
return cosStream;
}
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
throws IOException {
if (base == null) {
return null;
}
if (base instanceof COSObject cosObject) {
base = cosObject.getObject();
if (base == null) {
return null;
}
}
boolean complex =
base instanceof COSDictionary
|| base instanceof COSArray
|| base instanceof COSStream;
if (complex) {
if (!visited.add(base)) {
return PdfJsonCosValue.builder()
.type(PdfJsonCosValue.Type.NAME)
.value("__circular__")
.build();
}
}
try {
PdfJsonCosValue.PdfJsonCosValueBuilder builder = PdfJsonCosValue.builder();
if (base instanceof COSNull) {
builder.type(PdfJsonCosValue.Type.NULL);
return builder.build();
}
if (base instanceof COSBoolean booleanValue) {
builder.type(PdfJsonCosValue.Type.BOOLEAN).value(booleanValue.getValue());
return builder.build();
}
if (base instanceof COSInteger integer) {
builder.type(PdfJsonCosValue.Type.INTEGER).value(integer.longValue());
return builder.build();
}
if (base instanceof COSFloat floatValue) {
builder.type(PdfJsonCosValue.Type.FLOAT).value(floatValue.floatValue());
return builder.build();
}
if (base instanceof COSName name) {
builder.type(PdfJsonCosValue.Type.NAME).value(name.getName());
return builder.build();
}
if (base instanceof COSString cosString) {
builder.type(PdfJsonCosValue.Type.STRING)
.value(Base64.getEncoder().encodeToString(cosString.getBytes()));
return builder.build();
}
if (base instanceof COSArray array) {
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
for (COSBase item : array) {
PdfJsonCosValue serialized = serializeCosValue(item, visited);
items.add(serialized);
}
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(dictionary.getDictionaryObject(key), visited);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
return builder.build();
}
return null;
} finally {
if (complex) {
visited.remove(base);
}
}
}
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
throws IOException {
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
for (COSName key : cosStream.keySet()) {
COSBase value = cosStream.getDictionaryObject(key);
PdfJsonCosValue serialized = serializeCosValue(value, visited);
if (serialized != null) {
dictionary.put(key.getName(), serialized);
}
}
String rawData = null;
try (InputStream inputStream = cosStream.createRawInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
if (inputStream != null) {
inputStream.transferTo(baos);
}
byte[] data = baos.toByteArray();
if (data.length > 0) {
rawData = Base64.getEncoder().encodeToString(data);
}
}
return PdfJsonStream.builder().dictionary(dictionary).rawData(rawData).build();
}
}
@@ -0,0 +1,576 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFont;
@Slf4j
@Component
@RequiredArgsConstructor
public class PdfJsonFallbackFontService {
public static final String FALLBACK_FONT_ID = "fallback-noto-sans";
public static final String DEFAULT_FALLBACK_FONT_LOCATION =
"classpath:/static/fonts/NotoSans-Regular.ttf";
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
// Font name aliases map PDF font names to available fallback fonts
// This provides better visual consistency when editing PDFs
private static final Map<String, String> FONT_NAME_ALIASES =
Map.ofEntries(
// Liberation fonts are metric-compatible with Microsoft core fonts
Map.entry("arial", "fallback-liberation-sans"),
Map.entry("helvetica", "fallback-liberation-sans"),
Map.entry("arimo", "fallback-liberation-sans"),
Map.entry("liberationsans", "fallback-liberation-sans"),
Map.entry("times", "fallback-liberation-serif"),
Map.entry("timesnewroman", "fallback-liberation-serif"),
Map.entry("tinos", "fallback-liberation-serif"),
Map.entry("liberationserif", "fallback-liberation-serif"),
Map.entry("courier", "fallback-liberation-mono"),
Map.entry("couriernew", "fallback-liberation-mono"),
Map.entry("cousine", "fallback-liberation-mono"),
Map.entry("liberationmono", "fallback-liberation-mono"),
// DejaVu fonts - widely used open source fonts
Map.entry("dejavu", "fallback-dejavu-sans"),
Map.entry("dejavusans", "fallback-dejavu-sans"),
Map.entry("dejavuserif", "fallback-dejavu-serif"),
Map.entry("dejavumono", "fallback-dejavu-mono"),
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
// Noto Sans - Google's universal font (use as last resort generic fallback)
Map.entry("noto", "fallback-noto-sans"),
Map.entry("notosans", "fallback-noto-sans"));
private static final Map<String, FallbackFontSpec> BUILT_IN_FALLBACK_FONTS =
Map.ofEntries(
Map.entry(
FALLBACK_FONT_CJK_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansSC-Regular.ttf",
"NotoSansSC-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_JP_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansJP-Regular.ttf",
"NotoSansJP-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_KR_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
"NotoSansKR-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_AR_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansArabic-Regular.ttf",
"NotoSansArabic-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TH_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
"NotoSansThai-Regular",
"ttf")),
// Liberation Sans family
Map.entry(
"fallback-liberation-sans",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Regular.ttf",
"LiberationSans-Regular",
"ttf")),
Map.entry(
"fallback-liberation-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Bold.ttf",
"LiberationSans-Bold",
"ttf")),
Map.entry(
"fallback-liberation-sans-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-Italic.ttf",
"LiberationSans-Italic",
"ttf")),
Map.entry(
"fallback-liberation-sans-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSans-BoldItalic.ttf",
"LiberationSans-BoldItalic",
"ttf")),
// Liberation Serif family
Map.entry(
"fallback-liberation-serif",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Regular.ttf",
"LiberationSerif-Regular",
"ttf")),
Map.entry(
"fallback-liberation-serif-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Bold.ttf",
"LiberationSerif-Bold",
"ttf")),
Map.entry(
"fallback-liberation-serif-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-Italic.ttf",
"LiberationSerif-Italic",
"ttf")),
Map.entry(
"fallback-liberation-serif-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationSerif-BoldItalic.ttf",
"LiberationSerif-BoldItalic",
"ttf")),
// Liberation Mono family
Map.entry(
"fallback-liberation-mono",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Regular.ttf",
"LiberationMono-Regular",
"ttf")),
Map.entry(
"fallback-liberation-mono-bold",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Bold.ttf",
"LiberationMono-Bold",
"ttf")),
Map.entry(
"fallback-liberation-mono-italic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-Italic.ttf",
"LiberationMono-Italic",
"ttf")),
Map.entry(
"fallback-liberation-mono-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/LiberationMono-BoldItalic.ttf",
"LiberationMono-BoldItalic",
"ttf")),
// Noto Sans family (enhanced with weight variants)
Map.entry(
FALLBACK_FONT_ID,
new FallbackFontSpec(
DEFAULT_FALLBACK_FONT_LOCATION, "NotoSans-Regular", "ttf")),
Map.entry(
"fallback-noto-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-Bold.ttf",
"NotoSans-Bold",
"ttf")),
Map.entry(
"fallback-noto-sans-italic",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-Italic.ttf",
"NotoSans-Italic",
"ttf")),
Map.entry(
"fallback-noto-sans-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/NotoSans-BoldItalic.ttf",
"NotoSans-BoldItalic",
"ttf")),
// DejaVu Sans family
Map.entry(
"fallback-dejavu-sans",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans.ttf", "DejaVuSans", "ttf")),
Map.entry(
"fallback-dejavu-sans-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-Bold.ttf",
"DejaVuSans-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-sans-oblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-Oblique.ttf",
"DejaVuSans-Oblique",
"ttf")),
Map.entry(
"fallback-dejavu-sans-boldoblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSans-BoldOblique.ttf",
"DejaVuSans-BoldOblique",
"ttf")),
// DejaVu Serif family
Map.entry(
"fallback-dejavu-serif",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif.ttf",
"DejaVuSerif",
"ttf")),
Map.entry(
"fallback-dejavu-serif-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-Bold.ttf",
"DejaVuSerif-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-serif-italic",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-Italic.ttf",
"DejaVuSerif-Italic",
"ttf")),
Map.entry(
"fallback-dejavu-serif-bolditalic",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSerif-BoldItalic.ttf",
"DejaVuSerif-BoldItalic",
"ttf")),
// DejaVu Mono family
Map.entry(
"fallback-dejavu-mono",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono.ttf",
"DejaVuSansMono",
"ttf")),
Map.entry(
"fallback-dejavu-mono-bold",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-Bold.ttf",
"DejaVuSansMono-Bold",
"ttf")),
Map.entry(
"fallback-dejavu-mono-oblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-Oblique.ttf",
"DejaVuSansMono-Oblique",
"ttf")),
Map.entry(
"fallback-dejavu-mono-boldoblique",
new FallbackFontSpec(
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
"DejaVuSansMono-BoldOblique",
"ttf")));
private final ResourceLoader resourceLoader;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
public PdfJsonFont buildFallbackFontModel(String fallbackId) throws IOException {
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
if (spec == null) {
throw new IOException("Unknown fallback font id " + fallbackId);
}
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
String base64 = java.util.Base64.getEncoder().encodeToString(bytes);
return PdfJsonFont.builder()
.id(fallbackId)
.uid(fallbackId)
.baseName(spec.baseName())
.subtype("TrueType")
.embedded(true)
.program(base64)
.programFormat(spec.format())
.build();
}
public PDFont loadFallbackPdfFont(PDDocument document) throws IOException {
return loadFallbackPdfFont(document, FALLBACK_FONT_ID);
}
public PDFont loadFallbackPdfFont(PDDocument document, String fallbackId) throws IOException {
FallbackFontSpec spec = getFallbackFontSpec(fallbackId);
if (spec == null) {
throw new IOException("Unknown fallback font id " + fallbackId);
}
byte[] bytes = loadFallbackFontBytes(fallbackId, spec);
try (InputStream stream = new ByteArrayInputStream(bytes)) {
// Load with embedSubset=false to ensure full glyph coverage
// Fallback fonts need all glyphs available for substituting missing characters
return PDType0Font.load(document, stream, false);
}
}
public boolean canEncodeFully(PDFont font, String text) {
return canEncode(font, text);
}
public boolean canEncode(PDFont font, int codePoint) {
return canEncode(font, new String(Character.toChars(codePoint)));
}
public boolean canEncode(PDFont font, String text) {
if (font == null || text == null || text.isEmpty()) {
return false;
}
if (font instanceof PDType3Font) {
return false;
}
try {
font.encode(text);
return true;
} catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) {
// Only log at debug level to reduce verbosity - summary is logged elsewhere
log.debug(
"[FONT-DEBUG] Font {} cannot encode text '{}' ({}): {}",
font != null ? font.getName() : "null",
text,
font != null ? font.getClass().getSimpleName() : "null",
ex.getMessage());
return false;
}
}
/**
* Resolve fallback font ID based on the original font name and code point. Attempts to match
* font family and weight/style for visual consistency.
*
* @param originalFontName the name of the original font (may be null)
* @param codePoint the Unicode code point that needs to be rendered
* @return fallback font ID
*/
public String resolveFallbackFontId(String originalFontName, int codePoint) {
// First try to match based on original font name for visual consistency
if (originalFontName != null && !originalFontName.isEmpty()) {
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
// remove spaces
String normalized =
originalFontName
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
.toLowerCase()
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
// Extract base name without weight/style suffixes
// Split on common delimiters: hyphen, underscore, comma, plus
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
String baseName = normalized.split("[-_,+]")[0];
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
if (aliasedFontId != null) {
// Detect weight and style from the normalized font name
boolean isBold = detectBold(normalized);
boolean isItalic = detectItalic(normalized);
// Apply weight/style suffix to fallback font ID
String styledFontId = applyWeightStyle(aliasedFontId, isBold, isItalic);
log.debug(
"Matched font '{}' (normalized: '{}', base: '{}', bold: {}, italic: {}) to fallback '{}'",
originalFontName,
normalized,
baseName,
isBold,
isItalic,
styledFontId);
return styledFontId;
}
}
// Fall back to Unicode-based selection
return resolveFallbackFontId(codePoint);
}
/**
* Detect if font name indicates bold weight.
*
* @param normalizedFontName lowercase font name without subset prefix or spaces
* @return true if bold weight is detected
*/
private boolean detectBold(String normalizedFontName) {
// Check for explicit bold indicators
if (normalizedFontName.contains("bold")
|| normalizedFontName.contains("heavy")
|| normalizedFontName.contains("black")) {
return true;
}
// Check for numeric weight indicators (600-900 = bold)
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
return true;
}
return false;
}
/**
* Detect if font name indicates italic/oblique style.
*
* @param normalizedFontName lowercase font name without subset prefix or spaces
* @return true if italic style is detected
*/
private boolean detectItalic(String normalizedFontName) {
return normalizedFontName.contains("italic") || normalizedFontName.contains("oblique");
}
/**
* Apply weight/style suffix to fallback font ID.
*
* <p>Weight/style variants are only applied to font families where we have the actual font
* files available. Currently supported: - Liberation Sans: Regular, Bold, Italic, BoldItalic
* (full support) - Liberation Serif: Regular, Bold, Italic, BoldItalic (full support) -
* Liberation Mono: Regular, Bold, Italic, BoldItalic (full support) - Noto Sans: Regular, Bold,
* Italic, BoldItalic (full support) - DejaVu Sans: Regular, Bold, Oblique, BoldOblique (full
* support) - DejaVu Serif: Regular, Bold, Italic, BoldItalic (full support) - DejaVu Mono:
* Regular, Bold, Oblique, BoldOblique (full support)
*
* <p>To add weight/style support for additional font families: 1. Download the font files
* (Bold, Italic, BoldItalic) to: app/core/src/main/resources/static/fonts/ 2. Register the
* variants in BUILT_IN_FALLBACK_FONTS map (see lines 63-267) 3. Update the check below to
* include the font family prefix
*
* @param baseFontId base fallback font ID (e.g., "fallback-liberation-sans")
* @param isBold true if bold weight needed
* @param isItalic true if italic style needed
* @return styled font ID (e.g., "fallback-liberation-sans-bold"), or base ID if variants not
* available
*/
private String applyWeightStyle(String baseFontId, boolean isBold, boolean isItalic) {
// Only apply weight/style to font families where we have the font files available
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
boolean isSupported =
baseFontId.startsWith("fallback-liberation-")
|| baseFontId.equals("fallback-noto-sans")
|| baseFontId.startsWith("fallback-dejavu-");
if (!isSupported) {
return baseFontId;
}
// DejaVu Sans and Mono use "oblique" instead of "italic"
boolean useOblique =
baseFontId.equals("fallback-dejavu-sans")
|| baseFontId.equals("fallback-dejavu-mono");
if (isBold && isItalic) {
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
} else if (isBold) {
return baseFontId + "-bold";
} else if (isItalic) {
return baseFontId + (useOblique ? "-oblique" : "-italic");
}
return baseFontId;
}
/**
* Resolve fallback font ID based on Unicode code point properties.
*
* @param codePoint the Unicode code point
* @return fallback font ID
*/
public String resolveFallbackFontId(int codePoint) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
return FALLBACK_FONT_CJK_ID;
}
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
case ARABIC -> FALLBACK_FONT_AR_ID;
case THAI -> FALLBACK_FONT_TH_ID;
default -> FALLBACK_FONT_ID;
};
}
public String mapUnsupportedGlyph(int codePoint) {
return switch (codePoint) {
case 0x276E -> "<";
case 0x276F -> ">";
default -> null;
};
}
private FallbackFontSpec getFallbackFontSpec(String fallbackId) {
if (FALLBACK_FONT_ID.equals(fallbackId)) {
String baseName = inferBaseName(fallbackFontLocation, "NotoSans-Regular");
String format = inferFormat(fallbackFontLocation, "ttf");
return new FallbackFontSpec(fallbackFontLocation, baseName, format);
}
return BUILT_IN_FALLBACK_FONTS.get(fallbackId);
}
private byte[] loadFallbackFontBytes(String fallbackId, FallbackFontSpec spec)
throws IOException {
if (spec == null) {
throw new IOException("No fallback font specification for " + fallbackId);
}
byte[] cached = fallbackFontCache.get(fallbackId);
if (cached != null) {
return cached;
}
Resource resource = resourceLoader.getResource(spec.resourceLocation());
if (!resource.exists()) {
throw new IOException("Fallback font resource not found at " + spec.resourceLocation());
}
try (InputStream inputStream = resource.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
inputStream.transferTo(baos);
byte[] bytes = baos.toByteArray();
fallbackFontCache.put(fallbackId, bytes);
return bytes;
}
}
private String inferBaseName(String location, String defaultName) {
if (location == null || location.isBlank()) {
return defaultName;
}
int slash = location.lastIndexOf('/');
String fileName = slash >= 0 ? location.substring(slash + 1) : location;
int dot = fileName.lastIndexOf('.');
if (dot > 0) {
fileName = fileName.substring(0, dot);
}
return fileName.isEmpty() ? defaultName : fileName;
}
private String inferFormat(String location, String defaultFormat) {
if (location == null || location.isBlank()) {
return defaultFormat;
}
int dot = location.lastIndexOf('.');
if (dot >= 0 && dot < location.length() - 1) {
return location.substring(dot + 1).toLowerCase(Locale.ROOT);
}
return defaultFormat;
}
private record FallbackFontSpec(String resourceLocation, String baseName, String format) {}
}
@@ -0,0 +1,308 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SharedSignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
/**
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
* authenticated and unauthenticated users.
*/
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
validateFileName(fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (!Files.exists(allUsersPath)) {
throw new FileNotFoundException("Shared signature file not found");
}
return Files.readAllBytes(allUsersPath);
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
/** Save a signature as image file */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
return response;
}
/** Get all saved signatures for a user */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
return signatures;
}
/** Delete a saved signature */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Try to find and delete image file in personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
// Try shared folder if not found in personal
if (!deleted) {
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found");
}
log.info("Deleted signature {} for user {}", signatureId, username);
}
}
@@ -1,107 +0,0 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
// First try user's personal folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (Files.exists(userPath)) {
return Files.readAllBytes(userPath);
}
// Then try ALL_USERS folder
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (Files.exists(allUsersPath)) {
return Files.readAllBytes(allUsersPath);
}
throw new FileNotFoundException("Signature file not found");
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg")
|| fileName.endsWith(".jpeg")
|| fileName.endsWith(".png")
|| fileName.endsWith(".gif");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
}
}
@@ -0,0 +1,111 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.UserServiceInterface;
/**
* Service to manage job ownership and access control for PDF JSON operations. When security is
* enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally
* accessible.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false)
public class JobOwnershipServiceImpl
implements stirling.software.common.service.JobOwnershipService {
@Autowired(required = false)
private UserServiceInterface userService;
/**
* Get the current authenticated user's identifier. Returns empty if no user is authenticated.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
public Optional<String> getCurrentUserId() {
if (userService == null) {
log.debug("UserService not available");
return Optional.empty();
}
try {
String username = userService.getCurrentUsername();
if (username != null && !username.isEmpty() && !"anonymousUser".equals(username)) {
log.debug("Current authenticated user: {}", username);
return Optional.of(username);
}
} catch (Exception e) {
log.warn("Failed to get current username from UserService: {}", e.getMessage());
}
return Optional.empty();
}
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
public String createScopedJobKey(String jobId) {
Optional<String> userId = getCurrentUserId();
if (userId.isPresent()) {
String scopedKey = userId.get() + ":" + jobId;
log.debug("Created scoped job key: {}", scopedKey);
return scopedKey;
}
log.debug("No user authenticated, using unsecured job key: {}", jobId);
return jobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
public boolean validateJobAccess(String scopedJobKey) {
Optional<String> userId = getCurrentUserId();
// If no user authenticated, allow access (backwards compatibility)
if (userId.isEmpty()) {
log.debug("No authentication required, allowing access to job: {}", scopedJobKey);
return true;
}
// Check if job key starts with current user's ID
String userPrefix = userId.get() + ":";
if (!scopedJobKey.startsWith(userPrefix)) {
log.warn(
"Access denied: User {} attempted to access job key {} which they don't own",
userId.get(),
scopedJobKey);
throw new SecurityException(
"Access denied: You do not have permission to access this job");
}
log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey);
return true;
}
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
public String extractJobId(String scopedJobKey) {
int colonIndex = scopedJobKey.indexOf(':');
if (colonIndex > 0) {
return scopedJobKey.substring(colonIndex + 1);
}
return scopedJobKey;
}
}
@@ -0,0 +1,44 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* No-op implementation of job ownership service when security is disabled. All jobs are globally
* accessible without authentication.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true)
public class NoOpJobOwnershipService
implements stirling.software.common.service.JobOwnershipService {
@Override
public Optional<String> getCurrentUserId() {
// No authentication when security is disabled
return Optional.empty();
}
@Override
public String createScopedJobKey(String jobId) {
// Jobs are not scoped to users when security is disabled
return jobId;
}
@Override
public boolean validateJobAccess(String scopedJobKey) {
// All jobs are accessible when security is disabled
log.trace("Security disabled, allowing access to job: {}", scopedJobKey);
return true;
}
@Override
public String extractJobId(String scopedJobKey) {
// No user prefix when security is disabled
return scopedJobKey;
}
}
@@ -0,0 +1,350 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Base64;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfJsonFontService {
private final TempFileManager tempFileManager;
@Getter
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand;
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript;
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable;
private volatile boolean fontForgeCffConverterAvailable;
@PostConstruct
private void initialiseCffConverterAvailability() {
if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false;
fontForgeCffConverterAvailable = false;
return;
}
log.info("[FONT-DEBUG] CFF conversion enabled, checking tool availability...");
pythonCffConverterAvailable = isCommandAvailable(pythonCommand);
if (!pythonCffConverterAvailable) {
log.warn(
"[FONT-DEBUG] Python command '{}' not found; Python CFF conversion disabled",
pythonCommand);
} else {
log.info("[FONT-DEBUG] Python command '{}' is available", pythonCommand);
}
fontForgeCffConverterAvailable = isCommandAvailable(fontforgeCommand);
if (!fontForgeCffConverterAvailable) {
log.warn(
"[FONT-DEBUG] FontForge command '{}' not found; FontForge CFF conversion disabled",
fontforgeCommand);
} else {
log.info("[FONT-DEBUG] FontForge command '{}' is available", fontforgeCommand);
}
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
}
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn(
"[FONT-DEBUG] CFF conversion skipped: enabled={}, bytes={}",
cffConversionEnabled,
fontBytes == null ? "null" : fontBytes.length);
return null;
}
log.info(
"[FONT-DEBUG] Converting CFF font: {} bytes, method: {}",
fontBytes.length,
cffConverterMethod);
if ("python".equalsIgnoreCase(cffConverterMethod)) {
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
log.debug(
"[FONT-DEBUG] Python conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
} else if ("fontforge".equalsIgnoreCase(cffConverterMethod)) {
if (!fontForgeCffConverterAvailable) {
log.debug(
"[FONT-DEBUG] FontForge CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingFontForge(fontBytes);
log.debug(
"[FONT-DEBUG] FontForge conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
} else {
log.debug(
"[FONT-DEBUG] Unknown CFF converter method: {}, falling back to Python",
cffConverterMethod);
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available, skipping conversion");
return null;
}
byte[] result = convertCffUsingPython(fontBytes, toUnicode);
log.debug(
"[FONT-DEBUG] Python conversion result: {}",
result == null ? "null" : result.length + " bytes");
return result;
}
}
public String detectFontFlavor(byte[] fontBytes) {
if (fontBytes == null || fontBytes.length < 4) {
return null;
}
int signature =
((fontBytes[0] & 0xFF) << 24)
| ((fontBytes[1] & 0xFF) << 16)
| ((fontBytes[2] & 0xFF) << 8)
| (fontBytes[3] & 0xFF);
if (signature == 0x00010000 || signature == 0x74727565) {
return "ttf";
}
if (signature == 0x4F54544F) {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
}
return null;
}
public String detectTrueTypeFormat(byte[] data) {
if (data == null || data.length < 4) {
return null;
}
int signature =
((data[0] & 0xFF) << 24)
| ((data[1] & 0xFF) << 16)
| ((data[2] & 0xFF) << 8)
| (data[3] & 0xFF);
if (signature == 0x00010000) {
return "ttf";
}
if (signature == 0x4F54544F) {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
}
return null;
}
public String validateFontTables(byte[] fontBytes) {
if (fontBytes == null || fontBytes.length < 12) {
return "Font program too small";
}
int numTables = ((fontBytes[4] & 0xFF) << 8) | (fontBytes[5] & 0xFF);
if (numTables <= 0 || numTables > 512) {
return "Invalid numTables: " + numTables;
}
return null;
}
private byte[] convertCffUsingPython(byte[] fontBytes, String toUnicode) {
if (!pythonCffConverterAvailable) {
log.debug("[FONT-DEBUG] Python CFF converter not available");
return null;
}
if (pythonCommand == null
|| pythonCommand.isBlank()
|| pythonScript == null
|| pythonScript.isBlank()) {
log.debug("[FONT-DEBUG] Python converter not configured");
return null;
}
log.debug(
"[FONT-DEBUG] Running Python CFF converter: command={}, script={}",
pythonCommand,
pythonScript);
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
TempFile outputFile = new TempFile(tempFileManager, ".otf");
TempFile toUnicodeFile =
toUnicode != null ? new TempFile(tempFileManager, ".tounicode") : null) {
Files.write(inputFile.getPath(), fontBytes);
if (toUnicodeFile != null) {
try {
byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicode);
Files.write(toUnicodeFile.getPath(), toUnicodeBytes);
} catch (IllegalArgumentException ex) {
log.debug(
"[FONT-DEBUG] Failed to decode ToUnicode data for CFF conversion: {}",
ex.getMessage());
return null;
}
}
String[] command =
buildPythonCommand(
inputFile.getAbsolutePath(),
outputFile.getAbsolutePath(),
toUnicodeFile != null ? toUnicodeFile.getAbsolutePath() : null);
log.debug("[FONT-DEBUG] Executing: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
.runCommandWithOutputHandling(java.util.Arrays.asList(command));
if (result.getRc() != 0) {
log.error(
"[FONT-DEBUG] Python CFF conversion failed with exit code: {}",
result.getRc());
log.error("[FONT-DEBUG] Stdout: {}", result.getMessages());
return null;
}
if (!Files.exists(outputFile.getPath())) {
log.error("[FONT-DEBUG] Python CFF conversion produced no output file");
return null;
}
byte[] data = Files.readAllBytes(outputFile.getPath());
if (data.length == 0) {
log.error("[FONT-DEBUG] Python CFF conversion returned empty output");
return null;
}
log.info(
"[FONT-DEBUG] Python CFF conversion succeeded: {} bytes -> {} bytes",
fontBytes.length,
data.length);
return data;
} catch (IOException | InterruptedException ex) {
if (ex instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
log.error("[FONT-DEBUG] Python CFF conversion exception: {}", ex.getMessage(), ex);
return null;
}
}
public byte[] convertCffUsingFontForge(byte[] fontBytes) {
if (!fontForgeCffConverterAvailable) {
log.debug("FontForge CFF converter not available");
return null;
}
try (TempFile inputFile = new TempFile(tempFileManager, ".cff");
TempFile outputFile = new TempFile(tempFileManager, ".ttf")) {
Files.write(inputFile.getPath(), fontBytes);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CFF_CONVERTER)
.runCommandWithOutputHandling(
java.util.Arrays.asList(
fontforgeCommand,
"-lang=ff",
"-c",
"Open($1); "
+ "ScaleToEm(1000); "
+ "SelectWorthOutputting(); "
+ "SetFontOrder(2); "
+ "Reencode(\"unicode\"); "
+ "RoundToInt(); "
+ "RemoveOverlap(); "
+ "Simplify(); "
+ "CorrectDirection(); "
+ "Generate($2, \"\", 4+16+32); "
+ "Close(); "
+ "Quit()",
inputFile.getAbsolutePath(),
outputFile.getAbsolutePath()));
if (result.getRc() != 0) {
log.warn("FontForge CFF conversion failed: {}", result.getRc());
return null;
}
if (!Files.exists(outputFile.getPath())) {
log.warn("FontForge CFF conversion produced no output");
return null;
}
byte[] data = Files.readAllBytes(outputFile.getPath());
if (data.length == 0) {
log.warn("FontForge CFF conversion returned empty output");
return null;
}
return data;
} catch (IOException | InterruptedException ex) {
if (ex instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
log.warn("FontForge CFF conversion failed: {}", ex.getMessage());
return null;
}
}
private boolean isCommandAvailable(String command) {
if (command == null || command.isBlank()) {
return false;
}
try {
ProcessBuilder processBuilder = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
processBuilder.command("where", command);
} else {
processBuilder.command("which", command);
}
Process process = processBuilder.start();
int exitCode = process.waitFor();
return exitCode == 0;
} catch (Exception e) {
log.debug("Error checking for command {}: {}", command, e.getMessage());
return false;
}
}
private String[] buildPythonCommand(String input, String output, String toUnicode) {
if (toUnicode != null) {
return new String[] {
pythonCommand,
pythonScript,
"--input",
input,
"--output",
output,
"--to-unicode",
toUnicode
};
}
return new String[] {pythonCommand, pythonScript, "--input", input, "--output", output};
}
}
@@ -0,0 +1,474 @@
package stirling.software.SPDF.service.pdfjson;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import javax.imageio.ImageIO;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.contentstream.operator.OperatorName;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.util.Matrix;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
import stirling.software.SPDF.model.json.PdfJsonImageElement;
/**
* Service for handling PDF image operations for JSON conversion (extraction, encoding, rendering).
*/
@Service
@Slf4j
public class PdfJsonImageService {
private record EncodedImage(String base64, String format) {}
private record Bounds(float left, float right, float bottom, float top) {
float width() {
return Math.max(0f, right - left);
}
float height() {
return Math.max(0f, top - bottom);
}
}
/**
* Collects images from all pages in a PDF document.
*
* @param document The PDF document
* @param totalPages Total number of pages
* @param progress Progress callback
* @return Map of page number to list of image elements
* @throws IOException If image extraction fails
*/
public Map<Integer, List<PdfJsonImageElement>> collectImages(
PDDocument document, int totalPages, Consumer<PdfJsonConversionProgress> progress)
throws IOException {
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
int pageNumber = 1;
for (PDPage page : document.getPages()) {
ImageCollectingEngine engine =
new ImageCollectingEngine(page, pageNumber, imagesByPage, imageCache);
engine.processPage(page);
// Update progress for image extraction (70-80%)
int imageProgress = 70 + (int) ((pageNumber / (double) totalPages) * 10);
progress.accept(
PdfJsonConversionProgress.of(
imageProgress, "images", "Extracting images", pageNumber, totalPages));
pageNumber++;
}
return imagesByPage;
}
/**
* Extracts images from a single PDF page (for on-demand lazy loading).
*
* @param document The PDF document
* @param page The specific page to extract images from
* @param pageNumber The page number (1-indexed)
* @return List of image elements for this page
* @throws IOException If image extraction fails
*/
public List<PdfJsonImageElement> extractImagesForPage(
PDDocument document, PDPage page, int pageNumber) throws IOException {
Map<Integer, List<PdfJsonImageElement>> imagesByPage = new LinkedHashMap<>();
ImageCollectingEngine engine =
new ImageCollectingEngine(page, pageNumber, imagesByPage, new IdentityHashMap<>());
engine.processPage(page);
return imagesByPage.getOrDefault(pageNumber, new ArrayList<>());
}
/**
* Draws an image element on a PDF page content stream.
*
* @param contentStream The content stream to draw on
* @param document The PDF document
* @param element The image element to draw
* @param cache Cache of previously created image XObjects
* @throws IOException If drawing fails
*/
public void drawImageElement(
PDPageContentStream contentStream,
PDDocument document,
PdfJsonImageElement element,
Map<String, PDImageXObject> cache)
throws IOException {
if (element == null || element.getImageData() == null || element.getImageData().isBlank()) {
return;
}
String cacheKey =
element.getId() != null && !element.getId().isBlank()
? element.getId()
: Integer.toHexString(System.identityHashCode(element));
PDImageXObject image = cache.get(cacheKey);
if (image == null) {
image = createImageXObject(document, element);
if (image == null) {
return;
}
cache.put(cacheKey, image);
}
List<Float> transform = element.getTransform();
if (transform != null && transform.size() == 6) {
Matrix matrix =
new Matrix(
safeFloat(transform.get(0), 1f),
safeFloat(transform.get(1), 0f),
safeFloat(transform.get(2), 0f),
safeFloat(transform.get(3), 1f),
safeFloat(transform.get(4), 0f),
safeFloat(transform.get(5), 0f));
contentStream.drawImage(image, matrix);
return;
}
float width = safeFloat(element.getWidth(), fallbackWidth(element));
float height = safeFloat(element.getHeight(), fallbackHeight(element));
if (width <= 0f) {
width = Math.max(1f, fallbackWidth(element));
}
if (height <= 0f) {
height = Math.max(1f, fallbackHeight(element));
}
float left = resolveLeft(element, width);
float bottom = resolveBottom(element, height);
contentStream.drawImage(image, left, bottom, width, height);
}
/**
* Creates a PDImageXObject from a PdfJsonImageElement.
*
* @param document The PDF document
* @param element The image element with base64 data
* @return The created image XObject
* @throws IOException If image creation fails
*/
public PDImageXObject createImageXObject(PDDocument document, PdfJsonImageElement element)
throws IOException {
byte[] data;
try {
data = Base64.getDecoder().decode(element.getImageData());
} catch (IllegalArgumentException ex) {
log.debug("Failed to decode image element: {}", ex.getMessage());
return null;
}
String name = element.getId() != null ? element.getId() : UUID.randomUUID().toString();
return PDImageXObject.createFromByteArray(document, data, name);
}
private EncodedImage encodeImage(PDImage image) {
try {
BufferedImage bufferedImage = image.getImage();
if (bufferedImage == null) {
return null;
}
String format = resolveImageFormat(image);
if (format == null || format.isBlank()) {
format = "png";
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
boolean written = ImageIO.write(bufferedImage, format, baos);
if (!written) {
if (!"png".equalsIgnoreCase(format)) {
baos.reset();
if (!ImageIO.write(bufferedImage, "png", baos)) {
return null;
}
format = "png";
} else {
return null;
}
}
return new EncodedImage(Base64.getEncoder().encodeToString(baos.toByteArray()), format);
} catch (IOException ex) {
log.debug("Failed to encode image: {}", ex.getMessage());
return null;
}
}
private String resolveImageFormat(PDImage image) {
if (image instanceof PDImageXObject xObject) {
String suffix = xObject.getSuffix();
if (suffix != null && !suffix.isBlank()) {
return suffix.toLowerCase(Locale.ROOT);
}
}
return "png";
}
private float fallbackWidth(PdfJsonImageElement element) {
if (element.getRight() != null && element.getLeft() != null) {
return Math.max(0f, element.getRight() - element.getLeft());
}
if (element.getNativeWidth() != null) {
return element.getNativeWidth();
}
return 1f;
}
private float fallbackHeight(PdfJsonImageElement element) {
if (element.getTop() != null && element.getBottom() != null) {
return Math.max(0f, element.getTop() - element.getBottom());
}
if (element.getNativeHeight() != null) {
return element.getNativeHeight();
}
return 1f;
}
private float resolveLeft(PdfJsonImageElement element, float width) {
if (element.getLeft() != null) {
return element.getLeft();
}
if (element.getX() != null) {
return element.getX();
}
if (element.getRight() != null) {
return element.getRight() - width;
}
return 0f;
}
private float resolveBottom(PdfJsonImageElement element, float height) {
if (element.getBottom() != null) {
return element.getBottom();
}
if (element.getY() != null) {
return element.getY();
}
if (element.getTop() != null) {
return element.getTop() - height;
}
return 0f;
}
private List<Float> toMatrixValues(Matrix matrix) {
List<Float> values = new ArrayList<>(6);
values.add(matrix.getValue(0, 0));
values.add(matrix.getValue(0, 1));
values.add(matrix.getValue(1, 0));
values.add(matrix.getValue(1, 1));
values.add(matrix.getValue(2, 0));
values.add(matrix.getValue(2, 1));
return values;
}
private float safeFloat(Float value, float defaultValue) {
if (value == null || Float.isNaN(value) || Float.isInfinite(value)) {
return defaultValue;
}
return value;
}
/**
* Inner engine that extends PDFGraphicsStreamEngine to collect images from PDF content streams.
*/
private class ImageCollectingEngine extends PDFGraphicsStreamEngine {
private final int pageNumber;
private final Map<Integer, List<PdfJsonImageElement>> imagesByPage;
private final Map<COSBase, EncodedImage> imageCache;
private COSName currentXObjectName;
private int imageCounter = 0;
protected ImageCollectingEngine(
PDPage page,
int pageNumber,
Map<Integer, List<PdfJsonImageElement>> imagesByPage,
Map<COSBase, EncodedImage> imageCache)
throws IOException {
super(page);
this.pageNumber = pageNumber;
this.imagesByPage = imagesByPage;
this.imageCache = imageCache;
}
@Override
public void processPage(PDPage page) throws IOException {
super.processPage(page);
}
@Override
public void drawImage(PDImage pdImage) throws IOException {
EncodedImage encoded = getOrEncodeImage(pdImage);
if (encoded == null) {
return;
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
Bounds bounds = computeBounds(ctm);
List<Float> matrixValues = toMatrixValues(ctm);
PdfJsonImageElement element =
PdfJsonImageElement.builder()
.id(UUID.randomUUID().toString())
.objectName(
currentXObjectName != null
? currentXObjectName.getName()
: null)
.inlineImage(!(pdImage instanceof PDImageXObject))
.nativeWidth(pdImage.getWidth())
.nativeHeight(pdImage.getHeight())
.x(bounds.left)
.y(bounds.bottom)
.width(bounds.width())
.height(bounds.height())
.left(bounds.left)
.right(bounds.right)
.top(bounds.top)
.bottom(bounds.bottom)
.transform(matrixValues)
.zOrder(-1_000_000 + imageCounter)
.imageData(encoded.base64())
.imageFormat(encoded.format())
.build();
imageCounter++;
imagesByPage.computeIfAbsent(pageNumber, key -> new ArrayList<>()).add(element);
}
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3)
throws IOException {
// Not needed for image extraction
}
@Override
public void clip(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void moveTo(float x, float y) throws IOException {
// Not needed for image extraction
}
@Override
public void lineTo(float x, float y) throws IOException {
// Not needed for image extraction
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
throws IOException {
// Not needed for image extraction
}
@Override
public Point2D getCurrentPoint() throws IOException {
return new Point2D.Float();
}
@Override
public void closePath() throws IOException {
// Not needed for image extraction
}
@Override
public void endPath() throws IOException {
// Not needed for image extraction
}
@Override
public void shadingFill(COSName shadingName) throws IOException {
// Not needed for image extraction
}
@Override
public void fillAndStrokePath(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void fillPath(int windingRule) throws IOException {
// Not needed for image extraction
}
@Override
public void strokePath() throws IOException {
// Not needed for image extraction
}
@Override
protected void processOperator(Operator operator, List<COSBase> operands)
throws IOException {
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
&& !operands.isEmpty()
&& operands.get(0) instanceof COSName name) {
currentXObjectName = name;
}
super.processOperator(operator, operands);
currentXObjectName = null;
}
private EncodedImage getOrEncodeImage(PDImage pdImage) {
if (pdImage == null) {
return null;
}
if (pdImage instanceof PDImageXObject xObject) {
if (xObject.isStencil()) {
return encodeImage(pdImage);
}
COSBase key = xObject.getCOSObject();
EncodedImage cached = imageCache.get(key);
if (cached != null) {
return cached;
}
EncodedImage encoded = encodeImage(pdImage);
if (encoded != null) {
imageCache.put(key, encoded);
}
return encoded;
}
return encodeImage(pdImage);
}
private Bounds computeBounds(Matrix ctm) {
AffineTransform transform = ctm.createAffineTransform();
Point2D.Float p0 = new Point2D.Float(0, 0);
Point2D.Float p1 = new Point2D.Float(1, 0);
Point2D.Float p2 = new Point2D.Float(0, 1);
Point2D.Float p3 = new Point2D.Float(1, 1);
transform.transform(p0, p0);
transform.transform(p1, p1);
transform.transform(p2, p2);
transform.transform(p3, p3);
float minX = Math.min(Math.min(p0.x, p1.x), Math.min(p2.x, p3.x));
float maxX = Math.max(Math.max(p0.x, p1.x), Math.max(p2.x, p3.x));
float minY = Math.min(Math.min(p0.y, p1.y), Math.min(p2.y, p3.y));
float maxY = Math.max(Math.max(p0.y, p1.y), Math.max(p2.y, p3.y));
if (!Float.isFinite(minX) || !Float.isFinite(minY)) {
return new Bounds(0f, 0f, 0f, 0f);
}
return new Bounds(minX, maxX, minY, maxY);
}
}
}
@@ -0,0 +1,148 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.Calendar;
import java.util.Optional;
import java.util.TimeZone;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonMetadata;
/** Service for extracting and applying PDF metadata (document info and XMP) for JSON conversion. */
@Service
@Slf4j
public class PdfJsonMetadataService {
/**
* Extracts document information metadata from a PDF.
*
* @param document The PDF document
* @return Metadata model with document info
*/
public PdfJsonMetadata extractMetadata(PDDocument document) {
PdfJsonMetadata metadata = new PdfJsonMetadata();
PDDocumentInformation info = document.getDocumentInformation();
if (info != null) {
metadata.setTitle(info.getTitle());
metadata.setAuthor(info.getAuthor());
metadata.setSubject(info.getSubject());
metadata.setKeywords(info.getKeywords());
metadata.setCreator(info.getCreator());
metadata.setProducer(info.getProducer());
metadata.setCreationDate(formatCalendar(info.getCreationDate()));
metadata.setModificationDate(formatCalendar(info.getModificationDate()));
metadata.setTrapped(info.getTrapped());
}
metadata.setNumberOfPages(document.getNumberOfPages());
return metadata;
}
/**
* Extracts XMP metadata from a PDF as base64-encoded string.
*
* @param document The PDF document
* @return Base64-encoded XMP metadata, or null if not present
*/
public String extractXmpMetadata(PDDocument document) {
if (document.getDocumentCatalog() == null) {
return null;
}
PDMetadata metadata = document.getDocumentCatalog().getMetadata();
if (metadata == null) {
return null;
}
try (InputStream inputStream = metadata.createInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
inputStream.transferTo(baos);
byte[] data = baos.toByteArray();
if (data.length == 0) {
return null;
}
return Base64.getEncoder().encodeToString(data);
} catch (IOException ex) {
log.debug("Failed to extract XMP metadata: {}", ex.getMessage());
return null;
}
}
/**
* Applies metadata to a PDF document.
*
* @param document The PDF document
* @param metadata The metadata to apply
*/
public void applyMetadata(PDDocument document, PdfJsonMetadata metadata) {
if (metadata == null) {
return;
}
PDDocumentInformation info = document.getDocumentInformation();
info.setTitle(metadata.getTitle());
info.setAuthor(metadata.getAuthor());
info.setSubject(metadata.getSubject());
info.setKeywords(metadata.getKeywords());
info.setCreator(metadata.getCreator());
info.setProducer(metadata.getProducer());
if (metadata.getCreationDate() != null) {
parseInstant(metadata.getCreationDate())
.ifPresent(instant -> info.setCreationDate(toCalendar(instant)));
}
if (metadata.getModificationDate() != null) {
parseInstant(metadata.getModificationDate())
.ifPresent(instant -> info.setModificationDate(toCalendar(instant)));
}
info.setTrapped(metadata.getTrapped());
}
/**
* Applies XMP metadata to a PDF document from base64-encoded string.
*
* @param document The PDF document
* @param base64 Base64-encoded XMP metadata
*/
public void applyXmpMetadata(PDDocument document, String base64) {
if (base64 == null || base64.isBlank()) {
return;
}
try (InputStream inputStream =
new ByteArrayInputStream(Base64.getDecoder().decode(base64))) {
PDMetadata metadata = new PDMetadata(document, inputStream);
document.getDocumentCatalog().setMetadata(metadata);
} catch (IllegalArgumentException | IOException ex) {
log.debug("Failed to apply XMP metadata: {}", ex.getMessage());
}
}
private String formatCalendar(Calendar calendar) {
if (calendar == null) {
return null;
}
return calendar.toInstant().toString();
}
private Optional<Instant> parseInstant(String value) {
try {
return Optional.of(Instant.parse(value));
} catch (DateTimeParseException ex) {
log.warn("Failed to parse instant '{}': {}", value, ex.getMessage());
return Optional.empty();
}
}
private Calendar toCalendar(Instant instant) {
Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
calendar.setTimeInMillis(instant.toEpochMilli());
return calendar;
}
}
@@ -0,0 +1,308 @@
package stirling.software.SPDF.service.pdfjson;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PdfJsonConversionProgress;
import stirling.software.SPDF.model.json.PdfJsonAnnotation;
import stirling.software.SPDF.model.json.PdfJsonCosValue;
import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata;
import stirling.software.SPDF.model.json.PdfJsonFont;
import stirling.software.SPDF.model.json.PdfJsonImageElement;
import stirling.software.SPDF.model.json.PdfJsonPage;
import stirling.software.SPDF.model.json.PdfJsonPageDimension;
import stirling.software.SPDF.model.json.PdfJsonStream;
import stirling.software.SPDF.model.json.PdfJsonTextElement;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.ExceptionUtils;
/**
* Service for lazy loading PDF pages. Caches PDF documents and extracts pages on-demand to reduce
* memory usage for large PDFs.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class PdfLazyLoadingService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TaskManager taskManager;
private final PdfJsonMetadataService metadataService;
private final PdfJsonImageService imageService;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
/**
* Stores PDF file bytes for lazy page loading. Each page is extracted on-demand by re-loading
* the PDF from bytes.
*/
@Data
private static class CachedPdfDocument {
private final byte[] pdfBytes;
private final PdfJsonDocumentMetadata metadata;
private final long timestamp;
public CachedPdfDocument(byte[] pdfBytes, PdfJsonDocumentMetadata metadata) {
this.pdfBytes = pdfBytes;
this.metadata = metadata;
this.timestamp = System.currentTimeMillis();
}
}
/**
* Extracts document metadata, fonts, and page dimensions without page content. Caches the PDF
* bytes for subsequent page requests.
*
* @param file The uploaded PDF file
* @param jobId The job ID for caching
* @param fonts Font map (will be populated)
* @param pageFontResources Page font resources map (will be populated)
* @return Serialized metadata JSON
* @throws IOException If extraction fails
*/
public byte[] extractDocumentMetadata(
MultipartFile file,
String jobId,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources)
throws IOException {
if (file == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
Consumer<PdfJsonConversionProgress> progress =
jobId != null
? (p) -> {
log.info(
"Progress: [{}%] {} - {}{}",
p.getPercent(),
p.getStage(),
p.getMessage(),
(p.getCurrent() != null && p.getTotal() != null)
? String.format(
" (%d/%d)", p.getCurrent(), p.getTotal())
: "");
reportProgressToTaskManager(jobId, p);
}
: (p) -> {};
// Read PDF bytes once for processing and caching
byte[] pdfBytes = file.getBytes();
try (PDDocument document = pdfDocumentFactory.load(pdfBytes, true)) {
int totalPages = document.getNumberOfPages();
// Build metadata response
progress.accept(PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(metadataService.extractMetadata(document));
docMetadata.setXmpMetadata(metadataService.extractXmpMetadata(document));
docMetadata.setLazyImages(Boolean.TRUE);
List<PdfJsonFont> serializedFonts = new ArrayList<>(fonts.values());
serializedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
docMetadata.setFonts(serializedFonts);
// Extract page dimensions
List<PdfJsonPageDimension> pageDimensions = new ArrayList<>();
int pageIndex = 0;
for (PDPage page : document.getPages()) {
PdfJsonPageDimension dim = new PdfJsonPageDimension();
dim.setPageNumber(pageIndex + 1);
PDRectangle mediaBox = page.getMediaBox();
dim.setWidth(mediaBox.getWidth());
dim.setHeight(mediaBox.getHeight());
dim.setRotation(page.getRotation());
pageDimensions.add(dim);
pageIndex++;
}
docMetadata.setPageDimensions(pageDimensions);
// Cache PDF bytes and metadata for lazy page loading
if (jobId != null) {
CachedPdfDocument cached = new CachedPdfDocument(pdfBytes, docMetadata);
documentCache.put(jobId, cached);
log.info(
"Cached PDF bytes ({} bytes) for lazy loading, jobId: {}",
pdfBytes.length,
jobId);
// Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId);
}
progress.accept(
PdfJsonConversionProgress.of(100, "complete", "Metadata extraction complete"));
return objectMapper.writeValueAsBytes(docMetadata);
}
}
/**
* Extracts a single page from cached PDF bytes. Re-loads the PDF for each request.
*
* @param jobId The job ID
* @param pageNumber The page number (1-indexed)
* @param serializeCosValue Function to serialize COS values
* @param extractContentStreams Function to extract content streams
* @param filterImageXObjectsFromResources Function to filter image XObjects
* @param extractText Function to extract text elements for the page
* @param extractAnnotations Function to extract annotations for the page
* @return Serialized page JSON
* @throws IOException If extraction fails
*/
public byte[] extractSinglePage(
String jobId,
int pageNumber,
java.util.function.Function<COSBase, PdfJsonCosValue> serializeCosValue,
java.util.function.Function<PDPage, List<PdfJsonStream>> extractContentStreams,
java.util.function.Function<COSBase, COSBase> filterImageXObjectsFromResources,
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonTextElement>>
extractText,
java.util.function.BiFunction<PDDocument, Integer, List<PdfJsonAnnotation>>
extractAnnotations)
throws IOException {
CachedPdfDocument cached = documentCache.get(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
}
int pageIndex = pageNumber - 1;
int totalPages = cached.getMetadata().getPageDimensions().size();
if (pageIndex < 0 || pageIndex >= totalPages) {
throw new IllegalArgumentException(
"Page number " + pageNumber + " out of range (1-" + totalPages + ")");
}
log.debug("Loading PDF from bytes to extract page {} (jobId: {})", pageNumber, jobId);
// Re-load PDF from cached bytes and extract the single page
try (PDDocument document = pdfDocumentFactory.load(cached.getPdfBytes(), true)) {
PDPage page = document.getPage(pageIndex);
PdfJsonPage pageModel = new PdfJsonPage();
pageModel.setPageNumber(pageNumber);
PDRectangle mediaBox = page.getMediaBox();
pageModel.setWidth(mediaBox.getWidth());
pageModel.setHeight(mediaBox.getHeight());
pageModel.setRotation(page.getRotation());
// Extract text on-demand
pageModel.setTextElements(extractText.apply(document, pageNumber));
// Extract annotations on-demand
pageModel.setAnnotations(extractAnnotations.apply(document, pageNumber));
// Extract images on-demand
List<PdfJsonImageElement> images =
imageService.extractImagesForPage(document, page, pageNumber);
pageModel.setImageElements(images);
// Extract resources and content streams
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources.apply(resourcesBase);
pageModel.setResources(serializeCosValue.apply(filteredResources));
pageModel.setContentStreams(extractContentStreams.apply(page));
log.debug(
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
pageNumber,
pageModel.getTextElements().size(),
images.size(),
pageModel.getAnnotations().size(),
jobId);
return objectMapper.writeValueAsBytes(pageModel);
}
}
/** Clears a cached document. */
public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = documentCache.remove(jobId);
if (cached != null) {
log.info(
"Removed cached PDF bytes ({} bytes) for jobId: {}",
cached.getPdfBytes().length,
jobId);
}
}
/** Schedules automatic cleanup of cached documents after 30 minutes. */
private void scheduleDocumentCleanup(String jobId) {
new Thread(
() -> {
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(30));
clearCachedDocument(jobId);
log.info("Auto-cleaned cached document for jobId: {}", jobId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
})
.start();
}
/**
* Report progress to TaskManager for async jobs
*
* @param jobId The job ID
* @param progress The progress update
*/
private void reportProgressToTaskManager(String jobId, PdfJsonConversionProgress progress) {
try {
log.info(
"Reporting progress for job {}: {}% - {}",
jobId, progress.getPercent(), progress.getStage());
String note;
if (progress.getCurrent() != null && progress.getTotal() != null) {
note =
String.format(
"[%d%%] %s: %s (%d/%d)",
progress.getPercent(),
progress.getStage(),
progress.getMessage(),
progress.getCurrent(),
progress.getTotal());
} else {
note =
String.format(
"[%d%%] %s: %s",
progress.getPercent(), progress.getStage(), progress.getMessage());
}
boolean added = taskManager.addNote(jobId, note);
if (!added) {
log.warn("Failed to add note - job {} not found in TaskManager", jobId);
} else {
log.info("Successfully added progress note for job {}: {}", jobId, note);
}
} catch (Exception e) {
log.error("Exception reporting progress for job {}: {}", jobId, e.getMessage(), e);
}
}
}
@@ -0,0 +1,17 @@
package stirling.software.SPDF.service.pdfjson.type3;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Type3ConversionRequest {
private final PDDocument document;
private final PDType3Font font;
private final String fontId;
private final int pageNumber;
private final String fontUid;
}
@@ -0,0 +1,32 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
public interface Type3ConversionStrategy {
/** Unique identifier used when reporting results. */
String getId();
/** Human-readable label for UI toggles or logs. */
String getLabel();
/** True when the underlying tooling is usable on this host. */
boolean isAvailable();
/** Quick predicate to avoid running on unsupported Type3 shapes. */
default boolean supports(Type3ConversionRequest request, Type3GlyphContext context)
throws IOException {
return request != null && request.getFont() != null;
}
/**
* Attempt to synthesise a font program for the supplied Type3 font.
*
* @param request contextual information for the conversion attempt
* @return a candidate describing the outcome, never {@code null}
*/
PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException;
}
@@ -0,0 +1,133 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
@Slf4j
@Service
@RequiredArgsConstructor
public class Type3FontConversionService {
private final List<Type3ConversionStrategy> strategies;
private final Type3GlyphExtractor glyphExtractor;
public List<PdfJsonFontConversionCandidate> synthesize(Type3ConversionRequest request) {
if (request == null || request.getFont() == null) {
return Collections.emptyList();
}
if (strategies == null || strategies.isEmpty()) {
log.debug(
"[TYPE3] No conversion strategies registered for font {}", request.getFontId());
return Collections.emptyList();
}
List<PdfJsonFontConversionCandidate> candidates = new ArrayList<>();
Type3GlyphContext glyphContext = null;
for (Type3ConversionStrategy strategy : strategies) {
if (strategy == null) {
continue;
}
PdfJsonFontConversionCandidate candidate =
runStrategy(
strategy,
request,
glyphContext == null
? (glyphContext =
new Type3GlyphContext(request, glyphExtractor))
: glyphContext);
if (candidate != null) {
candidates.add(candidate);
}
}
return candidates;
}
private PdfJsonFontConversionCandidate runStrategy(
Type3ConversionStrategy strategy,
Type3ConversionRequest request,
Type3GlyphContext glyphContext) {
if (!strategy.isAvailable()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.SKIPPED)
.message("Strategy unavailable on current host")
.build();
}
try {
if (!strategy.supports(request, glyphContext)) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("Font not supported by strategy")
.build();
}
} catch (IOException supportCheckException) {
log.warn(
"[TYPE3] Strategy {} support check failed for font {}: {}",
strategy.getId(),
request.getFontUid(),
supportCheckException.getMessage(),
supportCheckException);
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("Support check failed: " + supportCheckException.getMessage())
.build();
}
try {
PdfJsonFontConversionCandidate result = strategy.convert(request, glyphContext);
if (result == null) {
log.info(
"[TYPE3] Strategy {} returned null result for font {}",
strategy.getId(),
request.getFontUid());
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("Strategy returned null result")
.build();
}
if (result.getStrategyId() == null) {
result.setStrategyId(strategy.getId());
}
if (result.getStrategyLabel() == null) {
result.setStrategyLabel(strategy.getLabel());
}
log.debug(
"[TYPE3] Strategy {} finished with status {} (message: {}) for font {}",
strategy.getId(),
result.getStatus(),
result.getMessage(),
request.getFontUid());
return result;
} catch (IOException ex) {
log.warn(
"[TYPE3] Strategy {} failed for font {}: {}",
strategy.getId(),
request.getFontUid(),
ex.getMessage(),
ex);
return PdfJsonFontConversionCandidate.builder()
.strategyId(strategy.getId())
.strategyLabel(strategy.getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message(ex.getMessage())
.build();
}
}
}
@@ -0,0 +1,218 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.font.encoding.Encoding;
import org.apache.pdfbox.util.Matrix;
/**
* Computes a reproducible hash for Type3 fonts so we can match them against a pre-built library of
* converted programs. The signature intentionally combines multiple aspects of the font (encoding,
* CharProc streams, glyph widths, font metrics) to minimise collisions between unrelated fonts that
* coincidentally share glyph names.
*/
public final class Type3FontSignatureCalculator {
private Type3FontSignatureCalculator() {}
public static String computeSignature(PDType3Font font) throws IOException {
if (font == null) {
return null;
}
MessageDigest digest = newDigest();
updateMatrix(digest, font.getFontMatrix());
updateRectangle(digest, font.getFontBBox());
updateEncoding(digest, font.getEncoding());
updateCharProcs(digest, font);
byte[] hash = digest.digest();
return "sha256:" + toHex(hash);
}
private static void updateEncoding(MessageDigest digest, Encoding encoding) {
if (encoding == null) {
updateInt(digest, -1);
return;
}
for (int code = 0; code <= 0xFF; code++) {
String name = encoding.getName(code);
if (name != null) {
updateInt(digest, code);
updateString(digest, name);
}
}
}
private static void updateCharProcs(MessageDigest digest, PDType3Font font) throws IOException {
COSDictionary charProcs =
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
if (charProcs == null || charProcs.size() == 0) {
updateInt(digest, 0);
return;
}
List<COSName> glyphNames = new ArrayList<>(charProcs.keySet());
glyphNames.sort(Comparator.comparing(COSName::getName, String.CASE_INSENSITIVE_ORDER));
for (COSName glyphName : glyphNames) {
updateString(digest, glyphName.getName());
int code = resolveCharCode(font, glyphName.getName());
updateInt(digest, code);
if (code >= 0) {
try {
updateFloat(digest, font.getWidthFromFont(code));
} catch (IOException ignored) {
updateFloat(digest, 0f);
}
} else {
updateFloat(digest, 0f);
}
COSStream stream =
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
? cosStream
: null;
if (stream != null) {
byte[] payload = readAllBytes(stream);
updateInt(digest, payload.length);
digest.update(payload);
PDType3CharProc charProc = new PDType3CharProc(font, stream);
updateRectangle(digest, extractGlyphBoundingBox(font, charProc));
} else {
updateInt(digest, -1);
}
}
updateInt(digest, glyphNames.size());
}
private static byte[] readAllBytes(COSStream stream) throws IOException {
try (InputStream inputStream = stream.createInputStream()) {
return inputStream.readAllBytes();
}
}
private static COSArray extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
if (charProc == null) {
return null;
}
COSStream stream = charProc.getCOSObject();
if (stream != null) {
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
if (bboxArray != null && bboxArray.size() == 4) {
return bboxArray;
}
}
return font.getCOSObject().getCOSArray(COSName.BBOX);
}
private static int resolveCharCode(PDType3Font font, String glyphName) {
if (glyphName == null || font.getEncoding() == null) {
return -1;
}
Encoding encoding = font.getEncoding();
for (int code = 0; code <= 0xFF; code++) {
String name = encoding.getName(code);
if (glyphName.equals(name)) {
return code;
}
}
return -1;
}
private static void updateMatrix(MessageDigest digest, Matrix matrix) {
if (matrix == null) {
updateInt(digest, -1);
return;
}
float[][] values = matrix.getValues();
updateInt(digest, values.length);
for (float[] row : values) {
if (row == null) {
updateInt(digest, -1);
continue;
}
updateInt(digest, row.length);
for (float value : row) {
updateFloat(digest, value);
}
}
}
private static void updateRectangle(MessageDigest digest, PDRectangle rectangle) {
if (rectangle == null) {
updateInt(digest, -1);
return;
}
updateFloat(digest, rectangle.getLowerLeftX());
updateFloat(digest, rectangle.getLowerLeftY());
updateFloat(digest, rectangle.getUpperRightX());
updateFloat(digest, rectangle.getUpperRightY());
}
private static void updateRectangle(MessageDigest digest, COSArray array) {
if (array == null) {
updateInt(digest, -1);
return;
}
updateInt(digest, array.size());
for (int i = 0; i < array.size(); i++) {
COSBase value = array.getObject(i);
if (value instanceof COSNumber number) {
updateFloat(digest, number.floatValue());
} else {
updateFloat(digest, 0f);
}
}
}
private static void updateString(MessageDigest digest, String value) {
if (value == null) {
updateInt(digest, -1);
return;
}
byte[] bytes = value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
updateInt(digest, bytes.length);
digest.update(bytes);
}
private static void updateInt(MessageDigest digest, int value) {
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
}
private static void updateFloat(MessageDigest digest, float value) {
if (Float.isNaN(value) || Float.isInfinite(value)) {
value = 0f;
}
digest.update(ByteBuffer.allocate(Float.BYTES).putFloat(value).array());
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("Missing SHA-256 MessageDigest", ex);
}
}
private static String toHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte value : bytes) {
builder.append(String.format(Locale.ROOT, "%02x", Byte.toUnsignedInt(value)));
}
return builder.toString();
}
}
@@ -0,0 +1,38 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
class Type3GlyphContext {
private final Type3ConversionRequest request;
private final Type3GlyphExtractor extractor;
private final AtomicReference<List<Type3GlyphOutline>> glyphs = new AtomicReference<>();
Type3GlyphContext(Type3ConversionRequest request, Type3GlyphExtractor extractor) {
this.request = request;
this.extractor = extractor;
}
public List<Type3GlyphOutline> getGlyphs() throws IOException {
List<Type3GlyphOutline> cached = glyphs.get();
if (cached == null) {
cached =
extractor.extractGlyphs(
request.getDocument(),
request.getFont(),
request.getFontId(),
request.getPageNumber());
glyphs.compareAndSet(null, cached);
}
return cached;
}
public PDType3Font getFont() {
return request.getFont();
}
}
@@ -0,0 +1,126 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.awt.geom.GeneralPath;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
@Slf4j
@Component
public class Type3GlyphExtractor {
public List<Type3GlyphOutline> extractGlyphs(
PDDocument document, PDType3Font font, String fontId, int pageNumber)
throws IOException {
Objects.requireNonNull(font, "font");
COSDictionary charProcs =
(COSDictionary) font.getCOSObject().getDictionaryObject(COSName.CHAR_PROCS);
if (charProcs == null || charProcs.size() == 0) {
return List.of();
}
List<Type3GlyphOutline> outlines = new ArrayList<>();
for (COSName glyphName : charProcs.keySet()) {
COSStream stream =
charProcs.getDictionaryObject(glyphName) instanceof COSStream cosStream
? cosStream
: null;
if (stream == null) {
continue;
}
PDType3CharProc charProc = new PDType3CharProc(font, stream);
outlines.add(analyseGlyph(document, font, glyphName, charProc, fontId, pageNumber));
}
return outlines;
}
private Type3GlyphOutline analyseGlyph(
PDDocument document,
PDType3Font font,
COSName glyphName,
PDType3CharProc charProc,
String fontId,
int pageNumber)
throws IOException {
int code = resolveCharCode(font, glyphName.getName());
float advanceWidth = 0f;
if (code >= 0) {
advanceWidth = font.getWidthFromFont(code);
}
PDRectangle glyphBBox = extractGlyphBoundingBox(font, charProc);
PDRectangle bbox = font.getFontBBox();
GlyphGraphicsExtractor extractor =
new GlyphGraphicsExtractor(new PDPage(bbox != null ? bbox : new PDRectangle()));
extractor.process(charProc);
GeneralPath outline = extractor.getAccumulatedPath();
Integer unicodeValue = null;
if (code >= 0) {
String unicode = font.toUnicode(code);
if (unicode != null && !unicode.isEmpty()) {
unicodeValue = unicode.codePointAt(0);
} else {
unicodeValue = code;
}
}
return Type3GlyphOutline.builder()
.glyphName(glyphName.getName())
.charCode(code)
.advanceWidth(advanceWidth)
.boundingBox(glyphBBox)
.outline(outline)
.hasFill(extractor.isSawFill())
.hasStroke(extractor.isSawStroke())
.hasImages(extractor.isSawImage())
.hasText(extractor.isSawText())
.hasShading(extractor.isSawShading())
.warnings(extractor.getWarnings())
.unicode(unicodeValue)
.build();
}
private PDRectangle extractGlyphBoundingBox(PDType3Font font, PDType3CharProc charProc) {
COSStream stream = charProc != null ? charProc.getCOSObject() : null;
if (stream != null) {
COSArray bboxArray = (COSArray) stream.getDictionaryObject(COSName.BBOX);
if (bboxArray != null && bboxArray.size() == 4) {
return new PDRectangle(bboxArray);
}
}
return font.getFontBBox();
}
private int resolveCharCode(PDType3Font font, String glyphName) {
if (glyphName == null || font.getEncoding() == null) {
return -1;
}
for (int code = 0; code <= 0xFF; code++) {
String name = font.getEncoding().getName(code);
if (glyphName.equals(name)) {
return code;
}
}
return -1;
}
private static final class GlyphGraphicsExtractor extends Type3GraphicsEngine {
GlyphGraphicsExtractor(PDPage page) {
super(page);
}
}
}
@@ -0,0 +1,164 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.awt.geom.GeneralPath;
import java.awt.geom.Point2D;
import java.io.IOException;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
import org.apache.pdfbox.util.Matrix;
import org.apache.pdfbox.util.Vector;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
class Type3GraphicsEngine extends PDFGraphicsStreamEngine {
private final GeneralPath accumulatedPath = new GeneralPath();
private final GeneralPath linePath = new GeneralPath();
private final Point2D.Float currentPoint = new Point2D.Float();
private boolean hasCurrentPoint;
@Getter private boolean sawStroke;
@Getter private boolean sawFill;
@Getter private boolean sawImage;
@Getter private boolean sawText;
@Getter private boolean sawShading;
@Getter private String warnings;
protected Type3GraphicsEngine(PDPage page) {
super(page);
}
public GeneralPath getAccumulatedPath() {
return (GeneralPath) accumulatedPath.clone();
}
public void process(PDType3CharProc charProc) throws IOException {
accumulatedPath.reset();
linePath.reset();
sawStroke = false;
sawFill = false;
sawImage = false;
sawText = false;
sawShading = false;
warnings = null;
if (charProc != null) {
processChildStream(charProc, getPage());
}
}
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) throws IOException {
moveTo((float) p0.getX(), (float) p0.getY());
lineTo((float) p1.getX(), (float) p1.getY());
lineTo((float) p2.getX(), (float) p2.getY());
lineTo((float) p3.getX(), (float) p3.getY());
closePath();
}
@Override
public void drawImage(PDImage pdImage) throws IOException {
sawImage = true;
}
@Override
public void shadingFill(COSName shadingName) throws IOException {
sawShading = true;
}
@Override
public void strokePath() throws IOException {
accumulatedPath.append(linePath, false);
linePath.reset();
sawStroke = true;
}
@Override
public void fillPath(int windingRule) throws IOException {
linePath.setWindingRule(
windingRule == 0 ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO);
accumulatedPath.append(linePath, false);
linePath.reset();
sawFill = true;
}
@Override
public void fillAndStrokePath(int windingRule) throws IOException {
fillPath(windingRule);
sawStroke = true;
}
@Override
public void clip(int windingRule) throws IOException {
// ignore
}
@Override
public void moveTo(float x, float y) throws IOException {
linePath.moveTo(x, y);
currentPoint.setLocation(x, y);
hasCurrentPoint = true;
}
@Override
public void lineTo(float x, float y) throws IOException {
linePath.lineTo(x, y);
currentPoint.setLocation(x, y);
hasCurrentPoint = true;
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
throws IOException {
linePath.curveTo(x1, y1, x2, y2, x3, y3);
currentPoint.setLocation(x3, y3);
hasCurrentPoint = true;
}
@Override
public Point2D getCurrentPoint() throws IOException {
return hasCurrentPoint ? (Point2D) currentPoint.clone() : null;
}
@Override
public void closePath() throws IOException {
linePath.closePath();
}
@Override
public void endPath() throws IOException {
linePath.reset();
hasCurrentPoint = false;
}
@Override
protected void showText(byte[] string) throws IOException {
sawText = true;
super.showText(string);
}
@Override
protected void showFontGlyph(
Matrix textRenderingMatrix, PDFont font, int code, Vector displacement)
throws IOException {
sawText = true;
super.showFontGlyph(textRenderingMatrix, font, code, displacement);
}
@Override
protected void processOperator(
Operator operator, java.util.List<org.apache.pdfbox.cos.COSBase> operands)
throws IOException {
if ("cm".equals(operator.getName())) {
warnings =
warnings == null ? "Encountered CTM concatenation" : warnings + "; CTM concat";
}
super.processOperator(operator, operands);
}
}
@@ -0,0 +1,113 @@
package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.json.PdfJsonFontConversionCandidate;
import stirling.software.SPDF.model.json.PdfJsonFontConversionStatus;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibrary;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryEntry;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryMatch;
import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayload;
@Slf4j
@Component
@Order(0)
@RequiredArgsConstructor
public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled;
@Override
public String getId() {
return "type3-library";
}
@Override
public String getLabel() {
return "Type3 Font Library";
}
@Override
public boolean isAvailable() {
return enabled && fontLibrary != null && fontLibrary.isLoaded();
}
@Override
public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
if (request == null || request.getFont() == null) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("No font supplied")
.build();
}
if (!isAvailable()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.SKIPPED)
.message("Library disabled")
.build();
}
Type3FontLibraryMatch match = fontLibrary.match(request.getFont(), request.getFontUid());
if (match == null || match.getEntry() == null) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.UNSUPPORTED)
.message("No library entry found")
.build();
}
Type3FontLibraryEntry entry = match.getEntry();
if (!entry.hasAnyPayload()) {
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.FAILURE)
.message("Library entry has no payloads")
.build();
}
String message =
String.format(
"Matched %s via %s",
entry.getLabel(),
match.getMatchType() != null ? match.getMatchType() : "alias");
return PdfJsonFontConversionCandidate.builder()
.strategyId(getId())
.strategyLabel(getLabel())
.status(PdfJsonFontConversionStatus.SUCCESS)
.program(toBase64(entry.getProgram()))
.programFormat(toFormat(entry.getProgram()))
.webProgram(toBase64(entry.getWebProgram()))
.webProgramFormat(toFormat(entry.getWebProgram()))
.pdfProgram(toBase64(entry.getPdfProgram()))
.pdfProgramFormat(toFormat(entry.getPdfProgram()))
.glyphCoverage(entry.getGlyphCoverage())
.message(message)
.build();
}
private String toBase64(Type3FontLibraryPayload payload) {
return payload != null ? payload.getBase64() : null;
}
private String toFormat(Type3FontLibraryPayload payload) {
return payload != null ? payload.getFormat() : null;
}
}
@@ -0,0 +1,299 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
@Slf4j
@Component
@RequiredArgsConstructor
public class Type3FontLibrary {
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
private String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
private final Map<String, Type3FontLibraryEntry> aliasIndex = new ConcurrentHashMap<>();
private List<Type3FontLibraryEntry> entries = List.of();
@jakarta.annotation.PostConstruct
void initialise() {
Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
entries = List.of();
return;
}
try (InputStream inputStream = resource.getInputStream()) {
List<RawEntry> rawEntries =
objectMapper.readValue(inputStream, new TypeReference<List<RawEntry>>() {});
List<Type3FontLibraryEntry> loaded = new ArrayList<>();
for (RawEntry rawEntry : rawEntries) {
Type3FontLibraryEntry entry = toEntry(rawEntry);
if (entry != null && entry.hasAnyPayload()) {
loaded.add(entry);
}
}
entries = Collections.unmodifiableList(loaded);
signatureIndex.clear();
aliasIndex.clear();
for (Type3FontLibraryEntry entry : entries) {
if (entry.getSignatures() != null) {
for (String signature : entry.getSignatures()) {
if (signature == null) {
continue;
}
String key = signature.toLowerCase(Locale.ROOT);
signatureIndex.putIfAbsent(key, entry);
}
}
if (entry.getAliases() != null) {
for (String alias : entry.getAliases()) {
String normalized = normalizeAlias(alias);
if (normalized != null) {
aliasIndex.putIfAbsent(normalized, entry);
}
}
}
}
log.info(
"[TYPE3] Loaded {} Type3 library entries (signatures={}, aliases={}) from {}",
entries.size(),
signatureIndex.size(),
aliasIndex.size(),
indexLocation);
} catch (IOException ex) {
log.warn(
"[TYPE3] Failed to load Type3 library index {}: {}",
indexLocation,
ex.getMessage(),
ex);
entries = List.of();
signatureIndex.clear();
aliasIndex.clear();
}
}
public boolean isLoaded() {
return !entries.isEmpty();
}
public Type3FontLibraryMatch match(PDType3Font font, String fontUid) throws IOException {
if (font == null || entries.isEmpty()) {
return null;
}
String signature = Type3FontSignatureCalculator.computeSignature(font);
if (signature != null) {
Type3FontLibraryEntry entry = signatureIndex.get(signature.toLowerCase(Locale.ROOT));
if (entry != null) {
log.debug(
"[TYPE3] Matched Type3 font {} to library entry {} via signature {}",
fontUid,
entry.getId(),
signature);
return Type3FontLibraryMatch.builder()
.entry(entry)
.matchType("signature")
.signature(signature)
.build();
}
log.debug(
"[TYPE3] No library entry for signature {} (font {})",
signature,
fontUid != null ? fontUid : font.getName());
}
String aliasKey = normalizeAlias(resolveBaseFontName(font));
if (aliasKey != null) {
Type3FontLibraryEntry entry = aliasIndex.get(aliasKey);
if (entry != null) {
log.debug(
"[TYPE3] Matched Type3 font {} to library entry {} via alias {}",
fontUid,
entry.getId(),
aliasKey);
return Type3FontLibraryMatch.builder()
.entry(entry)
.matchType("alias:" + aliasKey)
.signature(signature)
.build();
}
}
if (signature != null) {
log.debug(
"[TYPE3] Library had no alias match for signature {} (font {})",
signature,
fontUid != null ? fontUid : font.getName());
}
return null;
}
private Type3FontLibraryEntry toEntry(RawEntry rawEntry) {
if (rawEntry == null || rawEntry.id == null) {
return null;
}
try {
Type3FontLibraryEntry.Type3FontLibraryEntryBuilder builder =
Type3FontLibraryEntry.builder()
.id(rawEntry.id)
.label(rawEntry.label != null ? rawEntry.label : rawEntry.id)
.signatures(normalizeList(rawEntry.signatures))
.aliases(normalizeList(rawEntry.aliases))
.program(loadPayload(rawEntry.program))
.webProgram(loadPayload(rawEntry.webProgram))
.pdfProgram(loadPayload(rawEntry.pdfProgram))
.source(rawEntry.source);
if (rawEntry.glyphCoverage != null && !rawEntry.glyphCoverage.isEmpty()) {
for (Integer codePoint : rawEntry.glyphCoverage) {
if (codePoint != null) {
builder.glyphCode(codePoint);
}
}
}
return builder.build();
} catch (IOException ex) {
log.warn(
"[TYPE3] Failed to load Type3 library entry {}: {}",
rawEntry.id,
ex.getMessage());
return null;
}
}
private Type3FontLibraryPayload loadPayload(RawPayload payload) throws IOException {
if (payload == null) {
return null;
}
byte[] data = null;
if (payload.base64 != null && !payload.base64.isBlank()) {
try {
data = Base64.getDecoder().decode(payload.base64);
} catch (IllegalArgumentException ex) {
log.warn("[TYPE3] Invalid base64 payload in Type3 library: {}", ex.getMessage());
}
} else if (payload.resource != null && !payload.resource.isBlank()) {
data = loadResourceBytes(payload.resource);
}
if (data == null || data.length == 0) {
return null;
}
String base64 = Base64.getEncoder().encodeToString(data);
return new Type3FontLibraryPayload(base64, normalizeFormat(payload.format));
}
private byte[] loadResourceBytes(String location) throws IOException {
String resolved = resolveLocation(location);
Resource resource = resourceLoader.getResource(resolved);
if (!resource.exists()) {
throw new IOException("Resource not found: " + resolved);
}
try (InputStream inputStream = resource.getInputStream()) {
return inputStream.readAllBytes();
}
}
private String resolveLocation(String location) {
if (location == null || location.isBlank()) {
return location;
}
if (location.contains(":")) {
return location;
}
if (location.startsWith("/")) {
return "classpath:" + location;
}
return "classpath:/" + location;
}
private List<String> normalizeList(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
return values.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
private String normalizeAlias(String alias) {
if (alias == null) {
return null;
}
String value = alias.trim();
int plus = value.indexOf('+');
if (plus >= 0 && plus < value.length() - 1) {
value = value.substring(plus + 1);
}
return value.isEmpty() ? null : value.toLowerCase(Locale.ROOT);
}
private String normalizeFormat(String format) {
if (format == null) {
return null;
}
return format.trim().toLowerCase(Locale.ROOT);
}
private String resolveBaseFontName(PDType3Font font) {
if (font == null) {
return null;
}
String baseName = null;
try {
baseName = font.getName();
} catch (Exception ignored) {
// Some Type3 fonts throw when resolving names; fall back to COS dictionary.
}
if (baseName == null && font.getCOSObject() != null) {
baseName = font.getCOSObject().getNameAsString(COSName.BASE_FONT);
}
return baseName;
}
private static final class RawEntry {
public String id;
public String label;
public List<String> signatures;
public List<String> aliases;
public RawPayload program;
public RawPayload webProgram;
public RawPayload pdfProgram;
public List<Integer> glyphCoverage;
public String source;
}
private static final class RawPayload {
public String resource;
public String format;
public String base64;
}
}
@@ -0,0 +1,30 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import java.util.List;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
@Value
@Builder
public class Type3FontLibraryEntry {
String id;
String label;
@Singular List<String> signatures;
@Singular List<String> aliases;
Type3FontLibraryPayload program;
Type3FontLibraryPayload webProgram;
Type3FontLibraryPayload pdfProgram;
@Singular("glyphCode")
List<Integer> glyphCoverage;
String source;
public boolean hasAnyPayload() {
return (program != null && program.hasPayload())
|| (webProgram != null && webProgram.hasPayload())
|| (pdfProgram != null && pdfProgram.hasPayload());
}
}
@@ -0,0 +1,12 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class Type3FontLibraryMatch {
Type3FontLibraryEntry entry;
String matchType;
String signature;
}
@@ -0,0 +1,13 @@
package stirling.software.SPDF.service.pdfjson.type3.library;
import lombok.Value;
@Value
public class Type3FontLibraryPayload {
String base64;
String format;
public boolean hasPayload() {
return base64 != null && !base64.isBlank();
}
}
@@ -0,0 +1,25 @@
package stirling.software.SPDF.service.pdfjson.type3.model;
import java.awt.geom.GeneralPath;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
public class Type3GlyphOutline {
String glyphName;
int charCode;
float advanceWidth;
PDRectangle boundingBox;
GeneralPath outline;
boolean hasStroke;
boolean hasFill;
boolean hasImages;
boolean hasText;
boolean hasShading;
String warnings;
Integer unicode;
}
@@ -0,0 +1,299 @@
package stirling.software.SPDF.service.pdfjson.type3.tool;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator;
import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor;
import stirling.software.SPDF.service.pdfjson.type3.model.Type3GlyphOutline;
/**
* Small CLI helper that scans a PDF for Type3 fonts, computes their signatures, and optionally
* emits JSON describing the glyph coverage. This allows Type3 library entries to be added without
* digging through backend logs.
*
* <p>Usage:
*
* <pre>
* ./gradlew :proprietary:type3SignatureTool --args="--pdf path/to/sample.pdf --output type3.json --pretty"
* </pre>
*/
public final class Type3SignatureTool {
private static final ObjectMapper OBJECT_MAPPER =
new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private Type3SignatureTool() {}
public static void main(String[] args) throws Exception {
Arguments arguments = Arguments.parse(args);
if (arguments.showHelp || arguments.pdf == null) {
printUsage();
return;
}
Path pdfPath = arguments.pdf.toAbsolutePath();
if (!Files.exists(pdfPath)) {
throw new IOException("PDF not found: " + pdfPath);
}
List<Map<String, Object>> fonts;
try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) {
fonts = collectType3Fonts(document);
}
Map<String, Object> output = new LinkedHashMap<>();
output.put("pdf", pdfPath.toString());
output.put("fonts", fonts);
ObjectWriter writer =
arguments.pretty
? OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
: OBJECT_MAPPER.writer();
if (arguments.output != null) {
Path parent = arguments.output.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
writer.writeValue(arguments.output.toFile(), output);
verifyOutput(arguments.output, fonts.size());
} else {
writer.writeValue(System.out, output);
}
}
private static List<Map<String, Object>> collectType3Fonts(PDDocument document)
throws IOException {
if (document == null || document.getNumberOfPages() == 0) {
return List.of();
}
List<Map<String, Object>> fonts = new ArrayList<>();
Type3GlyphExtractor glyphExtractor = new Type3GlyphExtractor();
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
PDPage page = document.getPage(pageIndex);
PDResources resources = page.getResources();
if (resources == null) {
continue;
}
scanResources(document, pageIndex + 1, resources, glyphExtractor, visited, fonts);
}
return fonts;
}
private static void scanResources(
PDDocument document,
int pageNumber,
PDResources resources,
Type3GlyphExtractor glyphExtractor,
Set<Object> visited,
List<Map<String, Object>> fonts)
throws IOException {
if (resources == null) {
return;
}
for (COSName name : resources.getFontNames()) {
PDFont font = resources.getFont(name);
if (!(font instanceof PDType3Font type3Font)) {
continue;
}
Object cosObject = type3Font.getCOSObject();
if (cosObject != null && !visited.add(cosObject)) {
continue;
}
fonts.add(
describeFont(document, pageNumber, name.getName(), type3Font, glyphExtractor));
}
Deque<PDResources> embedded = new ArrayDeque<>();
for (COSName name : resources.getXObjectNames()) {
PDXObject xobject = resources.getXObject(name);
if (xobject instanceof PDFormXObject form && form.getResources() != null) {
embedded.add(form.getResources());
}
}
while (!embedded.isEmpty()) {
scanResources(document, pageNumber, embedded.pop(), glyphExtractor, visited, fonts);
}
}
private static Map<String, Object> describeFont(
PDDocument document,
int pageNumber,
String fontId,
PDType3Font font,
Type3GlyphExtractor glyphExtractor)
throws IOException {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("pageNumber", pageNumber);
payload.put("fontId", fontId);
payload.put("baseName", safeFontName(font));
payload.put("alias", normalizeAlias(safeFontName(font)));
payload.put("encoding", resolveEncoding(font));
payload.put("signature", Type3FontSignatureCalculator.computeSignature(font));
List<Type3GlyphOutline> glyphs =
glyphExtractor.extractGlyphs(document, font, fontId, pageNumber);
payload.put("glyphCount", glyphs != null ? glyphs.size() : 0);
Set<Integer> coverage = new TreeSet<>();
if (glyphs != null) {
for (Type3GlyphOutline glyph : glyphs) {
if (glyph == null) {
continue;
}
if (glyph.getUnicode() != null) {
coverage.add(glyph.getUnicode());
} else if (glyph.getCharCode() >= 0) {
coverage.add(0xF000 | (glyph.getCharCode() & 0xFF));
}
}
List<Map<String, Object>> warnings = new ArrayList<>();
for (Type3GlyphOutline glyph : glyphs) {
if (glyph != null && glyph.getWarnings() != null) {
Map<String, Object> warning = new LinkedHashMap<>();
warning.put("glyphName", glyph.getGlyphName());
warning.put("message", glyph.getWarnings());
warnings.add(warning);
}
}
if (!warnings.isEmpty()) {
payload.put("warnings", warnings);
}
}
if (!coverage.isEmpty()) {
payload.put("glyphCoverage", new ArrayList<>(coverage));
}
return payload;
}
private static void verifyOutput(Path output, int fontCount) throws IOException {
Path absolute = output.toAbsolutePath();
if (!Files.exists(absolute)) {
throw new IOException("Expected output file not found: " + absolute);
}
long size = Files.size(absolute);
if (size == 0) {
throw new IOException("Output file is empty: " + absolute);
}
System.out.println(
"Wrote " + fontCount + " fonts to " + absolute + " (" + size + " bytes, verified)");
}
private static String resolveEncoding(PDType3Font font) {
if (font == null || font.getEncoding() == null) {
return null;
}
Object encoding = font.getCOSObject().getDictionaryObject(COSName.ENCODING);
return encoding != null
? encoding.toString()
: font.getEncoding().getClass().getSimpleName();
}
private static String safeFontName(PDType3Font font) {
if (font == null) {
return null;
}
try {
if (font.getName() != null) {
return font.getName();
}
} catch (Exception ignored) {
// ignore
}
if (font.getCOSObject() != null) {
return font.getCOSObject().getNameAsString(COSName.BASE_FONT);
}
return null;
}
private static String normalizeAlias(String name) {
if (name == null) {
return null;
}
int plus = name.indexOf('+');
String normalized = plus >= 0 ? name.substring(plus + 1) : name;
normalized = normalized.trim();
return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT);
}
private static void printUsage() {
System.out.println(
"""
Type3SignatureTool - dump Type3 font signatures for library building
Usage:
--pdf <file.pdf> Input PDF to analyse (required)
--output <file.json> Optional output file (defaults to stdout)
--pretty Pretty-print JSON output
--help Show this help
Example:
./gradlew :proprietary:type3SignatureTool --args="--pdf samples/foo.pdf --output foo.json --pretty"
""");
}
private static final class Arguments {
private final Path pdf;
private final Path output;
private final boolean pretty;
private final boolean showHelp;
private Arguments(Path pdf, Path output, boolean pretty, boolean showHelp) {
this.pdf = pdf;
this.output = output;
this.pretty = pretty;
this.showHelp = showHelp;
}
static Arguments parse(String[] args) {
if (args == null || args.length == 0) {
return new Arguments(null, null, true, true);
}
Path pdf = null;
Path output = null;
boolean pretty = false;
boolean showHelp = false;
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if ("--pdf".equals(arg) && i + 1 < args.length) {
pdf = Paths.get(args[++i]);
} else if ("--output".equals(arg) && i + 1 < args.length) {
output = Paths.get(args[++i]);
} else if ("--pretty".equals(arg)) {
pretty = true;
} else if ("--help".equals(arg) || "-h".equals(arg)) {
showHelp = true;
}
}
return new Arguments(pdf, output, pretty, showHelp);
}
}
}
@@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -24,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.RegexPatternUtils;
@@ -41,6 +43,9 @@ public class JobController {
private final JobQueue jobQueue;
private final HttpServletRequest request;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
/**
* Get the status of a job
*
@@ -50,6 +55,13 @@ public class JobController {
@GetMapping("/job/{jobId}")
@Operation(summary = "Get job status")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job status: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -79,6 +91,13 @@ public class JobController {
@GetMapping("/job/{jobId}/result")
@Operation(summary = "Get job result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job result: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -144,13 +163,8 @@ public class JobController {
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
log.debug("Request to cancel job: {}", jobId);
// Verify that this job belongs to the current user
// We can use the current request's session to validate ownership
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
if (sessionJobIds == null
|| !(sessionJobIds instanceof java.util.Set)
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
// Either no jobs in session or jobId doesn't match user's jobs
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to cancel job: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to cancel this job"));
@@ -210,6 +224,13 @@ public class JobController {
@GetMapping("/job/{jobId}/result/files")
@Operation(summary = "Get job result files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job files: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -330,4 +351,26 @@ public class JobController {
return "attachment; filename=\"" + fileName + "\"";
}
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @return true if user has access, false otherwise
*/
private boolean validateJobAccess(String jobId) {
// If JobOwnershipService is available (security enabled), use it
if (jobOwnershipService != null) {
try {
return jobOwnershipService.validateJobAccess(jobId);
} catch (SecurityException e) {
log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage());
return false;
}
}
// Security disabled - allow all access (backwards compatibility)
// When security is not enabled, any user can access any job by jobId
return true;
}
}
@@ -1,5 +1,6 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
@@ -7,6 +8,9 @@ logging.level.org.eclipse.jetty=WARN
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
logging.level.stirling.software.common.service.JobExecutorService=INFO
logging.level.stirling.software.common.service.TaskManager=INFO
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
server.error.path=/error
@@ -48,7 +52,7 @@ server.servlet.session.timeout:30m
springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
springdoc.swagger-ui.path=/index.html
springdoc.swagger-ui.path=/swagger-ui.html
# Force OpenAPI 3.0 specification version
springdoc.api-docs.version=OPENAPI_3_0
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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