Compare commits

..
Author SHA1 Message Date
ConnorYohandGitHub 78df38b600 Merge branch 'main' into ExtraSamlFixes 2025-12-17 10:50:35 +00:00
Anthony StirlingandGitHub 4ec75d4d8c allow static overrides (#5258)
# 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)

### 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-12-17 10:07:51 +00:00
Anthony Stirling 63550e0c7d Merge remote-tracking branch 'origin/main' into ExtraSamlFixes 2025-12-16 16:26:29 +00:00
Anthony Stirling a2091b7851 Merge remote-tracking branch 'origin/main' into ExtraSamlFixes 2025-12-16 16:25:47 +00:00
Reece BrowneandGitHub 93ed05b054 Only allow dual page view when there is more than one page, update to… (#5246) 2025-12-16 16:02:22 +00:00
195b1472e4 Bug/v2/viewer annotations (#5245)
Show uneditable annotations on viewer
show editable annotations layer when in annotation tools (sign, add
image, add text)
Remove draw tool from viewer (this is replaced wholesale in an upcoming
PR so it wasn't worth doing the work to ensure it worked with the new
annotation layer set up_)
refactoring work, mostly renaming variables we can use for all
annotation based tools that had sign specific names.
remove "tools" tooltip

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 15:52:47 +00:00
James BruntonandGitHub 340006ceea Add Sign Up functionality to desktop app (#5244)
# Description of Changes
Adds Sign Up with email to desktop app. SSO sign up will come in a
future PR.
2025-12-16 14:55:53 +00:00
Anthony StirlingandGitHub d80e627899 Cache fix issues V2 (#5237)
# 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)

### 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-12-15 23:54:25 +00:00
Anthony Stirling 43137cde83 tests 2025-12-15 23:34:28 +00:00
Anthony Stirling 9ea2c94912 tests 2025-12-15 23:27:59 +00:00
Anthony Stirling ad33679c2b fix grandfather logs 2025-12-15 23:06:13 +00:00
Anthony Stirling c390f21114 minor stuff 2025-12-15 22:29:19 +00:00
Anthony Stirling b03ecef121 version bump 2025-12-15 22:15:32 +00:00
Anthony Stirling 3d9d2621fa fixes 2025-12-15 22:10:38 +00:00
Anthony Stirling 9c84e1eed4 Merge remote-tracking branch 'origin/main' into pdfCache 2025-12-15 21:58:35 +00:00
Anthony Stirling e424af1a31 several SAML 2025-12-15 21:57:59 +00:00
Anthony StirlingandGitHub 336ec34125 V2 Handle SSO account restrictions in account settings (#5225)
## Summary
- hide password and username update controls for SSO accounts and show a
managed-account notice
- prevent account update handlers from calling APIs when the user
authenticates via SSO
- expose authenticationType on the user session model and add
translations for new SSO messaging

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693ae8144148832888ecf128e66cd3ca)
2025-12-15 21:55:58 +00:00
Anthony StirlingandGitHub 71d416ce90 Merge branch 'main' into pdfCache 2025-12-15 15:35:40 +00:00
5f72c05623 line art (#5052)
## Summary
- introduce a shared line art conversion interface and proprietary
ImageMagick-backed implementation
- have the compress controller optionally autowire the enterprise
service before running per-image line art processing
- remove ImageMagick command details from core by delegating conversions
through the proprietary service

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e)

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-15 11:14:10 +00:00
Anthony StirlingandGitHub 33188815da Remove UserApi mapping from proprietary signature controller (#5239)
## Summary
- remove the UserApi composite annotation from the proprietary signature
controller to avoid duplicate request mappings

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693af8cc8210832890f4787bae07d11f)
2025-12-15 10:43:36 +00:00
0064c1866e Shorten onbaording (#5198)
Also added `enableDesktopInstallSlide` flag in `settings.yml` to hide
the download for desktop page in the onboarding.

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-12-15 09:31:41 +00:00
Anthony StirlingandGitHub 371d816ce7 Validate certificate inputs for cert signing (#5191)
## Summary
- validate required certificate inputs before loading keystores to
prevent null dereferences
- surface clear errors for missing PEM, PKCS12/PFX, and JKS uploads
during PDF signing
- add a unit test covering the missing PKCS12/PFX keystore scenario

## Testing
- ./gradlew :stirling-pdf:test --tests
stirling.software.SPDF.controller.api.security.CertSignControllerTest

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934c8803d648328bf76b72a4f689c60)
2025-12-13 17:42:12 +00:00
Anthony StirlingandGitHub 5f54308d2b Simplify V2 PR auto-deploy triggers (#5236)
## Summary
- deploy V2 environments for authorized PR authors without requiring
title or branch keywords
- update deployment messaging to reflect the new trigger policy

## Testing
- not run


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693c9ac446d88328b8ae04609caec787)
2025-12-13 16:51:16 +00:00
Anthony Stirling 91cdd20b3f remove stuff 2025-12-13 16:48:35 +00:00
Anthony Stirling 235e68fa2b lint :) 2025-12-13 16:28:27 +00:00
Anthony Stirling 3279b28a76 fixes and cleanups 2025-12-13 13:56:24 +00:00
Reece BrowneandGitHub 6f7b8ce433 Bug/v2/static wasm (#5238)
# 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)

### 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-12-13 12:57:10 +00:00
James BruntonandGitHub 69ffd29bb5 Fix German text for sign tool text entry (#5232)
# Description of Changes
Fix #5206

Required splitting out the logic for the text with font entry so that
the labels are configurable from the call-site instead of all using the
same ones for Sign.
2025-12-12 18:00:40 +00:00
Reece BrowneandGitHub f4cc87144d Fix language codes in picker (#5233)
# 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)

### 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-12-12 17:44:15 +00:00
Anthony StirlingandGitHub c86e2d6840 Delete .github/README.md 2025-12-11 16:38:32 +00:00
Anthony StirlingandGitHub 5f072f87bb Account change details (#5190)
## Summary
Accounts setting page to change a users password or username
Fix huge bug were users can see admin settings due to hard code
admin=true
## Testing
- not run (not requested)

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b8ecdbf08328a0951b46db77dfd2)
2025-12-11 13:56:35 +00:00
Anthony StirlingandGitHub eb3e57577c Bump version from 2.1.2 to 2.1.3 (#5224)
# 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)

### 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-12-11 13:16:37 +00:00
Reece BrowneandGitHub f29d85565a Chore/v2/ctrlf (#5217)
# 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)

### 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-12-11 12:42:16 +00:00
Reece BrowneandGitHub ae72344317 Offline pdfium (#5213)
# 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)

### 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-12-11 11:23:20 +00:00
Reece BrowneandGitHub 6565a6ce18 Bug/v2/improved cache busting (#5107)
# 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-12-11 11:19:55 +00:00
LudyandGitHub e26035c3b3 build(versioning): synchronize app version across Tauri and simulation configs (#5120)
# Description of Changes

- **What was changed**
- Added `groovy.json.JsonOutput` and `groovy.json.JsonSlurper` imports
to `build.gradle`.
- Introduced a reusable `writeIfChanged(File targetFile, String
newContent)` helper to avoid unnecessary file writes when content is
unchanged.
  - Added `updateTauriConfigVersion(String version)` to:
    - Parse `frontend/src-tauri/tauri.conf.json`.
    - Set the `version` field from `project.version`.
- Re-write the file as pretty-printed JSON (with a trailing line
separator) only if content actually changed.
- Added `updateSimulationVersion(File fileToUpdate, String version)` to:
- Locate the `appVersion: '<value>'` assignment via regex in simulation
files.
    - Replace the existing version with `project.version`.
- Fail the build with a clear `GradleException` if `appVersion` cannot
be found.
- Registered a new Gradle task `syncAppVersion` (group: `versioning`)
which:
    - Reads `project.version` as the canonical app version.
    - Updates `frontend/src-tauri/tauri.conf.json`.
- Updates `frontend/src/core/testing/serverExperienceSimulations.ts`.
- Updates
`frontend/src/proprietary/testing/serverExperienceSimulations.ts`.
- Updated the main `build` task so it now depends on `syncAppVersion` in
addition to `:stirling-pdf:bootJar` and `buildRestartHelper`.

- **Why the change was made**
- To ensure the desktop Tauri configuration and server experience
simulation configs consistently use the same application version as
defined in `project.version`.
- To remove manual version bumps in multiple files and eliminate the
risk of version mismatches between backend, desktop app, and
simulation/testing tooling.
- To minimize noise in commits and CI by only touching versioned files
when their content actually changes (using `writeIfChanged`).


---

## 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-12-11 11:13:54 +00:00
ConnorYohandGitHub e474cc76ad Improved static upgrade flow (#5214)
<img width="996" height="621" alt="image"
src="https://github.com/user-attachments/assets/1ac87414-09ed-4307-8f7c-25984e0c89d1"
/>
<img width="608" height="351" alt="image"
src="https://github.com/user-attachments/assets/c271f75e-4844-4034-8905-007cc7ab1265"
/>
<img width="660" height="355" alt="image"
src="https://github.com/user-attachments/assets/34913b74-d4fa-418a-b098-fda48b41f0dd"
/>
<img width="1371" height="906" alt="image"
src="https://github.com/user-attachments/assets/35b61389-fd67-41b3-9969-e5409e53b362"
/>
<img width="639" height="450" alt="image"
src="https://github.com/user-attachments/assets/ae018bf3-0fcf-4221-892f-440d7325540a"
/>
<img width="963" height="599" alt="image"
src="https://github.com/user-attachments/assets/f6f67682-f43c-46f3-8632-16b209780b15"
/>
<img width="982" height="628" alt="image"
src="https://github.com/user-attachments/assets/45a7c171-3eb4-4271-a299-f3a6e78c1a52"
/>
2025-12-11 11:13:20 +00:00
Reece BrowneandGitHub 43eaa84a8f fix tooltips on tab (#5219)
# 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)

### 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-12-11 09:20:39 +00:00
James BruntonandGitHub 2cd4175689 Fix Mac app not being able to open files with spaces in their name (#5218)
# Description of Changes
Fix #5189.

Fix Mac app not being able to open files with spaces in their name,
which was happening because the URL was not being decoded on input.
2025-12-11 08:55:37 +00:00
Anthony Stirling d95abcea95 missed commit 2025-12-10 23:26:46 +00:00
Anthony Stirling a73636a597 Cache fix issues 2025-12-10 23:26:35 +00:00
Dario Ghunney WareandGitHub d6a83fe6a1 Fix: SSO Login Page (#5220)
Users logging in via OAuth2 were redirected to Spring's default login
form instead of the React frontend login page. This happened because the
OAuth2 configuration used `.loginPage("/oauth2")` which pointed to the
old Thymeleaf template.

### 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.
2025-12-10 19:46:48 +00:00
James BruntonandGitHub 3c92cb7c2b Improve styling of quick access bar (#5197)
# Description of Changes

Currently, the Quick Access Bar only renders well in Chrome. It's got
all sorts of layout issues in Firefox and Safari. This PR attempts to
retain the recent changes to make the bar thinner etc. but make it work
better in all browsers.
2025-12-10 17:21:07 +00:00
James BruntonandGitHub b83888c74a Make lite version of CI (#5188)
# Description of Changes
Add lite mode for CI which just runs the most important jobs for
deployment. This won't be used in this repo, but allows other repos
containing Stirling to easily disable jobs like desktop builds etc. if
they're unnecessary, without needing to deal with conflicts in the
files. They'll just need to set the repo variable `CI_PROFILE` to
`lite`. We have an upstream repo that we'd like these changes for.
2025-12-10 13:54:57 +00:00
Anthony StirlingandGitHub 787d0d21c9 lang updates plus --include-existing flag (#5212)
# 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)

### 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-12-10 11:41:11 +00:00
Dario Ghunney WareandGitHub 7b26b184d1 Fix: Access to Swagger UI when login enabled (#5194)
Fixes for /swagger-ui/index.html & /v1/api-docs endpoints not being
accessible when login was enabled.

- `UserAuthenticationFilter.isPublicAuthEndpoint()` had gaps in its
check, missing `/v1/api-docs`
- Refactored `UserAuthenticationFilter` to use
`RequestUriUtils.isPublicAuthEndpoint()` instead of its own incorrect
method

Closes #5125 & #5028

---

### 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.
2025-12-10 11:39:29 +00:00
Anthony StirlingandGitHub f17ad56def Handle restricted language configuration fallback V2 (#5154)
## Summary
- restrict supported languages to the validated list from app-config
instead of always adding an extra fallback
- set the effective fallback locale to the preferred configured language
and switch away from disallowed selections automatically

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27)
2025-12-10 11:12:18 +00:00
Reece BrowneandGitHub de438d00e1 Fiix colours (#5211)
<img width="638" height="471" alt="image"
src="https://github.com/user-attachments/assets/c2352e25-6ee0-4726-9ce4-059870347ea6"
/>
<img width="638" height="450" alt="image"
src="https://github.com/user-attachments/assets/a17931ca-ffea-4dc3-a17a-629d98003da6"
/>
<img width="985" height="774" alt="image"
src="https://github.com/user-attachments/assets/b16203c6-a136-4fee-ba57-495f7b6e8c75"
/>
<img width="635" height="348" alt="image"
src="https://github.com/user-attachments/assets/4b4ab328-b2e4-442f-84a5-d947e97653ec"
/>
2025-12-10 11:10:34 +00:00
Anthony StirlingandGitHub 6787169583 Handle composition input in PDF text editor (#5192)
## Summary
- track IME composition state in the PDF text editor to avoid
interrupting phonetic input methods
- update text syncing to occur after composition completes and skip
redundant updates mid-composition

## Testing
- npm run lint -- --max-warnings 0


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693744be74148328bd3bda9150de6e56)
2025-12-10 10:11:45 +00:00
Anthony StirlingandGitHub 291e1a392b extra font support in text editor (#5208)
# 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)

### 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-12-10 10:11:16 +00:00
Anthony StirlingandGitHub 7c5aa3685f Add configurable SMTP TLS/SSL options (#5204)
## Summary
- add optional STARTTLS and SSL-related fields to mail settings with
defaults matching prior behavior
- apply the new mail properties in the SMTP JavaMail configuration,
including trust and hostname verification overrides
- update mail settings template and tests to cover default behavior and
explicit TLS/SSL overrides
- clarify STARTTLS naming and sslTrust usage with examples in property
comments and the settings template
- default sslTrust to a wildcard when unset so TLS connections accept
any host by default unless tightened

## Testing
- ./gradlew :proprietary:test --tests
stirling.software.proprietary.security.service.MailConfigTest --console
plain

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693864b2a6648328ae75c7e88a726a65)
2025-12-10 10:10:54 +00:00
Anthony StirlingGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
9c03914edd Add admin password reset option for users (#5180)
## Summary
- add backend support for admins to reset user passwords and optionally
email notifications when SMTP is enabled
- surface mail capability in admin settings data for the UI
- add a shared change-password modal hooked into People and Team user
actions with random password generation and email options

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b978fe3c83289b5b95dec79b3d38)

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-10 10:10:40 +00:00
James BruntonandGitHub c980ee10c0 Backport fixes from SaaS (#5187)
# Description of Changes
- Add new skeleton loader style type (block - nothing's currently using
it but it might as well be available)
- Make Dev API overridable (and set to the new docs that actually work
while Swagger docs don't work properly)
2025-12-09 11:45:01 +00:00
James BruntonandGitHub fa4d2bc09a Fix path to sample file in tour (#5186)
# Description of Changes
Fix path to sample file in tour
2025-12-09 11:43:18 +00:00
Anthony StirlingandGitHub 7faf7e50fa Chang etext on intro (#5160)
# 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)

### 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-12-06 00:06:11 +00:00
EthanHealy01andGitHub bb201ef9c1 Chore/bump gradle version number (#5176)
bump version number
2025-12-05 23:22:32 +00:00
82dbcfbb9b SSO login fix (#5167)
Fixes bug where SSO login with custom providers caused an
`InvalidClientRegistrationIdException: Invalid Client Registration with
Id: oidc` errors.

  Root Cause:
- Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc`
regardless of provider registration ID
- Frontend: Unknown providers were mapped back to 'oidc' instead of
using actual provider ID

Closes #5141

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com>
2025-12-05 23:19:41 +00:00
EthanHealy01andGitHub 9fd8fd89ed add enum SERVER to list of valid licenses (#5172) 2025-12-05 13:18:23 +00:00
Keon ChenandGitHub 3a2370ea1f Update OCR setup guide link in LanguagePicker (#5162)
# Description of Changes

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

- What was changed
Update the link to the doc page for language picker in OCR config
- Why the change was made
Old link doesn't work anymore
- 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)
- [x ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ x] 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
- [ x] My changes generate no new warnings

### Documentation

- [x ] 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)
- [x ] 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)

### 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-12-04 21:35:11 +00:00
e7db714091 More fixes for automate (#5168)
# Description of Changes
Fix file missed in #5127 to use `apiClient` instead of `axios` directly

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-12-04 17:53:08 +00:00
c6b4a2b141 Desktop to match normal login screens (#5122)1
Also fixed issue with csrf
Also fixed issue with rust keychain

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-04 17:48:19 +00:00
Anthony StirlingandGitHub 7459463a3c V2 sso in server plan (#5158)
# 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)

### 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-12-03 21:12:29 +00:00
c9bf436895 couple of small fixes for text editor (#5155)
# Description of Changes

- Workbench.tsx: Allow PDF text editor to handle it's own custom state
(this is a bit of a hotfix, we're going to handle the PDF text editors
file upload flow better in a future PR).
- PdfTextEditorView.tsx: Added a dropzone and some helper text to upload
the first file.
- PdfTextEditorView.tsx: Hide document view when isConverting is true.
Prevents showing stale content from previous file during conversion.
- useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of
Recommended tools list. Increased visibility of the new feature.
- PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on
file selection. Stops the "jumpy" behavior when selecting files.
- HomePage.tsx: Check specifically for pdfTextEditor tool instead of any
custom workbench. Prevents auto-switch to viewer when uploading files
while in PDF Text Editor.

<img width="2056" height="1073" alt="Screenshot 2025-12-03 at 6 01
14 PM"
src="https://github.com/user-attachments/assets/dfc63a46-7991-486c-ba00-0ce7637502f5"
/>


---

## 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)

### 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-12-03 20:37:23 +00:00
EthanHealy01andGitHub f8dbf171e1 Feature/v2/get all info on pdf (#5105)
# Description of Changes

- Addition of the get all info on PDF tool

---

## 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-12-03 20:02:42 +00:00
ConnorYohandGitHub e59c717dc0 Fixes state management loops around getting results V2 (#5153)
Makes sure settings step collapses in results step

Makes sure result step doesn't always reset even in development for
baseTool

Make sure result step doesn't reset for convert
2025-12-03 17:42:04 +00:00
ConnorYohandGitHub f2bffe2dc6 Fix-convert-V2 (#5147)
Custom processors can now return consume all inputs flag. This allows to
have many inputs to single output consumption

Fixed multi call conversion logic
2025-12-03 17:39:49 +00:00
Anthony StirlingandGitHub 5d827df08c Add onboarding bypass flag V2 version2 version 2 (#5151)
## Summary
- add a shared hook that honors a `bypassOnboarding` query parameter and
marks onboarding steps as completed for the session
- block onboarding orchestrator and UI elements when the bypass flag is
present so tours and popups stay hidden

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693059f866a8832891dd97f3d52ca5a0)
2025-12-03 17:17:22 +00:00
Anthony StirlingandGitHub bdb3c887f3 opensource text editor (#5146)
# 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)

### 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-12-03 12:55:34 +00:00
Dario Ghunney WareGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>LudyEthanHealy01EthanAnthony Stirlingstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
f902e8aca9 OAuth Provider Buttons (#5103)
PR to allow other OAuth providers to be displayed on the login screen.
Will show a generic icon when the provider is not in the known set of
providers.
<img width="424" height="205" alt="47ab288dadbc889fd84cc83c9ded0829"
src="https://github.com/user-attachments/assets/2877eb3d-2ade-406f-a2bf-dc404793e30f"
/>

---------

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-03 10:54:53 +00:00
Anthony StirlingandGitHub 65a3eeca76 Toml (#5115)
# 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-12-03 09:57:00 +00:00
Anthony StirlingandGitHub f72538d30f Add files via upload 2025-12-03 09:11:05 +00:00
Anthony StirlingandGitHub 88c5fb46ad Bump version from 2.0.2 to 2.0.3 2025-12-02 23:39:27 +00:00
Anthony StirlingandGitHub 8e2f9546a5 fixes for automate (#5127)
# 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-12-02 23:38:19 +00:00
f2f4bd5230 Bug/v2/signature fixes (#5104)
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-02 22:48:29 +00:00
Anthony StirlingandGitHub f3cc30d0c2 Update wording for third-party services reference 2025-12-02 19:14:03 +00:00
Reece BrowneandGitHub ba7c75aff4 Update embed and allow form rendering (#5124)
You can now see the values of forms filled elsewhre (still no filling
forms ourselves)
2025-12-02 18:56:22 +00:00
Anthony StirlingandGitHub a53d73ef51 Revise README for improved structure and clarity (#5121)
# 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-12-02 17:25:59 +00:00
c2a63cf425 java frontend (#5097)
# 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 <reece@stirlingpdf.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-12-02 17:15:29 +00:00
319 changed files with 19056 additions and 5379 deletions
+1
View File
@@ -5,6 +5,7 @@ frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
@@ -1,403 +0,0 @@
"""
Author: Ludy87
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
import copy
import glob
import os
import argparse
import re
def find_duplicate_keys(file_path):
"""
Identifies duplicate keys in a .properties file.
:param file_path: Path to the .properties file.
:return: List of tuples (key, first_occurrence_line, duplicate_line).
"""
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Skip empty lines and comments
if not stripped_line or stripped_line.startswith("#"):
continue
# Split the line into key and value
if "=" in stripped_line:
key, _ = stripped_line.split("=", 1)
key = key.strip()
# Check if the key already exists
if key in keys:
duplicates.append((key, keys[key], line_number))
else:
keys[key] = line_number
return duplicates
# Maximum size for properties files (e.g., 200 KB)
MAX_FILE_SIZE = 200 * 1024
def parse_properties_file(file_path):
"""
Parses a .properties file and returns a structured list of its contents.
:param file_path: Path to the .properties file.
:return: List of dictionaries representing each line in the file.
"""
properties_list = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Handle empty lines
if not stripped_line:
properties_list.append(
{"line_number": line_number, "type": "empty", "content": ""}
)
continue
# Handle comments
if stripped_line.startswith("#"):
properties_list.append(
{
"line_number": line_number,
"type": "comment",
"content": stripped_line,
}
)
continue
# Handle key-value pairs
match = re.match(r"^([^=]+)=(.*)$", line)
if match:
key, value = match.groups()
properties_list.append(
{
"line_number": line_number,
"type": "entry",
"key": key.strip(),
"value": value.strip(),
}
)
return properties_list
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the file in their original format.
:param file_path: Path to the .properties file.
:param updated_properties: List of updated properties to write.
"""
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
# Sort lines by their numbers and retain comments and empty lines
all_lines = sorted(set(updated_lines.keys()))
original_format = []
for line in all_lines:
if line in updated_lines:
entry = updated_lines[line]
else:
entry = None
ref_entry = updated_lines[line]
if ref_entry["type"] in ["comment", "empty"]:
original_format.append(ref_entry)
elif entry is None:
# Add missing entries from the reference file
original_format.append(ref_entry)
elif entry["type"] == "entry":
# Replace entries with those from the current JSON
original_format.append(entry)
# Write the updated content back to the file
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
for entry in original_format:
if entry["type"] == "comment":
file.write(f"{entry['content']}\n")
elif entry["type"] == "empty":
file.write(f"{entry['content']}\n")
elif entry["type"] == "entry":
file.write(f"{entry['key']}={entry['value']}\n")
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference .properties file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_properties_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
current_properties = parse_properties_file(os.path.join(branch, file_path))
updated_properties = []
for ref_entry in reference_properties:
ref_entry_copy = copy.deepcopy(ref_entry)
for current_entry in current_properties:
if current_entry["type"] == "entry":
if ref_entry_copy["type"] != "entry":
continue
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
ref_entry_copy["value"] = current_entry["value"]
updated_properties.append(ref_entry_copy)
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_properties(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
return [""]
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = reference_file.split("/")[0]
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_lines = read_properties(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
if (
basename_current_file == basename_reference_file
or (
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_",
)
)
)
or not file_normpath.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
current_lines = read_properties(os.path.join(branch, file_path))
reference_line_count = len(reference_lines)
current_line_count = len(current_lines)
if reference_line_count != current_line_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_line_count > current_line_count:
report.append(
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
)
elif reference_line_count < current_line_count:
report.append(
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys = []
reference_keys = []
for line in current_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
current_keys.append(key)
for line in reference_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
reference_keys.append(key)
current_keys_set = set(current_keys)
reference_keys_set = set(reference_keys)
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
spaces_keys_list = []
for key in missing_keys_list:
if " " in key:
spaces_keys_list.append(key)
if spaces_keys_list:
spaces_keys_str = "`, `".join(spaces_keys_list)
report.append(
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
)
report.append(
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_*.properties",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -1,6 +1,6 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
@@ -9,10 +9,10 @@ The script also provides functionality to update the translation files to match
adjusting the format.
Usage:
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import copy
import glob
@@ -20,12 +20,14 @@ import os
import argparse
import re
import json
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
Identifies duplicate keys in a TOML file (including nested keys).
:param file_path: Path to the TOML file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
@@ -35,8 +37,9 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
# Load TOML file
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
@@ -54,18 +57,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
# Maximum size for TOML files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
def parse_toml_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
Parses a TOML translation file and returns a flat dictionary of all keys.
:param file_path: Path to the TOML file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
@@ -99,38 +102,37 @@ def unflatten_dict(d, sep="."):
return result
def write_json_file(file_path, updated_properties):
def write_toml_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
Writes updated properties back to the TOML file.
:param file_path: Path to the TOML file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
with open(file_path, "wb") as file:
tomli_w.dump(nested_data, file)
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON file.
:param reference_file: Path to the reference TOML file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_json_file(reference_file)
reference_properties = parse_toml_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".json")
or not file_path.endswith(".toml")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
current_properties = parse_toml_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
@@ -141,16 +143,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_json_file(os.path.join(branch, file_path), updated_properties)
write_toml_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_json_keys(file_path):
def read_toml_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return parse_toml_file(file_path)
return {}
@@ -160,7 +162,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(reference_file)
reference_keys = read_toml_keys(reference_file)
has_differences = False
only_reference_file = True
@@ -197,12 +199,12 @@ def check_for_differences(reference_file, file_list, branch, actor):
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
current_keys = read_toml_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
@@ -272,7 +274,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -286,7 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
@@ -337,9 +339,9 @@ if __name__ == "__main__":
"public",
"locales",
"*",
"translation.json",
"translation.toml",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
+3 -14
View File
@@ -52,7 +52,6 @@ jobs:
core.setOutput('repository', pr.head.repo.full_name);
core.setOutput('ref', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.fork));
core.setOutput('base_ref', pr.base.ref);
core.setOutput('author', pr.user.login);
core.setOutput('state', pr.state);
@@ -65,10 +64,6 @@ jobs:
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
# nur bei workflow_dispatch gesetzt:
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
# für Auto-PR-Logik:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
run: |
set -e
@@ -89,14 +84,8 @@ jobs:
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
if [ "$is_auth" = true ]; then
should=true
else
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
should=true
fi
fi
fi
@@ -174,7 +163,7 @@ jobs:
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
@@ -394,7 +383,7 @@ jobs:
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
`🔄 **Auto-deployed** for approved V2 contributors.`;
await github.rest.issues.createComment({
owner,
@@ -14,6 +14,7 @@ jobs:
permissions:
issues: write
if: |
vars.CI_PROFILE != 'lite' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
@@ -180,7 +181,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
+16 -3
View File
@@ -262,7 +262,13 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
include:
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -272,6 +278,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
@@ -301,7 +314,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/backend/${{ matrix.docker-rev }}
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -313,7 +326,7 @@ jobs:
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
build/reports/tests/
build/test-results/
@@ -1,19 +1,14 @@
name: Check Properties Files on PR
name: Check TOML Translation Files on PR
# This workflow validates TOML translation files
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "app/core/src/main/resources/messages_*.properties"
- "frontend/public/locales/*/translation.toml"
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
@@ -73,22 +68,22 @@ jobs:
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No properties files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching properties files"
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for TOML translation files
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching TOML files"
- name: Determine reference file test
- name: Determine reference file
id: determine-file
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
@@ -125,11 +120,11 @@ jobs:
pull_number: prNumber,
});
// Filter for relevant files based on the PR changes
// Filter for relevant TOML files based on the PR changes
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
)
.map(file => file.filename);
@@ -169,16 +164,16 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "frontend/public/locales/en-GB/translation.toml",
ref: branch,
});
referenceFilePath = "pr-branch-messages_en_GB.properties";
referenceFilePath = "pr-branch-translation-en-GB.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
@@ -186,11 +181,11 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "frontend/public/locales/en-GB/translation.toml",
ref: "main",
});
referenceFilePath = "main-branch-messages_en_GB.properties";
referenceFilePath = "main-branch-translation-en-GB.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
@@ -198,11 +193,20 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
pip install tomli-w
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check files..."
python .github/scripts/check_language_properties.py \
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
@@ -213,7 +217,7 @@ jobs:
id: capture-output
run: |
if [ -f result.txt ] && [ -s result.txt ]; then
echo "Test, capturing output..."
echo "Capturing output..."
SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
@@ -227,7 +231,7 @@ jobs:
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
else
echo "No update found."
echo "No output found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
@@ -249,7 +253,7 @@ jobs:
issue_number: issueNumber
});
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
// Only update or create comments by the action user
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
@@ -260,7 +264,7 @@ jobs:
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
@@ -269,7 +273,7 @@ jobs:
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Created new comment.");
} else {
@@ -287,6 +291,6 @@ jobs:
run: |
echo "Cleaning up temporary files..."
rm -rf pr-branch
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+1
View File
@@ -31,6 +31,7 @@ permissions:
jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
+11 -9
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- V2-master
- alljavadocker
# 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
@@ -23,6 +24,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-24.04-8core
permissions:
packages: write
@@ -93,10 +95,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V2-demo branch - test)
- name: Generate tags for latest (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -110,7 +112,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -149,10 +151,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V2-demo branch - test)
- name: Generate tags for latest-fat (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -166,7 +168,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -203,10 +205,10 @@ jobs:
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)
- name: Generate tags for ultra-lite (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -220,7 +222,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+4 -3
View File
@@ -24,6 +24,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
packages: write
@@ -107,7 +108,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -152,7 +153,7 @@ jobs:
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./Dockerfile.ultra-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -183,7 +184,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -17,6 +17,7 @@ permissions: read-all
jobs:
analysis:
if: ${{ vars.CI_PROFILE != 'lite' }}
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
+1
View File
@@ -27,6 +27,7 @@ permissions:
jobs:
sonarqube:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
+1
View File
@@ -10,6 +10,7 @@ permissions:
jobs:
stale:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
issues: write
+1
View File
@@ -23,6 +23,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
-122
View File
@@ -1,122 +0,0 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# 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:
sync-files:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
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: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
# Wheels-only + Hash-Pinning
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- 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
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
+22 -16
View File
@@ -1,15 +1,15 @@
name: Sync Files V2
name: Sync Files (TOML)
on:
workflow_dispatch:
push:
branches:
- V2
- main
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
@@ -52,21 +52,25 @@ jobs:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
- name: Install Python dependencies
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
pip install tomli-w
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Install dependencies
- name: Install README dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.py
python scripts/counter_translation_v3.py
- name: Run git add
run: |
@@ -82,21 +86,22 @@ jobs:
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
branch: sync_readme_v3
base: main
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### 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:
This Pull Request was automatically generated to synchronize updates to translation files and documentation. 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`.
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
@@ -115,4 +120,5 @@ jobs:
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.json
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
+3
View File
@@ -28,6 +28,7 @@ permissions:
jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -636,6 +637,8 @@ jobs:
if [ "${{ needs.build.result }}" = "success" ]; then
echo "✅ All Tauri builds completed successfully!"
echo "Artifacts are ready for distribution."
elif [ "${{ needs.build.result }}" = "skipped" ]; then
echo "⏭️ Tauri builds skipped (CI lite mode enabled)"
else
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
+2 -1
View File
@@ -21,6 +21,7 @@ permissions:
jobs:
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -66,7 +67,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
+3 -3
View File
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**File to update:** `frontend/public/locales/en-GB/translation.toml`
**Required Translation Keys**:
```json
```toml
{
"home": {
"[toolName]": {
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
+48 -152
View File
@@ -1,173 +1,69 @@
<p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
<h1 align="center">Stirling-PDF</h1>
<p align="center">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
</p>
[![Docker Pulls](https://img.shields.io/docker/pulls/frooodle/s-pdf)](https://hub.docker.com/r/frooodle/s-pdf)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/HYmhKj45pU)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge)](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
[![GitHub Repo stars](https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social)](https://github.com/Stirling-Tools/stirling-pdf)
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling&#0045;pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling&#0032;PDF - Open&#0032;source&#0032;locally&#0032;hosted&#0032;web&#0032;PDF&#0032;editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
<p align="center">
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
</a>
<a href="https://discord.gg/HYmhKj45pU">
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
</a>
<a href="https://github.com/Stirling-Tools/stirling-pdf">
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
</a>
</p>
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
![Stirling PDF - Dashboard](images/home-light.png)
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
## Key Capabilities
All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
- **Enterprisegrade** - SSO, auditing, and flexible onprem deployments.
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
- **Global UI** - Interface available in 40+ languages.
![stirling-home](images/stirling-home.jpg)
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
## Features
## Quick Start
- 50+ PDF Operations
- Parallel file processing and downloads
- Dark mode support
- Custom download options
- Custom 'Pipelines' to run multiple features in a automated queue
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
```bash
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
```
## PDF Features
Then open: http://localhost:8080
### Page Operations
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
- Merge multiple PDFs into a single resultant file
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
- Reorganize PDF pages into different orders
- Rotate PDFs in 90-degree increments
- Remove pages
- Multi-page layout (format PDFs into a multi-paged page)
- Scale page contents size by set percentage
- Adjust contrast
- Crop PDF
- Auto-split PDF (with physically scanned page dividers)
- Extract page(s)
- Convert PDF to a single page
- Overlay PDFs on top of each other
- PDF to a single page
- Split PDF by sections
## Resources
### Conversion Operations
- [**Documentation**](https://docs.stirlingpdf.com)
- [**Homepage**](https://stirling.com)
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
- Convert PDFs to and from images
- Convert any common file to PDF (using LibreOffice)
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
- Convert HTML to PDF
- Convert PDF to XML
- Convert PDF to CSV
- URL to PDF
- Markdown to PDF
## Support
### Security & Permissions
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- Add and remove passwords
- Change/set PDF permissions
- Add watermark(s)
- Certify/sign PDFs
- Sanitize PDFs
- Auto-redact text
## Contributing
### Other Operations
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
- Add/generate/write signatures
- Split by Size or PDF
- Repair PDFs
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto-rename files by detecting PDF header text
- OCR on PDF (using Tesseract OCR)
- PDF/A conversion (using LibreOffice)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
- Show/detect embedded JavaScript
For development setup, see the [Developer Guide](DeveloperGuide.md).
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
## License
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
- Installation guides for all platforms
- Configuration options
- Feature documentation
- API reference
- Security setup
- Enterprise features
## Supported Languages
Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| 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) | ![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) | ![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
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
## 🤝 Looking to contribute?
Join our community:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU)
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
@@ -491,6 +491,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
/* ImageMagick */
addEndpointToGroup("ImageMagick", "compress-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -574,6 +577,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group)
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group);
}
@@ -37,10 +37,6 @@ public class AppConfig {
private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -49,6 +45,17 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}")
public boolean v2Enabled;
@@ -68,6 +68,7 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -100,6 +101,46 @@ public class ApplicationProperties {
private String outputFolder;
}
@Data
public static class PdfEditor {
private Cache cache = new Cache();
private FontNormalization fontNormalization = new FontNormalization();
private CffConverter cffConverter = new CffConverter();
private Type3 type3 = new Type3();
private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
@Data
public static class Cache {
private long maxBytes = -1;
private int maxPercent = 20;
}
@Data
public static class FontNormalization {
private boolean enabled = false;
}
@Data
public static class CffConverter {
private boolean enabled = true;
private String method = "python";
private String pythonCommand = "/opt/venv/bin/python3";
private String pythonScript = "/scripts/convert_cff_to_ttf.py";
private String fontforgeCommand = "fontforge";
}
@Data
public static class Type3 {
private Library library = new Library();
@Data
public static class Library {
private boolean enabled = true;
private String index = "classpath:/type3/library/index.json";
}
}
}
@Data
public static class Legal {
private String termsAndConditions;
@@ -112,7 +153,6 @@ public class ApplicationProperties {
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
@@ -358,6 +398,7 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
@@ -368,10 +409,12 @@ public class ApplicationProperties {
private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup();
private List<String> corsAllowedOrigins = new ArrayList<>();
private String
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
// 'http://localhost:8080', 'https://api.example.com'). Required for
// SSO.
private String frontendUrl; // Frontend URL for invite email links (e.g.
// falls back to backend URL.
// 'https://app.example.com'). If not set, falls back to backendUrl.
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -536,6 +579,7 @@ public class ApplicationProperties {
@ToString.Exclude private String key;
private String UUID;
private String appVersion;
private Boolean isNewServer;
}
// TODO: Remove post migration
@@ -575,6 +619,16 @@ public class ApplicationProperties {
private String username;
@ToString.Exclude private String password;
private String from;
// STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207)
private Boolean startTlsEnable = true;
private Boolean startTlsRequired;
// SSL/TLS wrapper for implicit TLS (typically port 465)
private Boolean sslEnable;
// Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates;
// defaults to "*" (trust all) when not set
private String sslTrust;
// Enables hostname verification for TLS connections
private Boolean sslCheckServerIdentity;
}
@Data
@@ -643,6 +697,7 @@ public class ApplicationProperties {
private int weasyPrintSessionLimit;
private int installAppSessionLimit;
private int calibreSessionLimit;
private int imageMagickSessionLimit;
private int qpdfSessionLimit;
private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
@@ -680,6 +735,10 @@ public class ApplicationProperties {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
}
public int getImageMagickSessionLimit() {
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
}
public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
}
@@ -709,6 +768,8 @@ public class ApplicationProperties {
@JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes;
private long imageMagickTimeoutMinutes;
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
@@ -746,6 +807,10 @@ public class ApplicationProperties {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
}
public long getImageMagickTimeoutMinutes() {
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
}
public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
}
@@ -0,0 +1,12 @@
package stirling.software.common.service;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
public interface LineArtConversionService {
PDImageXObject convertImageToLineArt(
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
throws IOException;
}
@@ -254,10 +254,7 @@ public class PostHogService {
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
"security_loginAttemptCount",
@@ -86,6 +86,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getCalibreSessionLimit();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getImageMagickSessionLimit();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -141,6 +146,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getCalibreTimeoutMinutes();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getImageMagickTimeoutMinutes();
case GHOSTSCRIPT ->
applicationProperties
.getProcessExecutor()
@@ -301,6 +311,7 @@ public class ProcessExecutor {
WEASYPRINT,
INSTALL_APP,
CALIBRE,
IMAGEMAGICK,
TESSERACT,
QPDF,
GHOSTSCRIPT,
@@ -26,6 +26,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
@@ -39,6 +40,7 @@ public class RequestUriUtils {
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/manifest.json")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
@@ -60,7 +62,8 @@ public class RequestUriUtils {
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
|| normalizedUri.endsWith(".toml")
|| normalizedUri.endsWith(".wasm");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
@@ -124,11 +127,13 @@ public class RequestUriUtils {
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.endsWith(".wasm")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
|| requestURI.startsWith("/pdfjs")
|| requestURI.startsWith("/pdfium"));
}
/**
@@ -161,10 +166,9 @@ public class RequestUriUtils {
// 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");
|| trimmedUri.startsWith("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
@@ -24,6 +24,9 @@ public class RequestUriUtilsTest {
assertTrue(
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
"PDF.js files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
"PDFium wasm should be static");
assertTrue(
RequestUriUtils.isStaticResource("/api/v1/info/status"),
"API status should be static");
@@ -110,7 +113,8 @@ public class RequestUriUtilsTest {
"/downloads/document.png",
"/assets/brand.ico",
"/any/path/with/image.svg",
"/deep/nested/folder/icon.png"
"/deep/nested/folder/icon.png",
"/pdfium/pdfium.wasm"
})
void testIsStaticResourceWithFileExtensions(String path) {
assertTrue(
@@ -148,6 +152,9 @@ public class RequestUriUtilsTest {
assertFalse(
RequestUriUtils.isTrackableResource("/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
"PDFium wasm should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/swagger/index.html"),
"Swagger files should not be trackable");
@@ -224,7 +231,8 @@ public class RequestUriUtilsTest {
"/api/v1/info/health",
"/site.webmanifest",
"/fonts/roboto.woff",
"/pdfjs/viewer.js"
"/pdfjs/viewer.js",
"/pdfium/pdfium.wasm"
})
void testNonTrackableResources(String path) {
assertFalse(
@@ -138,13 +138,13 @@ public class SPDFApplication {
@PostConstruct
public void init() {
String baseUrl = appConfig.getBaseUrl();
String backendUrl = appConfig.getBackendUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = baseUrl;
baseUrlStatic = backendUrl;
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
String url = backendUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -46,6 +46,7 @@ public class ExternalAppDepConfig {
put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract"));
put("rar", List.of("rar")); // Required for real CBR output
put("magick", List.of("ImageMagick"));
}
};
}
@@ -128,6 +129,7 @@ public class ExternalAppDepConfig {
checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar");
checkDependencyAndDisableGroup("magick");
// Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) {
@@ -34,7 +34,6 @@ public class InitialSetup {
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
@@ -60,18 +59,6 @@ public class InitialSetup {
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
@@ -95,7 +82,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
@@ -107,6 +94,7 @@ public class InitialSetup {
}
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
}
public static boolean isNewServer() {
@@ -62,10 +62,15 @@ public class OpenApiConfig {
// Add server configuration from environment variable
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
Server server;
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
Server server = new Server().url(swaggerServerUrl).description("API Server");
openAPI.addServersItem(server);
server = new Server().url(swaggerServerUrl).description("API Server");
} else {
// Use relative path so Swagger uses the current browser origin to avoid CORS issues
// when accessing via different ports
server = new Server().url("/").description("Current Server");
}
openAPI.addServersItem(server);
// Add ErrorResponse schema to components
Schema<?> errorResponseSchema =
@@ -1,10 +1,14 @@
package stirling.software.SPDF.config;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
@@ -25,6 +29,41 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
registry.addResourceHandler("/assets/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
registry.addResourceHandler("/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode
@@ -124,7 +124,6 @@ public class SettingsController {
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@@ -159,12 +158,6 @@ public class SettingsController {
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties
.getSecurity()
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
@@ -0,0 +1,60 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.exception.CacheUnavailableException;
@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
@Slf4j
@RequiredArgsConstructor
public class ConvertPdfJsonExceptionHandler {
private final ObjectMapper objectMapper;
@ExceptionHandler(CacheUnavailableException.class)
@ResponseBody
public ResponseEntity<byte[]> handleCacheUnavailable(CacheUnavailableException ex) {
try {
byte[] body =
objectMapper.writeValueAsBytes(
java.util.Map.of(
"error", "cache_unavailable",
"action", "reupload",
"message", ex.getMessage()));
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(body);
} catch (Exception e) {
log.warn("Failed to serialize cache_unavailable response", e);
var fallbackBody =
java.util.Map.of(
"error", "cache_unavailable",
"action", "reupload",
"message", String.valueOf(ex.getMessage()));
try {
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(objectMapper.writeValueAsBytes(fallbackBody));
} catch (Exception ignored) {
// Truly last-ditch fallback
return ResponseEntity.status(HttpStatus.GONE)
.contentType(MediaType.APPLICATION_JSON)
.body(
"{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}"
.getBytes(StandardCharsets.UTF_8));
}
}
}
}
@@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
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.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -58,6 +62,9 @@ public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
@Autowired(required = false)
private LineArtConversionService lineArtConversionService;
private boolean isQpdfEnabled() {
return endpointConfiguration.isGroupEnabled("qpdf");
}
@@ -66,6 +73,10 @@ public class CompressController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
private boolean isImageMagickEnabled() {
return endpointConfiguration.isGroupEnabled("ImageMagick");
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@@ -660,6 +671,9 @@ public class CompressController {
Integer optimizeLevel = request.getOptimizeLevel();
String expectedOutputSizeString = request.getExpectedOutputSize();
Boolean convertToGrayscale = request.getGrayscale();
Boolean convertToLineArt = request.getLineArt();
Double lineArtThreshold = request.getLineArtThreshold();
Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
if (expectedOutputSizeString == null && optimizeLevel == null) {
throw new Exception("Both expected output size and optimize level are not specified");
}
@@ -689,6 +703,26 @@ public class CompressController {
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
}
if (Boolean.TRUE.equals(convertToLineArt)) {
if (lineArtConversionService == null) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Line art conversion is unavailable - ImageMagick service not found");
}
if (!isImageMagickEnabled()) {
throw new IOException(
"ImageMagick is not enabled but line art conversion was requested");
}
double thresholdValue =
lineArtThreshold == null
? 55d
: Math.min(100d, Math.max(0d, lineArtThreshold));
int edgeLevel =
lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel));
currentFile =
applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel);
}
boolean sizeMet = false;
boolean imageCompressionApplied = false;
boolean externalCompressionApplied = false;
@@ -810,6 +844,75 @@ public class CompressController {
}
}
private Path applyLineArtConversion(
Path currentFile, List<Path> tempFiles, double threshold, int edgeLevel)
throws IOException {
Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf");
tempFiles.add(lineArtFile);
try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) {
Map<String, List<ImageReference>> uniqueImages = findImages(doc);
CompressionStats stats = new CompressionStats();
stats.uniqueImagesCount = uniqueImages.size();
calculateImageStats(uniqueImages, stats);
Map<String, PDImageXObject> convertedImages =
createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel);
replaceImages(doc, uniqueImages, convertedImages, stats);
log.info(
"Applied line art conversion to {} unique images ({} total references)",
stats.uniqueImagesCount,
stats.totalImages);
doc.save(lineArtFile.toString());
return lineArtFile;
}
}
private Map<String, PDImageXObject> createLineArtImages(
PDDocument doc,
Map<String, List<ImageReference>> uniqueImages,
CompressionStats stats,
double threshold,
int edgeLevel)
throws IOException {
Map<String, PDImageXObject> convertedImages = new HashMap<>();
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
String imageHash = entry.getKey();
List<ImageReference> references = entry.getValue();
if (references.isEmpty()) continue;
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
int originalSize = (int) originalImage.getCOSObject().getLength();
stats.totalOriginalBytes += originalSize;
PDImageXObject converted =
lineArtConversionService.convertImageToLineArt(
doc, originalImage, threshold, edgeLevel);
convertedImages.put(imageHash, converted);
stats.compressedImages++;
int convertedSize = (int) converted.getCOSObject().getLength();
stats.totalCompressedBytes += convertedSize * references.size();
double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize);
log.info(
"Image hash {}: Line art conversion {} → {} (reduced by {}%)",
imageHash,
GeneralUtils.formatBytes(originalSize),
GeneralUtils.formatBytes(convertedSize),
String.format("%.1f", reductionPercentage));
}
return convertedImages;
}
// Run Ghostscript compression
private void applyGhostscriptCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
@@ -66,7 +66,8 @@ public class ConfigController {
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
configData.put("baseUrl", appConfig.getBaseUrl());
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
@@ -74,6 +75,7 @@ public class ConfigController {
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
@@ -123,6 +125,9 @@ public class ConfigController {
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
configData.put(
"enableDesktopInstallSlide",
applicationProperties.getSystem().getEnableDesktopInstallSlide());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
@@ -226,4 +231,10 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/group-enabled")
public ResponseEntity<Boolean> isGroupEnabled(@RequestParam(name = "group") String group) {
boolean enabled = endpointConfiguration.isGroupEnabled(group);
return ResponseEntity.ok(enabled);
}
}
@@ -191,6 +191,12 @@ public class CertSignController {
switch (certType) {
case "PEM":
privateKeyFile =
validateFilePresent(
privateKeyFile, "PEM private key", "private key file is required");
certFile =
validateFilePresent(
certFile, "PEM certificate", "certificate file is required");
ks = KeyStore.getInstance("JKS");
ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
@@ -200,10 +206,16 @@ public class CertSignController {
break;
case "PKCS12":
case "PFX":
p12File =
validateFilePresent(
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray());
break;
case "JKS":
jksfile =
validateFilePresent(
jksfile, "JKS keystore", "JKS keystore file is required");
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray());
break;
@@ -251,6 +263,17 @@ public class CertSignController {
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf"));
}
private MultipartFile validateFilePresent(
MultipartFile file, String argumentName, String errorDescription) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
argumentName + " - " + errorDescription);
}
return file;
}
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser =
@@ -1,20 +1,129 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
@Value("${server.servlet.context-path:/}")
private String contextPath;
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
@PostConstruct
public void init() {
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
}
// Fall back to classpath index.html
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
}
}
}
private String processIndexHtml() throws IOException {
Resource resource = getIndexHtmlResource();
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
}
private Resource getIndexHtmlResource() throws IOException {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
return new FileSystemResource(externalIndexPath.toFile());
}
// Fall back to classpath
return new ClassPathResource("static/index.html");
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
}
}
@@ -0,0 +1,8 @@
package stirling.software.SPDF.exception;
public class CacheUnavailableException extends RuntimeException {
public CacheUnavailableException(String message) {
super(message);
}
}
@@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean grayscale = false;
@Schema(
description =
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private Boolean lineArt = false;
@Schema(
description = "Threshold to use for line art conversion (0-100).",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "55")
private Double lineArtThreshold = 55d;
@Schema(
description =
"Edge detection strength to use for line art conversion (1-3). This maps to"
+ " ImageMagick's -edge radius.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "1",
allowableValues = {"1", "2", "3"})
private Integer lineArtEdgeLevel = 1;
}
@@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.DateConverter;
import org.apache.pdfbox.util.Matrix;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -144,15 +143,23 @@ public class PdfJsonConversionService {
private final PdfJsonFontService fontService;
private final Type3FontConversionService type3FontConversionService;
private final Type3GlyphExtractor type3GlyphExtractor;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>();
@Value("${stirling.pdf.json.font-normalization.enabled:true}")
private boolean fontNormalizationEnabled;
private long cacheMaxBytes;
private int cacheMaxPercent;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
private final java.util.LinkedHashMap<String, CachedPdfDocument> lruCache =
new java.util.LinkedHashMap<>(16, 0.75f, true);
private final Object cacheLock = new Object();
private volatile long currentCacheBytes = 0L;
private volatile long cacheBudgetBytes = -1L;
private volatile boolean ghostscriptAvailable;
private static final float FLOAT_EPSILON = 0.0001f;
@@ -161,7 +168,23 @@ public class PdfJsonConversionService {
@PostConstruct
private void initializeToolAvailability() {
loadConfigurationFromProperties();
initializeGhostscriptAvailability();
initializeCacheBudget();
}
private void loadConfigurationFromProperties() {
stirling.software.common.model.ApplicationProperties.PdfEditor cfg =
applicationProperties.getPdfEditor();
if (cfg != null) {
fontNormalizationEnabled = cfg.getFontNormalization().isEnabled();
cacheMaxBytes = cfg.getCache().getMaxBytes();
cacheMaxPercent = cfg.getCache().getMaxPercent();
} else {
fontNormalizationEnabled = false;
cacheMaxBytes = -1;
cacheMaxPercent = 20;
}
}
private void initializeGhostscriptAvailability() {
@@ -202,6 +225,25 @@ public class PdfJsonConversionService {
}
}
private void initializeCacheBudget() {
long effective = -1L;
if (cacheMaxBytes > 0) {
effective = cacheMaxBytes;
} else if (cacheMaxPercent > 0) {
long maxMem = Runtime.getRuntime().maxMemory();
effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100);
}
cacheBudgetBytes = effective;
if (cacheBudgetBytes > 0) {
log.info(
"PDF JSON cache budget configured: {} bytes (source: {})",
cacheBudgetBytes,
cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
} else {
log.info("PDF JSON cache budget: unlimited");
}
}
public byte[] convertPdfToJson(MultipartFile file) throws IOException {
return convertPdfToJson(file, null, false);
}
@@ -236,7 +278,10 @@ public class PdfJsonConversionService {
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else {
jobId = contextJobId;
log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId);
log.info(
"Starting PDF to JSON conversion, jobId from context: {} (lightweight={})",
jobId,
lightweight);
}
Consumer<PdfJsonConversionProgress> progress =
@@ -318,9 +363,9 @@ public class PdfJsonConversionService {
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
int totalPages = document.getNumberOfPages();
// Only use lazy images for real async jobs where client can access the cache
// Synchronous calls with synthetic jobId should do full extraction
boolean useLazyImages = totalPages > 5 && isRealJobId;
// Always enable lazy mode for real async jobs so cache is available regardless of
// page count. Synchronous calls with synthetic jobId still do full extraction.
boolean useLazyImages = isRealJobId;
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
log.debug(
@@ -403,6 +448,11 @@ public class PdfJsonConversionService {
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
log.info(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
isRealJobId);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -435,16 +485,23 @@ public class PdfJsonConversionService {
cachedPdfBytes = Files.readAllBytes(workingPath);
}
CachedPdfDocument cached =
new CachedPdfDocument(
cachedPdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached);
log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}",
cachedPdfBytes.length,
buildCachedDocument(
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached);
log.info(
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
cached.getPdfSize(),
totalPages,
fonts.size(),
jobId);
jobId,
cached.isDiskBacked());
scheduleDocumentCleanup(jobId);
} else {
log.warn(
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
useLazyImages,
isRealJobId,
jobId);
}
if (lightweight) {
@@ -2973,6 +3030,139 @@ public class PdfJsonConversionService {
}
}
// Cache helpers
private CachedPdfDocument buildCachedDocument(
String jobId,
byte[] pdfBytes,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources)
throws IOException {
if (pdfBytes == null) {
throw new IllegalArgumentException("pdfBytes must not be null");
}
long budget = cacheBudgetBytes;
// If single document is larger than budget, spill straight to disk
if (budget > 0 && pdfBytes.length > budget) {
TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache");
Files.write(tempFile.getPath(), pdfBytes);
log.debug(
"Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}",
pdfBytes.length,
budget,
jobId);
return new CachedPdfDocument(
null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources);
}
return new CachedPdfDocument(
pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources);
}
private void putCachedDocument(String jobId, CachedPdfDocument cached) {
synchronized (cacheLock) {
CachedPdfDocument existing = documentCache.put(jobId, cached);
if (existing != null) {
lruCache.remove(jobId);
currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize());
existing.close();
}
lruCache.put(jobId, cached);
currentCacheBytes += cached.getInMemorySize();
enforceCacheBudget();
}
}
private CachedPdfDocument getCachedDocument(String jobId) {
synchronized (cacheLock) {
CachedPdfDocument cached = documentCache.get(jobId);
if (cached != null) {
lruCache.remove(jobId);
lruCache.put(jobId, cached);
}
return cached;
}
}
private void enforceCacheBudget() {
if (cacheBudgetBytes <= 0) {
return;
}
// Must be called under cacheLock
java.util.Iterator<java.util.Map.Entry<String, CachedPdfDocument>> it =
lruCache.entrySet().iterator();
while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) {
java.util.Map.Entry<String, CachedPdfDocument> entry = it.next();
it.remove();
CachedPdfDocument removed = entry.getValue();
documentCache.remove(entry.getKey(), removed);
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
removed.close();
log.warn(
"Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)",
entry.getKey(),
cacheBudgetBytes,
currentCacheBytes);
}
if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) {
// Spill the most recently used large entry to disk
String key =
lruCache.entrySet().stream()
.reduce((first, second) -> second)
.map(java.util.Map.Entry::getKey)
.orElse(null);
if (key != null) {
CachedPdfDocument doc = lruCache.get(key);
if (doc != null && doc.getInMemorySize() > 0) {
try {
CachedPdfDocument diskDoc =
buildCachedDocument(
key,
doc.getPdfBytes(),
doc.getMetadata(),
doc.getFonts(),
doc.getPageFontResources());
lruCache.put(key, diskDoc);
documentCache.put(key, diskDoc);
currentCacheBytes =
Math.max(0L, currentCacheBytes - doc.getInMemorySize())
+ diskDoc.getInMemorySize();
doc.close();
log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key);
} catch (IOException ex) {
log.warn(
"Failed to spill cached PDF for jobId {} to disk: {}",
key,
ex.getMessage());
}
}
}
}
}
private void removeCachedDocument(String jobId) {
log.warn(
"removeCachedDocument called for jobId: {} [CALLER: {}]",
jobId,
Thread.currentThread().getStackTrace()[2].toString());
CachedPdfDocument removed = null;
synchronized (cacheLock) {
removed = documentCache.remove(jobId);
if (removed != null) {
lruCache.remove(jobId);
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
log.warn(
"Removed cached document for jobId: {} (size={} bytes)",
jobId,
removed.getInMemorySize());
} else {
log.warn("Attempted to remove jobId: {} but it was not in cache", jobId);
}
}
if (removed != null) {
removed.close();
}
}
private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException {
if (element.getCharacterSpacing() != null) {
@@ -5311,6 +5501,8 @@ public class PdfJsonConversionService {
*/
private static class CachedPdfDocument {
private final byte[] pdfBytes;
private final TempFile pdfTempFile;
private final long pdfSize;
private final PdfJsonDocumentMetadata metadata;
private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
public CachedPdfDocument(
byte[] pdfBytes,
TempFile pdfTempFile,
long pdfSize,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources) {
this.pdfBytes = pdfBytes;
this.pdfTempFile = pdfTempFile;
this.pdfSize = pdfSize;
this.metadata = metadata;
// Create defensive copies to prevent mutation of shared maps
this.fonts =
@@ -5336,8 +5532,14 @@ public class PdfJsonConversionService {
}
// Getters return defensive copies to prevent external mutation
public byte[] getPdfBytes() {
return pdfBytes;
public byte[] getPdfBytes() throws IOException {
if (pdfBytes != null) {
return pdfBytes;
}
if (pdfTempFile != null) {
return Files.readAllBytes(pdfTempFile.getPath());
}
throw new IOException("Cached PDF backing missing");
}
public PdfJsonDocumentMetadata getMetadata() {
@@ -5352,6 +5554,18 @@ public class PdfJsonConversionService {
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources);
}
public long getPdfSize() {
return pdfSize;
}
public long getInMemorySize() {
return pdfBytes != null ? pdfBytes.length : 0L;
}
public boolean isDiskBacked() {
return pdfBytes == null && pdfTempFile != null;
}
public long getTimestamp() {
return timestamp;
}
@@ -5363,7 +5577,19 @@ public class PdfJsonConversionService {
public CachedPdfDocument withUpdatedFonts(
byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) {
Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts;
return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources);
return new CachedPdfDocument(
nextBytes,
null,
nextBytes != null ? nextBytes.length : 0,
metadata,
fontsToUse,
pageFontResources);
}
public void close() {
if (pdfTempFile != null) {
pdfTempFile.close();
}
}
}
@@ -5444,14 +5670,15 @@ public class PdfJsonConversionService {
// Cache PDF bytes, metadata, and fonts for lazy page loading
if (jobId != null) {
CachedPdfDocument cached =
new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached);
buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached);
log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}",
pdfBytes.length,
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})",
cached.getPdfSize(),
totalPages,
fonts.size(),
jobId);
jobId,
cached.isDiskBacked());
// Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId);
@@ -5466,9 +5693,10 @@ public class PdfJsonConversionService {
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = documentCache.get(jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document found for jobId: " + jobId);
}
int pageIndex = pageNumber - 1;
@@ -5480,8 +5708,8 @@ public class PdfJsonConversionService {
}
log.debug(
"Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})",
cached.getPdfBytes().length,
"Loading PDF from {} to extract page {} (jobId: {})",
cached.isDiskBacked() ? "disk cache" : "memory cache",
pageNumber,
jobId);
@@ -5627,10 +5855,21 @@ public class PdfJsonConversionService {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
CachedPdfDocument cached = documentCache.get(jobId);
log.info("Looking up cache for jobId: {}", jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new IllegalArgumentException("No cached document available for jobId: " + jobId);
log.error(
"Cache not found for jobId: {}. Available cache keys: {}",
jobId,
documentCache.keySet());
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document available for jobId: " + jobId);
}
log.info(
"Found cached document for jobId: {} (size={}, diskBacked={})",
jobId,
cached.getPdfSize(),
cached.isDiskBacked());
if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}",
@@ -5709,7 +5948,14 @@ public class PdfJsonConversionService {
document.save(baos);
byte[] updatedBytes = baos.toByteArray();
documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts));
CachedPdfDocument updated =
buildCachedDocument(
jobId,
updatedBytes,
cached.getMetadata(),
mergedFonts,
cached.getPageFontResources());
putCachedDocument(jobId, updated);
// Clear Type3 cache entries for this incremental update
clearType3CacheEntriesForJob(updateJobId);
@@ -5724,11 +5970,13 @@ public class PdfJsonConversionService {
/** Clears a cached document. */
public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = documentCache.remove(jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
removeCachedDocument(jobId);
if (cached != null) {
log.debug(
"Removed cached PDF bytes ({} bytes) for jobId: {}",
cached.getPdfBytes().length,
"Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}",
cached.getPdfSize(),
cached.isDiskBacked(),
jobId);
}
@@ -33,8 +33,12 @@ public class PdfJsonFallbackFontService {
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_TC_ID = "fallback-noto-tc";
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
public static final String FALLBACK_FONT_DEVANAGARI_ID = "fallback-noto-devanagari";
public static final String FALLBACK_FONT_MALAYALAM_ID = "fallback-noto-malayalam";
public static final String FALLBACK_FONT_TIBETAN_ID = "fallback-noto-tibetan";
// Font name aliases map PDF font names to available fallback fonts
// This provides better visual consistency when editing PDFs
@@ -59,6 +63,22 @@ public class PdfJsonFallbackFontService {
Map.entry("dejavuserif", "fallback-dejavu-serif"),
Map.entry("dejavumono", "fallback-dejavu-mono"),
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
// Traditional Chinese fonts (Taiwan, Hong Kong, Macau)
Map.entry("mingliu", "fallback-noto-tc"),
Map.entry("pmingliu", "fallback-noto-tc"),
Map.entry("microsoftjhenghei", "fallback-noto-tc"),
Map.entry("jhenghei", "fallback-noto-tc"),
Map.entry("kaiti", "fallback-noto-tc"),
Map.entry("kaiu", "fallback-noto-tc"),
Map.entry("dfkaib5", "fallback-noto-tc"),
Map.entry("dfkai", "fallback-noto-tc"),
// Simplified Chinese fonts (Mainland China) - more common
Map.entry("simsun", "fallback-noto-cjk"),
Map.entry("simhei", "fallback-noto-cjk"),
Map.entry("microsoftyahei", "fallback-noto-cjk"),
Map.entry("yahei", "fallback-noto-cjk"),
Map.entry("songti", "fallback-noto-cjk"),
Map.entry("heiti", "fallback-noto-cjk"),
// Noto Sans - Google's universal font (use as last resort generic fallback)
Map.entry("noto", "fallback-noto-sans"),
Map.entry("notosans", "fallback-noto-sans"));
@@ -83,6 +103,12 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
"NotoSansKR-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TC_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansTC-Regular.ttf",
"NotoSansTC-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_AR_ID,
new FallbackFontSpec(
@@ -95,6 +121,24 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
"NotoSansThai-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_DEVANAGARI_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansDevanagari-Regular.ttf",
"NotoSansDevanagari-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_MALAYALAM_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansMalayalam-Regular.ttf",
"NotoSansMalayalam-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TIBETAN_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSerifTibetan-Regular.ttf",
"NotoSerifTibetan-Regular",
"ttf")),
// Liberation Sans family
Map.entry(
"fallback-liberation-sans",
@@ -268,12 +312,29 @@ public class PdfJsonFallbackFontService {
"ttf")));
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String legacyFallbackFontLocation;
private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
@jakarta.annotation.PostConstruct
private void loadConfig() {
String configured = null;
if (applicationProperties.getPdfEditor() != null) {
configured = applicationProperties.getPdfEditor().getFallbackFont();
}
if (configured != null && !configured.isBlank()) {
fallbackFontLocation = configured;
} else {
fallbackFontLocation = legacyFallbackFontLocation;
}
log.info("Using fallback font location: {}", fallbackFontLocation);
}
public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
@@ -484,6 +545,20 @@ public class PdfJsonFallbackFontService {
*/
public String resolveFallbackFontId(int codePoint) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
// Bopomofo is primarily used in Taiwan for Traditional Chinese phonetic annotation
if (block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED) {
return FALLBACK_FONT_TC_ID;
}
// Compatibility ideographs are primarily used by Traditional Chinese encodings (e.g., Big5,
// HKSCS) so prefer the Traditional Chinese fallback here.
if (block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT) {
return FALLBACK_FONT_TC_ID;
}
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
@@ -492,19 +567,23 @@ public class PdfJsonFallbackFontService {
|| 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) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
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;
case DEVANAGARI -> FALLBACK_FONT_DEVANAGARI_ID;
case MALAYALAM -> FALLBACK_FONT_MALAYALAM_ID;
case TIBETAN -> FALLBACK_FONT_TIBETAN_ID;
default -> FALLBACK_FONT_ID;
};
}
@@ -179,7 +179,7 @@ public class SharedSignatureService {
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
@@ -207,7 +207,7 @@ public class SharedSignatureService {
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
@@ -238,7 +238,7 @@ public class SharedSignatureService {
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
@@ -5,7 +5,6 @@ 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;
@@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager;
public class PdfJsonFontService {
private final TempFileManager tempFileManager;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Getter
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter private boolean cffConversionEnabled;
@Getter
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Getter 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;
@@ -48,6 +41,7 @@ public class PdfJsonFontService {
@PostConstruct
private void initialiseCffConverterAvailability() {
loadConfiguration();
if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false;
@@ -77,6 +71,22 @@ public class PdfJsonFontService {
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
}
private void loadConfiguration() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getCffConverter() != null) {
var cfg = applicationProperties.getPdfEditor().getCffConverter();
this.cffConversionEnabled = cfg.isEnabled();
this.cffConverterMethod = cfg.getMethod();
this.pythonCommand = cfg.getPythonCommand();
this.pythonScript = cfg.getPythonScript();
this.fontforgeCommand = cfg.getFontforgeCommand();
} else {
// Use defaults when config is not available
this.cffConversionEnabled = false;
log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled");
}
}
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn(
@@ -2,7 +2,6 @@ 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;
@@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled;
@Override
@@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
return enabled && fontLibrary != null && fontLibrary.isLoaded();
}
@jakarta.annotation.PostConstruct
private void loadConfiguration() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getType3() != null
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
var cfg = applicationProperties.getPdfEditor().getType3().getLibrary();
this.enabled = cfg.isEnabled();
} else {
this.enabled = false;
log.warn("PdfEditor Type3 library configuration not available, disabled");
}
}
@Override
public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
@@ -14,7 +14,6 @@ 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;
@@ -34,8 +33,8 @@ public class Type3FontLibrary {
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
private String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
@@ -44,6 +43,17 @@ public class Type3FontLibrary {
@jakarta.annotation.PostConstruct
void initialise() {
if (applicationProperties.getPdfEditor() != null
&& applicationProperties.getPdfEditor().getType3() != null
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
this.indexLocation =
applicationProperties.getPdfEditor().getType3().getLibrary().getIndex();
} else {
log.warn(
"[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled");
entries = List.of();
return;
}
Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
@@ -12,7 +12,6 @@
security:
enableLogin: true # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
@@ -59,6 +58,8 @@ security:
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
@@ -105,6 +106,11 @@ mail:
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@@ -122,13 +128,15 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -174,23 +182,6 @@ system:
databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
stirling:
pdf:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
json:
font-normalization:
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
cff-converter:
enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
python-command: /opt/venv/bin/python3 # Python interpreter path
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
type3:
library:
enabled: true # Match common Type3 fonts against the built-in library of converted programs
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
@@ -219,6 +210,7 @@ processExecutor:
weasyPrintSessionLimit: 16
installAppSessionLimit: 1
calibreSessionLimit: 1
imageMagickSessionLimit: 4
ghostscriptSessionLimit: 8
ocrMyPdfSessionLimit: 2
timeoutMinutes: # Process executor timeout in minutes
@@ -228,7 +220,26 @@ processExecutor:
weasyPrinttimeoutMinutes: 30
installApptimeoutMinutes: 60
calibretimeoutMinutes: 30
imageMagickTimeoutMinutes: 30
tesseractTimeoutMinutes: 30
qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 30
pdfEditor:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
cache:
max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap
max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0
font-normalization:
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
cff-converter:
enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
python-command: /opt/venv/bin/python3 # Python interpreter path
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
type3:
library:
enabled: true # Match common Type3 fonts against the built-in library of converted programs
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
@@ -1,9 +1,10 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
@@ -107,7 +108,8 @@ class CertSignControllerTest {
derCertBytes = baos.toByteArray();
}
when(pdfDocumentFactory.load(any(MultipartFile.class)))
lenient()
.when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
invocation -> {
MultipartFile file = invocation.getArgument(0);
@@ -167,6 +169,31 @@ class CertSignControllerTest {
assertTrue(response.getBody().length > 0);
}
@Test
void testSignPdfWithMissingPkcs12FileThrowsError() {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
request.setFileInput(pdfFile);
request.setCertType("PFX");
request.setPassword("password");
request.setShowSignature(false);
request.setReason("test");
request.setLocation("test");
request.setName("tester");
request.setPageNumber(1);
request.setShowLogo(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@Test
void testSignPdfWithJks() throws Exception {
MockMultipartFile pdfFile =
@@ -94,6 +94,22 @@ public class ProprietaryUIDataController {
this.auditRepository = auditRepository;
}
/**
* Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set,
* otherwise defaults to http://localhost:8080
*/
private String getBackendBaseUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
// If backendUrl is configured, use it
if (backendUrl != null && !backendUrl.trim().isEmpty()) {
return backendUrl.trim();
}
// For development, default to localhost:8080 (backend port)
return "http://localhost:8080";
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@@ -185,14 +201,17 @@ public class ProprietaryUIDataController {
}
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
// For SAML, we need to use the backend URL directly, not a relative path
// This ensures Spring Security generates the correct ACS URL
String backendUrl = getBackendBaseUrl();
String fullSamlPath = backendUrl + saml2AuthenticationPath;
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
}
}
@@ -205,6 +224,10 @@ public class ProprietaryUIDataController {
data.setLoginMethod(securityProps.getLoginMethod());
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
// Add language configuration for login page
data.setLanguages(applicationProperties.getUi().getLanguages());
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
return ResponseEntity.ok(data);
}
@@ -328,6 +351,7 @@ public class ProprietaryUIDataController {
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
return ResponseEntity.ok(data);
}
@@ -376,7 +400,7 @@ public class ProprietaryUIDataController {
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
@@ -491,6 +515,8 @@ public class ProprietaryUIDataController {
private boolean altLogin;
private boolean firstTimeSetup;
private boolean showDefaultCredentials;
private List<String> languages;
private String defaultLocale;
}
@Data
@@ -510,6 +536,7 @@ public class ProprietaryUIDataController {
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
}
@Data
@@ -1,7 +1,12 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -17,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import stirling.software.proprietary.security.service.UserService;
@@ -28,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService;
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@@ -38,6 +42,7 @@ public class SignatureController {
private final SignatureService signatureService;
private final UserService userService;
private static final String ALL_USERS_FOLDER = "ALL_USERS";
/**
* Save a new signature for the authenticated user. Enforces storage limits and authentication
@@ -84,19 +89,105 @@ public class SignatureController {
}
/**
* Delete a signature owned by the authenticated user. Users can only delete their own personal
* signatures, not shared ones.
* Update a signature label. Users can update labels for their own personal signatures and for
* shared signatures.
*/
@PostMapping("/{signatureId}/label")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> updateSignatureLabel(
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
try {
String username = userService.getCurrentUsername();
String newLabel = body.get("label");
if (newLabel == null || newLabel.trim().isEmpty()) {
log.warn("Invalid label update request");
return ResponseEntity.badRequest().build();
}
signatureService.updateSignatureLabel(username, signatureId, newLabel);
log.info("User {} updated label for signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
log.warn("Failed to update signature label: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature owned by the authenticated user. Users can delete their own personal
* signatures. Admins can also delete shared signatures.
*/
@DeleteMapping("/{signatureId}")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted signature {}", username, signatureId);
return ResponseEntity.noContent().build();
boolean isAdmin = userService.isCurrentUserAdmin();
// Validate filename to prevent path traversal
if (signatureId.contains("..")
|| signatureId.contains("/")
|| signatureId.contains("\\")) {
log.warn("Invalid signature ID: {}", signatureId);
return ResponseEntity.badRequest().build();
}
// Try to delete from personal folder first
try {
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted personal signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
// If not found in personal folder, check if it's in shared folder
if (isAdmin) {
// Admin can delete from shared folder
if (deleteFromSharedFolder(signatureId)) {
log.info("Admin {} deleted shared signature {}", username, signatureId);
return ResponseEntity.noContent().build();
}
}
// If not admin or not found in shared folder either, return 404
throw e;
}
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature from the shared (ALL_USERS) folder. Only admins should call this method.
*/
private boolean deleteFromSharedFolder(String signatureId) throws IOException {
String signatureBasePath = InstallationPathConfig.getSignaturesPath();
Path sharedFolder = Paths.get(signatureBasePath, ALL_USERS_FOLDER);
boolean deleted = false;
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;
log.info("Deleted shared signature file: {}", file);
}
}
// Also delete metadata file if it exists
Path metadataPath = sharedFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
Files.delete(metadataPath);
log.info("Deleted shared signature metadata: {}", metadataPath);
}
}
return deleted;
}
}
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@@ -120,9 +120,7 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
@@ -33,7 +33,8 @@ public class MailConfig {
// Creates a new instance of JavaMailSenderImpl, which is a Spring implementation
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
String host = mailProperties.getHost();
mailSender.setHost(host);
mailSender.setPort(mailProperties.getPort());
mailSender.setDefaultEncoding("UTF-8");
@@ -70,8 +71,32 @@ public class MailConfig {
log.info("SMTP authentication disabled - no credentials provided");
}
boolean startTlsEnabled =
mailProperties.getStartTlsEnable() == null || mailProperties.getStartTlsEnable();
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.enable", Boolean.toString(startTlsEnabled));
if (mailProperties.getStartTlsRequired() != null) {
props.put(
"mail.smtp.starttls.required", mailProperties.getStartTlsRequired().toString());
}
if (mailProperties.getSslEnable() != null) {
props.put("mail.smtp.ssl.enable", mailProperties.getSslEnable().toString());
}
// Trust the configured host to allow STARTTLS with self-signed certificates
String sslTrust = mailProperties.getSslTrust();
if (sslTrust == null || sslTrust.trim().isEmpty()) {
sslTrust = "*";
}
if (sslTrust != null && !sslTrust.trim().isEmpty()) {
props.put("mail.smtp.ssl.trust", sslTrust);
}
if (mailProperties.getSslCheckServerIdentity() != null) {
props.put(
"mail.smtp.ssl.checkserveridentity",
mailProperties.getSslCheckServerIdentity().toString());
}
// Returns the configured mail sender, ready to send emails
return mailSender;
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
@@ -198,74 +194,19 @@ public class SecurityConfiguration {
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
SessionCreationPolicy.STATELESS));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
@@ -324,10 +265,16 @@ public class SecurityConfiguration {
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
// We configure form login to handle Spring Security redirects,
// but use /perform_login as the processing URL so /login remains a React route
http.formLogin(
formLogin ->
formLogin
.loginPage("/login")
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl(
"/perform_login") // Process form posts here (not
// /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -342,18 +289,7 @@ public class SecurityConfiguration {
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
oauth2.loginPage("/login")
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
@@ -387,12 +323,8 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2.loginPage("/login")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
@@ -402,7 +334,8 @@ public class SecurityConfiguration {
securityProperties.getSaml2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
@@ -411,7 +344,8 @@ public class SecurityConfiguration {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
});
})
.saml2Metadata(metadata -> {});
}
} else {
log.debug("Login is not enabled.");
@@ -244,10 +244,13 @@ public class AuthController {
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
userMap.put(
"authenticationType",
user.getAuthenticationType()); // Expose authentication type for SSO detection
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata);
return userMap;
@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import jakarta.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
@@ -236,6 +238,8 @@ public class UserController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
// Set flags before changing password so they're saved together
user.setForcePasswordChange(false);
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -584,6 +588,79 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changePasswordForUser")
public ResponseEntity<?> changePasswordForUser(
@RequestParam(name = "username") String username,
@RequestParam(name = "newPassword", required = false) String newPassword,
@RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
@RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword,
@RequestParam(name = "forcePasswordChange", defaultValue = "false")
boolean forcePasswordChange,
HttpServletRequest request,
Authentication authentication)
throws SQLException, UnsupportedProviderException, MessagingException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
String currentUsername = authentication.getName();
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own password."));
}
User user = userOpt.get();
String finalPassword = newPassword;
if (generateRandom) {
finalPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
if (finalPassword == null || finalPassword.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "New password is required."));
}
// Set force password change flag before changing password so both are saved together
user.setForcePasswordChange(forcePasswordChange);
userService.changePassword(user, finalPassword);
// Invalidate all active sessions to force reauthentication
userService.invalidateUserSessions(username);
if (sendEmail) {
if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email is not configured."));
}
String userEmail = user.getUsername();
// Check if username is a valid email format
if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"User's email is not a valid email address. Notifications are disabled."));
}
String loginUrl = buildLoginUrl(request);
emailService
.get()
.sendPasswordChangedNotification(
userEmail,
user.getUsername(),
includePassword ? finalPassword : null,
loginUrl);
}
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(
@@ -26,6 +26,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
@@ -47,19 +49,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -68,7 +57,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
}
@@ -77,10 +70,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow specific auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Allow auth endpoints to pass through without JWT
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
@@ -241,24 +241,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -59,6 +59,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "forcePasswordChange")
private Boolean forcePasswordChange = false;
@Column(name = "roleName")
private String roleName;
@@ -117,6 +120,14 @@ public class User implements UserDetails, Serializable {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public boolean isForcePasswordChange() {
return forcePasswordChange != null && forcePasswordChange;
}
public void setForcePasswordChange(boolean forcePasswordChange) {
this.forcePasswordChange = forcePasswordChange;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
log.warn(
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
log.warn(
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
username);
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("No OAuth2 provider registered");
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
}
log.info(
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
registrations.size(),
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
return new InMemoryClientRegistrationRepository(registrations);
}
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
boolean isValid =
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
if (isValid) {
log.info(
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + "oidc")
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();
@@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -67,21 +68,27 @@ public class CustomSaml2AuthenticationSuccessHandler
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
@@ -138,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
response.sendRedirect(
contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
return;
}
try {
if (!userExists || saml2Properties.getBlockRegistration()) {
log.debug("Registration blocked for new user: {}", username);
response.sendRedirect(
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
// Block new users only if: blockRegistration is true OR autoCreateUser is false
if (!userExists
&& (saml2Properties.getBlockRegistration()
|| !saml2Properties.getAutoCreateUser())) {
log.debug(
"Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
username,
saml2Properties.getBlockRegistration(),
saml2Properties.getAutoCreateUser());
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?maxUsersReached=true");
return;
}
@@ -216,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
String origin = resolveOrigin(request);
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
/**
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
* then falls back to detecting from request headers.
*/
private String resolveOrigin(HttpServletRequest request) {
// First check if frontendUrl is configured
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
return configuredFrontendUrl.trim();
}
// Fall back to auto-detection from request headers
return resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
@@ -41,25 +41,92 @@ public class Saml2Configuration {
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert());
log.info(
"Initializing SAML2 configuration with registration ID: {}",
samlConf.getRegistrationId());
// Load IdP certificate
X509Certificate idpCert;
try {
Resource idpCertResource = samlConf.getIdpCert();
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
log.error(
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
idpCert = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
idpCert.getSubjectX500Principal().getName());
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Saml2X509Credential signingCredential =
new Saml2X509Credential(
CertificateUtils.readPrivateKey(privateKeyResource),
CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING);
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
if (!privateKeyResource.exists()) {
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
throw new IllegalStateException(
"SAML2 SP private key file does not exist: "
+ privateKeyResource.getDescription());
}
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
if (!certificateResource.exists()) {
log.error(
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
throw new IllegalStateException(
"SAML2 SP certificate file does not exist: "
+ certificateResource.getDescription());
}
Saml2X509Credential signingCredential;
try {
signingCredential =
new Saml2X509Credential(
CertificateUtils.readPrivateKey(privateKeyResource),
CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING);
log.info("Successfully loaded SP credentials");
} catch (Exception e) {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl == null || backendUrl.isBlank()) {
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
log.warn(
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
} else {
log.info("Using configured backend URL for SAML: {}", backendUrl);
}
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(samlConf.getIdpIssuer())
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation("http://localhost:8080/login")
.singleLogoutServiceResponseLocation(sloResponseLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
@@ -75,9 +142,14 @@ public class Saml2Configuration {
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
"http://localhost:8080/login")
sloResponseLocation)
.wantAuthnRequestsSigned(true))
.build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@@ -223,4 +223,54 @@ public class EmailService {
sendPlainEmail(to, subject, body, true);
}
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl)
throws MessagingException {
String subject = "Your Stirling PDF password has been updated";
String passwordSection =
newPassword == null
? ""
: """
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
"""
.formatted(newPassword);
String body =
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
<div style=\"text-align: center; padding: 20px; background-color: #222;\">
<img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">
</div>
<div style=\"padding: 30px; color: #333;\">
<h2 style=\"color: #222; margin-top: 0;\">Your password was changed</h2>
<p>Hello %s,</p>
<p>An administrator has updated the password for your Stirling PDF account.</p>
%s
<p>If you did not expect this change, please contact your administrator immediately.</p>
<div style=\"text-align: center; margin: 30px 0;\">
<a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Go to Stirling PDF</a>
</div>
<p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>
<div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">
%s
</div>
</div>
<div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, passwordSection, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
@Service
public class ImageMagickLineArtConversionService implements LineArtConversionService {
@Override
public PDImageXObject convertImageToLineArt(
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
throws IOException {
Path inputImage = Files.createTempFile("lineart_image_input_", ".png");
Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff");
try {
ImageIO.write(originalImage.getImage(), "png", inputImage.toFile());
List<String> command = new ArrayList<>();
command.add("magick");
command.add(inputImage.toString());
command.add("-colorspace");
command.add("Gray");
// Edge-aware line art conversion using ImageMagick's built-in operators.
// -edge/-negate/-normalize are standard convert options (IM v6+/v7) that
// accentuate outlines before thresholding to a bilevel image.
command.add("-edge");
command.add(String.valueOf(edgeLevel));
command.add("-negate");
command.add("-normalize");
command.add("-type");
command.add("Bilevel");
command.add("-threshold");
command.add(String.format(Locale.ROOT, "%.1f%%", threshold));
command.add("-compress");
command.add("Group4");
command.add(outputImage.toString());
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0) {
log.warn(
"ImageMagick line art conversion failed with return code: {}",
result.getRc());
throw new IOException("ImageMagick line art conversion failed");
}
byte[] convertedBytes = Files.readAllBytes(outputImage);
return PDImageXObject.createFromByteArray(
doc, convertedBytes, originalImage.getCOSObject().toString());
} catch (Exception e) {
log.warn("ImageMagick line art conversion failed", e);
throw new IOException("ImageMagick line art conversion failed", e);
} finally {
Files.deleteIfExists(inputImage);
Files.deleteIfExists(outputImage);
}
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -13,6 +14,8 @@ import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@@ -31,6 +34,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper = new ObjectMapper();
// Storage limits per user
private static final int MAX_SIGNATURES_PER_USER = 20;
@@ -88,6 +92,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Copy text signature properties if present
if ("text".equals(request.getType())) {
response.setSignerName(request.getSignerName());
response.setFontFamily(request.getFontFamily());
response.setFontSize(request.getFontSize());
response.setTextColor(request.getTextColor());
}
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
@@ -133,6 +145,19 @@ public class SignatureService implements PersonalSignatureServiceInterface {
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
// Save metadata JSON file
String metadataFileName = request.getId() + ".json";
Path metadataPath = targetFolder.resolve(metadataFileName);
verifyPathWithinDirectory(metadataPath, targetFolder);
String metadataJson = objectMapper.writeValueAsString(response);
Files.writeString(
metadataPath,
metadataJson,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
return response;
}
@@ -179,6 +204,13 @@ public class SignatureService implements PersonalSignatureServiceInterface {
log.info("Deleted signature file: {}", file);
}
}
// Also delete metadata file if it exists
Path metadataPath = personalFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
Files.delete(metadataPath);
log.info("Deleted signature metadata: {}", metadataPath);
}
}
if (!deleted) {
@@ -186,6 +218,50 @@ public class SignatureService implements PersonalSignatureServiceInterface {
}
}
/** Update a signature label. */
public void updateSignatureLabel(String username, String signatureId, String newLabel)
throws IOException {
validateFileName(signatureId);
// Try personal folder first
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
Path metadataPath = personalFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
updateMetadataLabel(metadataPath, newLabel);
log.info("Updated label for personal signature {} (user: {})", signatureId, username);
return;
}
// If not found in personal, try shared folder
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
Path sharedMetadataPath = sharedFolder.resolve(signatureId + ".json");
if (Files.exists(sharedMetadataPath)) {
updateMetadataLabel(sharedMetadataPath, newLabel);
log.info("Updated label for shared signature {}", signatureId);
return;
}
throw new FileNotFoundException("Signature metadata not found");
}
private void updateMetadataLabel(Path metadataPath, String newLabel) throws IOException {
String metadataJson = Files.readString(metadataPath, StandardCharsets.UTF_8);
SavedSignatureResponse sig =
objectMapper.readValue(metadataJson, SavedSignatureResponse.class);
sig.setLabel(newLabel);
sig.setUpdatedAt(System.currentTimeMillis());
String updatedJson = objectMapper.writeValueAsString(sig);
Files.writeString(
metadataPath,
updatedJson,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
}
// Private helper methods
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
@@ -245,16 +321,31 @@ public class SignatureService implements PersonalSignatureServiceInterface {
String fileName = path.getFileName().toString();
String id = fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
// Try to load metadata from JSON file
Path metadataPath = folder.resolve(id + ".json");
SavedSignatureResponse sig;
// Set unified URL path (works for both personal and shared)
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
if (Files.exists(metadataPath)) {
// Load from metadata file
String metadataJson =
Files.readString(
metadataPath, StandardCharsets.UTF_8);
sig =
objectMapper.readValue(
metadataJson, SavedSignatureResponse.class);
} else {
// Fallback for old signatures without metadata
sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
}
signatures.add(sig);
} catch (IOException e) {
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -176,6 +177,13 @@ public class UserLicenseSettingsService {
*/
@Transactional
public void grandfatherExistingOAuthUsers() {
// Only grandfather users if this is a V1V2 upgrade, not a fresh V2 install
Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer();
if (Boolean.TRUE.equals(isNewServer)) {
log.info("Fresh V2 installation detected - skipping OAuth user grandfathering");
return;
}
UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration
@@ -343,17 +351,65 @@ public class UserLicenseSettingsService {
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
public boolean isOAuthEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Check license first - if paying, they're eligible (no need to check grandfathering)
boolean hasPaid = hasPaidLicense();
if (hasPaid) {
log.debug("User {} eligible for OAuth via paid license", username);
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} eligible for OAuth via grandfathering (no paid license)", username);
return true;
}
// Not grandfathered and no license
log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username);
return false;
}
/**
* Checks if a user is eligible to use SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use SAML
*/
public boolean isSamlEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Check license first - if paying, they're eligible (no need to check grandfathering)
boolean hasEnterprise = hasEnterpriseLicense();
if (hasEnterprise) {
log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
return true;
}
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) {
log.info(
"User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
username);
return true;
}
// Not grandfathered and no license
log.info(
"User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered",
username);
return false;
}
/**
@@ -495,8 +551,12 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
return hasPaid;
}
/**
@@ -510,7 +570,19 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
log.info(
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
license,
(license == License.ENTERPRISE));
if (license != License.ENTERPRISE) {
log.warn(
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
license);
}
return license == License.ENTERPRISE;
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/**
* Unit tests for OAuth2Configuration redirect URI logic.
*
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
* InvalidClientRegistrationIdException when custom provider names were used.
*
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
* Valid OIDC configuration responses 3. Network mocking infrastructure
*/
class OAuth2ConfigurationTest {
/**
* Tests the redirect URI pattern for OIDC provider configurations.
*
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
* registration ID. For example: - Provider name: "authentik" Redirect URI:
* "/login/oauth2/code/authentik" - Provider name: "mycompany" Redirect URI:
* "/login/oauth2/code/mycompany" - Provider name: "oidc" Redirect URI:
* "/login/oauth2/code/oidc"
*
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
* a registration with ID 'oidc' when the provider redirected back. This caused
* InvalidClientRegistrationIdException when custom provider names were used.
*/
@Test
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
// Verify the redirect URI pattern constant
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
// Test cases: provider name expected redirect URI
String[][] testCases = {
{"authentik", redirectUriBase + "authentik"},
{"mycompany", redirectUriBase + "mycompany"},
{"oidc", redirectUriBase + "oidc"},
{"okta", redirectUriBase + "okta"},
{"auth0", redirectUriBase + "auth0"}
};
for (String[] testCase : testCases) {
String providerName = testCase[0];
String expectedRedirectUri = testCase[1];
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
String actualRedirectUri = redirectUriBase + providerName;
assertEquals(
expectedRedirectUri,
actualRedirectUri,
String.format(
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
providerName));
}
}
/**
* Documents the critical fix for OAuth2 redirect URI mismatch.
*
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
*
* <pre>
* // BEFORE (bug):
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
*
* // AFTER (fix):
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
* </pre>
*/
@Test
void testCriticalFix_redirectUriMatchesRegistrationId() {
// The redirect URI path segment extraction by Spring Security
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
// Spring extracts the path segment between "code/" and "?"
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
// The extracted ID MUST match an actual registration ID
assertEquals("authentik", extractedRegistrationId);
// If we had used hardcoded "oidc", the callback would be:
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
// This would look for registration with ID "oidc" but we registered "authentik"
assertEquals("oidc", buggyExtractedId);
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
// Result: InvalidClientRegistrationIdException
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
}
/** Helper method simulating Spring's extraction of registration ID from callback URL */
private String extractRegistrationIdFromCallback(String callbackUrl) {
// Simplified version of what Spring Security does
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
String path = callbackUrl.split("\\?")[0];
String[] parts = path.split("/");
return parts[parts.length - 1]; // Last path segment
}
/**
* Validates the frontend-backend flow for custom provider names.
*
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
* "authentik" SUCCESS
*
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
* InvalidClientRegistrationIdException
*/
@Test
void testEndToEndFlow_registrationIdConsistency() {
String providerName = "authentik";
// Step 2: Registration ID
String registrationId = providerName;
assertEquals("authentik", registrationId);
// Step 3: Redirect URI (MUST use same name)
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
// Step 4: Provider list endpoint
String authorizationPath = "/oauth2/authorization/" + providerName;
assertEquals("/oauth2/authorization/authentik", authorizationPath);
// Step 5: Frontend extracts provider ID
String frontendProviderId =
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
assertEquals("authentik", frontendProviderId);
// Step 6-8: OAuth flow (external)
// Step 9: Callback URL from provider
String callbackUrl =
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
// Step 10: Registration lookup
assertEquals(
registrationId,
extractedId,
"Registration ID from callback MUST match original registration ID");
}
}
@@ -27,6 +27,11 @@ class MailConfigTest {
when(mailProps.getPort()).thenReturn(587);
when(mailProps.getUsername()).thenReturn("user@example.com");
when(mailProps.getPassword()).thenReturn("password");
when(mailProps.getStartTlsEnable()).thenReturn(null);
when(mailProps.getStartTlsRequired()).thenReturn(null);
when(mailProps.getSslEnable()).thenReturn(null);
when(mailProps.getSslTrust()).thenReturn(null);
when(mailProps.getSslCheckServerIdentity()).thenReturn(null);
}
@Test
@@ -50,6 +55,32 @@ class MailConfigTest {
() -> assertEquals("password", impl.getPassword()),
() -> assertEquals("UTF-8", impl.getDefaultEncoding()),
() -> assertEquals("true", props.getProperty("mail.smtp.auth")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")));
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals(null, props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals(null, props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")));
}
@Test
void shouldRespectExplicitTlsOverrides() {
ApplicationProperties appProps = mock(ApplicationProperties.class);
when(mailProps.getStartTlsEnable()).thenReturn(false);
when(mailProps.getStartTlsRequired()).thenReturn(true);
when(mailProps.getSslEnable()).thenReturn(true);
when(mailProps.getSslTrust()).thenReturn("*");
when(mailProps.getSslCheckServerIdentity()).thenReturn(true);
when(appProps.getMail()).thenReturn(mailProps);
MailConfig config = new MailConfig(appProps);
JavaMailSenderImpl impl = (JavaMailSenderImpl) config.javaMailSender();
Properties props = impl.getJavaMailProperties();
assertAll(
() -> assertEquals("false", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.checkserveridentity")));
}
}
@@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest {
@Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium;
@Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
@@ -49,6 +50,9 @@ class UserLicenseSettingsServiceTest {
mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium);
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getIsNewServer())
.thenReturn(false); // Default: not a new server
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
@@ -267,4 +271,222 @@ class UserLicenseSettingsServiceTest {
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
// ===== OAuth Eligibility Tests =====
@Test
void isOAuthEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
// Non-grandfathered user with SERVER license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isOAuthEligible(user);
assertEquals(
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without paid license should NOT be eligible");
}
@Test
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
// New user (null) with SERVER license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(null);
assertEquals(
true, result, "New user with SERVER license should be eligible for auto-creation");
}
@Test
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
// New user (null) without license should NOT be eligible
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(null);
assertEquals(
false,
result,
"New user without paid license should NOT be eligible for auto-creation");
}
@Test
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, OAuth should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isOAuthEligible(user);
assertEquals(
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
}
// ===== SAML Eligibility Tests =====
@Test
void isSamlEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible for SAML regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(user);
assertEquals(
true,
result,
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
// New user (null) with ENTERPRISE license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(null);
assertEquals(
true,
result,
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
}
@Test
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
// New user (null) with SERVER license should NOT be eligible for SAML
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(null);
assertEquals(
false,
result,
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
}
@Test
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, SAML should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isSamlEligible(user);
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
}
}
+49 -2
View File
@@ -12,6 +12,8 @@ plugins {
}
import com.github.jk1.license.render.*
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
ext {
springBootVersion = "3.5.6"
@@ -57,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.2'
version = '2.1.4'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -65,6 +67,51 @@ allprojects {
}
}
def writeIfChanged(File targetFile, String newContent) {
if (targetFile.getText('UTF-8') != newContent) {
targetFile.write(newContent, 'UTF-8')
}
}
def updateTauriConfigVersion(String version) {
File tauriConfig = file('frontend/src-tauri/tauri.conf.json')
def parsed = new JsonSlurper().parse(tauriConfig)
parsed.version = version
def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator()
writeIfChanged(tauriConfig, formatted)
}
def updateSimulationVersion(File fileToUpdate, String version) {
def content = fileToUpdate.getText('UTF-8')
def matcher = content =~ /(appVersion:\s*')([^']*)(')/
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}")
writeIfChanged(fileToUpdate, updatedContent)
}
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
def appVersion = project.version.toString()
println "Synchronizing application version to ${appVersion}"
updateTauriConfigVersion(appVersion)
[
'frontend/src/core/testing/serverExperienceSimulations.ts',
'frontend/src/proprietary/testing/serverExperienceSimulations.ts'
].each { path ->
updateSimulationVersion(file(path), appVersion)
}
}
}
tasks.register('writeVersion', WriteProperties) {
destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
println "Writing version.properties to ${destinationFile.get().asFile.path}"
@@ -314,7 +361,7 @@ tasks.named('bootRun') {
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion'
doFirst {
println "Delegating to :stirling-pdf:bootJar"
+32 -35
View File
@@ -8,36 +8,33 @@
Fork Stirling-PDF and create a new branch out of `main`.
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
## Frontend Translation Files (TOML Format)
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
### Add Language Directory and Translation File
1. Create a new language directory in `frontend/public/locales/`
- Use hyphenated format: `pl-PL` (not underscore)
For example, to add Polish, you would add:
2. Copy the reference translation file:
- Source: `frontend/public/locales/en-GB/translation.toml`
- Destination: `frontend/public/locales/pl-PL/translation.toml`
```html
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
```
3. Translate all entries in the TOML file
- Keep the TOML structure intact
- Preserve all placeholders like `{n}`, `{total}`, `{filename}`, `{{variable}}`
- See `scripts/translations/README.md` for translation tools and workflows
The `data-bs-language-code` is the code used to reference the file in the next step.
4. Update the language selector in the frontend to include your new language
### Add Language Property File
Start by copying the existing English property file:
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
Then make a Pull Request (PR) into `main` for others to use!
## Handling Untranslatable Strings
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
For example, if the English string `error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
**Note**: Use underscores in `ignore_translation.toml` even though frontend uses hyphens (e.g., `pl_PL` not `pl-PL`)
```toml
[pl_PL]
@@ -50,27 +47,27 @@ ignore = [
## Add New Translation Tags
> [!IMPORTANT]
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
> If you add any new translation tags, they must first be added to the `en-GB/translation.toml` file. This ensures consistency across all language files.
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
- New translation tags **must be added** to `frontend/public/locales/en-GB/translation.toml` to maintain a reference for other languages.
- After adding the new tags to `en-GB/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`).
- Use the scripts in `scripts/translations/` to validate and manage translations (see `scripts/translations/README.md`)
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
### Use this code to perform a local check
### Validation Commands
#### Windows command
```powershell
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
```
#### Linux command
Use the translation scripts in `scripts/translations/` directory:
```bash
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
# Analyze translation progress
python3 scripts/translations/translation_analyzer.py --language pl-PL
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
# Validate TOML structure
python3 scripts/translations/validate_json_structure.py --language pl-PL
# Validate placeholders
python3 scripts/translations/validate_placeholders.py --language pl-PL
```
See `scripts/translations/README.md` for complete documentation.
+1
View File
@@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
gcompat \
libc6-compat \
libreoffice \
imagemagick \
# pdftohtml
poppler-utils \
# OCR MY PDF
+1
View File
@@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+1
View File
@@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
+139
View File
@@ -0,0 +1,139 @@
# Stirling-PDF Dockerfile - Full version with embedded frontend
# Single JAR contains both frontend and backend
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Full version"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
# Copy scripts and fonts
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Install all dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+143
View File
@@ -0,0 +1,143 @@
# Stirling-PDF Dockerfile - Fat version with embedded frontend
# Single JAR contains both frontend and backend with extra fonts for air-gapped environments
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Fat"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Fat version with extra fonts for air-gapped environments"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
# Copy scripts and fonts
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
FAT_DOCKER=true \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Install all dependencies plus extra fonts for air-gapped environments
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
ghostscript \
imagemagick \
fontforge \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# Extra fonts for fat version
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
fc-cache -f -v && \
chmod +x /scripts/* && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+104
View File
@@ -0,0 +1,104 @@
# Stirling-PDF Dockerfile - Ultra-lite version with embedded frontend
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:8.14-jdk21 AS build
# Install Node.js and npm for frontend build
RUN apt-get update && apt-get install -y \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm --version \
&& node --version \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy gradle files for dependency resolution
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set working directory
WORKDIR /app
# Copy entire project
COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features)
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite"
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React"
# Copy scripts
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
# Copy built JAR from build stage
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
# Environment Variables
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf \
ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openjdk21-jre && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
mkdir -p /usr/share/fonts/opentype/noto && \
chmod +x /scripts/*.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
+2 -2
View File
@@ -103,8 +103,8 @@ http {
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Cache static assets (but not API endpoints)
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
+2 -2
View File
@@ -106,8 +106,8 @@ http {
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Cache static assets (but not API endpoints)
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
+4 -4
View File
@@ -3,21 +3,21 @@
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
<link rel="icon" href="/modern-logo/favicon.ico" />
<link rel="icon" href="modern-logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/>
<link rel="apple-touch-icon" href="/modern-logo/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>Stirling PDF</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="src/index.tsx"></script>
</body>
</html>
+221 -163
View File
@@ -11,26 +11,26 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-print": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.5.0",
"@embedpdf/engines": "^1.5.0",
"@embedpdf/plugin-annotation": "^1.5.0",
"@embedpdf/plugin-bookmark": "^1.5.0",
"@embedpdf/plugin-export": "^1.5.0",
"@embedpdf/plugin-history": "^1.5.0",
"@embedpdf/plugin-interaction-manager": "^1.5.0",
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",
"@embedpdf/plugin-search": "^1.5.0",
"@embedpdf/plugin-selection": "^1.5.0",
"@embedpdf/plugin-spread": "^1.5.0",
"@embedpdf/plugin-thumbnail": "^1.5.0",
"@embedpdf/plugin-tiling": "^1.5.0",
"@embedpdf/plugin-viewport": "^1.5.0",
"@embedpdf/plugin-zoom": "^1.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -105,6 +105,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
@@ -456,6 +457,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -499,6 +501,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -575,13 +578,14 @@
"license": "0BSD"
},
"node_modules/@embedpdf/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -592,13 +596,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.4.1.tgz",
"integrity": "sha512-yugIb5OwTI/1VnAaEvSYxAd2DvYBPkV/D7wytagyaOq98o3sqzcY2Q9zHt+LhnawA5KKG1e/FDPjCd4qm8gsvg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.5.0.tgz",
"integrity": "sha512-/GzhjHFHWfOaX7vjgFJX/pyq668wYjoda1bZ9MpwF/EF000Wwy2Q0AOhprjldPFz8ASKjwKwqsXmaqrK99yOAQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/pdfium": "1.4.1"
"@embedpdf/models": "1.5.0",
"@embedpdf/pdfium": "1.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -609,31 +613,31 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.4.1.tgz",
"integrity": "sha512-2nTg8Q1qpplBvspZJXMCZOA+/OILpfdNRPddlplxZXY/Upx0rzKXx/e6pXWW7AuOgtfGneT4h9tMs3A595/PdQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.5.0.tgz",
"integrity": "sha512-x/1li3jdag+IzfZkcfRLKLqASLep4v6dgVi3z0JArwaicFra8k1IY2xaVTrwcZyx7pRb/rxvoO9yLHW0Y34NFw==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.4.1.tgz",
"integrity": "sha512-BekKEK4UNCwzj7xOffKn6WpL0FQHxq+mTj2iGI3N7OwAX2J/BO2G+rDOB+lvojQG+Dkpg8uqm427ZKJDRyLgVQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.5.0.tgz",
"integrity": "sha512-PI32t2U4ThZC907n2Iwr8E5WqmC574G83u3V9ysNFl29N9kasrY9RiLSzU4W/yQvXPjIbpQHBsbMKXLjCFBI9w==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.4.1.tgz",
"integrity": "sha512-d4HibNy6ecyDqx2Y2R8VjaqppSdjNofAJmU6VenOd88wn080sAUqvnkeVJ6ehJH5BoND4ymQrcAkcbVeYK0myA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.5.0.tgz",
"integrity": "sha512-mxEPI6xYwOGaf9fYfoywuj6nwA10eHFPBuN066MzwphDk6DOHJGZ3Vq8zNQBXh20c/Lb25PL718D7MZWxZLUHg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/utils": "1.4.1"
"@embedpdf/models": "1.5.0",
"@embedpdf/utils": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-history": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-selection": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-history": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-selection": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -641,15 +645,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.5.0.tgz",
"integrity": "sha512-s3C9PtVesy5X8Ds/C9TEElFiqfKGRklG/uNPTROpNoolfpi0h7qX2xqqh/9+FzKH2nHjVcPB7Pp432v16h7eRA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -657,15 +661,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
"integrity": "sha512-g89fREFM/zkt2Ai2Q5dWwDkhXgC/JmVyUniaMgm1fTG/MZ0Z05E7f34DUzX/CKcJyVjxEgl6tojBTMeUbm15bA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.5.0.tgz",
"integrity": "sha512-luk68mNW9l2X31qk4b02phKaqDl9aDXUAgHVz1EWrgwXQ3Oz9WEdu60utYARYDiepDo3Caadll8RwctYSf/anA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -674,15 +678,16 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -690,15 +695,16 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -707,15 +713,16 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -724,17 +731,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.4.1.tgz",
"integrity": "sha512-zmOZJ9dUqXiaV0F5GPf/5WTWf3jAEkiv153Tl3x8HT9Rfff+WQhV48NruCIBAy/T4jVt4aH7D1zt/B/ftvcdkA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.5.0.tgz",
"integrity": "sha512-EMQ08dHqLkZmFVuLOO6h3AAinFPQoA1r6OlL9z+p0sswq31JAgd4X7+xjYIpI01z/V3+cTzPHzp7qwob5E4tbA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -743,15 +750,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.4.1.tgz",
"integrity": "sha512-YEjU6rQVW8wb125JXl1wma95+JISwADZpfqZZOtvPBRABp6ce4byblDTNjWVmTYWSgKUZrlXCL3Ff3Ig+bjbjw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.5.0.tgz",
"integrity": "sha512-rjorvNxAZfO9X4cFZVU9fHnldMWqMceJGmr3mH+yj7KdHePvNDDP+omyZyZKtxlUZENaeDI2h6k5z0GbhBz6sQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -760,15 +767,16 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -777,15 +785,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.4.1.tgz",
"integrity": "sha512-hVzHkKwMNH3tUhxqJGsj5qTLpYZXbj6E74AEcG0w/fz5FrK7EnofPqt0gRfYmIzxnQGIh+39BRtcp8gmx8UNnw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.5.0.tgz",
"integrity": "sha512-5EmBCsq0VfrE3xWY6ofuVm8S6aK95EbAycRIk1wczcmTdvpsuXZ6P2ZaECUgYMcpZ6uAg4/kGf8X8VVZuCihSQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -794,16 +802,17 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -812,16 +821,16 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.4.1.tgz",
"integrity": "sha512-8JG4CbOcUsLuT0vHJJ4cECmu+Yn53EokWFUVXi2Mo/XvHjhrQuWmD7+y6s/qQPEpctFYWmUCXTDAX9ynPud+2Q==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.5.0.tgz",
"integrity": "sha512-TB5b0H8Iobx/azVUBIlG2ClaKtf0y3/Xi3E/iB8BwvkIE2+g6EGfp8IMXIn8WDXST6bbvJEP31Ab0Ilp6SVkiw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-loader": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-loader": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -830,17 +839,18 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -849,16 +859,16 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.4.1.tgz",
"integrity": "sha512-l+SrDVGTiiItkt2cEtzv7V/X5HhmLbYHcQ8CFobGeIKdJtzKS1Nu/JSKqg7Ki7eCNgyPL1yMNfNE92bNKYVN4w==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.5.0.tgz",
"integrity": "sha512-3EU5Cp+fPQSiMjvMR/P2kXxXry/RlnxHLs4JeskAaH95QcqWW3VD+DrHkWSiLFkdhI18rNNGNlMc5RvDGvbXGQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-loader": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-loader": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -867,16 +877,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.4.1.tgz",
"integrity": "sha512-bN3msjI0PovazgbPK3LyugYVTwIDo0RyBUhBaG42FgJxeY3hmFOWTPgfUH1QF7twHlySnksIvHRFYR3nViryVw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.5.0.tgz",
"integrity": "sha512-Z2qpyyr5s2M6460KDGu1Vk6rdbQFIoCpnyFAT6e7UaTIKkqJSNpmjqMsBU5PosYCFu/cClpHPvS7tg9/IKAk6g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-render": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-render": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -885,18 +895,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.4.1.tgz",
"integrity": "sha512-wgTfj5T8HV6KP61iiR63DVNrbVp8sPxTqa1Sm+2/D0jY+EPSSCmpt1/qYWiAXd1X+t78foOjCnfbo7fEMn5/pg==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.5.0.tgz",
"integrity": "sha512-0Vx9elHNpMM+zv8hEoZXBEm8Q0+4kU52LxOlTYRr1A5FskF836sUct6g1ngwK1bmfbAfpz+62PnYI2EeilDZig==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-render": "1.4.1",
"@embedpdf/plugin-scroll": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-render": "1.5.0",
"@embedpdf/plugin-scroll": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -905,15 +915,16 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
"@embedpdf/models": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/core": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -922,19 +933,19 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.4.1.tgz",
"integrity": "sha512-9HocmXnPZxqN06q7kyNAmLjgDHOEW8/8QfgNE3nMpRyNHIgnAjxvsWc9lApgp5ErDPG0cSDt0Cduil6nB3wSBQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.5.0.tgz",
"integrity": "sha512-LiDkCd5/IXg2CRORl1Yikan2op+AYXSxhHzCFatyBdwzVj+n4y9I74OwCI62Mar8WDAIMyXZDCQxGPToSm+zDw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/models": "1.5.0",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.4.1",
"@embedpdf/plugin-interaction-manager": "1.4.1",
"@embedpdf/plugin-scroll": "1.4.1",
"@embedpdf/plugin-viewport": "1.4.1",
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-scroll": "1.5.0",
"@embedpdf/plugin-viewport": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -943,9 +954,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.4.1.tgz",
"integrity": "sha512-vvJ51Qsz3PyJWR2YvDMMpJXg4+YqdV7Vn2cusmW9sx+4EnAiBiw0HevEE+FepgFV8k+A0WbwXzmsujDIQJ7R4A==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.5.0.tgz",
"integrity": "sha512-L6jsAPQPGM8ne+MMFAd5gqXb1RNEgNyh16VvVUVKcVnJlBhwil59nVeEQ0cwPhjF5qVeY6MQDIOjBzJqkgXOYg==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -1064,6 +1075,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1107,6 +1119,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2137,6 +2150,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2187,6 +2201,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2254,6 +2269,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3186,6 +3202,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3304,7 +3321,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4081,6 +4097,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4409,6 +4426,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4419,6 +4437,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4488,6 +4507,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5201,7 +5221,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5211,7 +5230,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5222,7 +5240,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5235,7 +5252,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5262,6 +5278,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5669,7 +5686,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5946,6 +5962,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6993,7 +7010,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7388,6 +7406,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7558,6 +7577,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7724,8 +7744,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7790,7 +7809,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8881,6 +8899,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9357,7 +9376,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9678,6 +9696,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10264,8 +10283,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11076,6 +11094,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -11411,6 +11442,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11690,6 +11722,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12072,6 +12105,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12081,6 +12115,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13592,7 +13627,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13801,6 +13835,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14102,6 +14137,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14183,6 +14219,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14387,6 +14424,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14479,6 +14517,25 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-static-copy": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.3",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.15"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
@@ -14538,6 +14595,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14551,6 +14609,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15162,8 +15221,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
+21 -20
View File
@@ -7,26 +7,26 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-bookmark": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-print": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.5.0",
"@embedpdf/engines": "^1.5.0",
"@embedpdf/plugin-annotation": "^1.5.0",
"@embedpdf/plugin-bookmark": "^1.5.0",
"@embedpdf/plugin-export": "^1.5.0",
"@embedpdf/plugin-history": "^1.5.0",
"@embedpdf/plugin-interaction-manager": "^1.5.0",
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",
"@embedpdf/plugin-search": "^1.5.0",
"@embedpdf/plugin-selection": "^1.5.0",
"@embedpdf/plugin-spread": "^1.5.0",
"@embedpdf/plugin-thumbnail": "^1.5.0",
"@embedpdf/plugin-tiling": "^1.5.0",
"@embedpdf/plugin-viewport": "^1.5.0",
"@embedpdf/plugin-zoom": "^1.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -152,6 +152,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+181 -10
View File
@@ -163,6 +163,11 @@ unfavorite = "إزالة من المفضلة"
fullscreen = "التبديل إلى وضع ملء الشاشة"
sidebar = "التبديل إلى وضع الشريط الجانبي"
[backendStartup]
notFoundTitle = "لم يتم العثور على الخادم الخلفي"
retry = "إعادة المحاولة"
unreachable = "لا يمكن للتطبيق حالياً الاتصال بالخادم الخلفي. تحقق من حالة الخادم والاتصال بالشبكة، ثم حاول مرة أخرى."
[zipWarning]
title = "ملف ZIP كبير"
message = "هذا الملف ZIP يحتوي على {{count}} ملفات. هل تريد الاستخراج على أي حال؟"
@@ -913,8 +918,8 @@ desc = "تراكب ملف PDF فوق آخر"
title = "تراكب ملفات PDF"
[home.pdfTextEditor]
title = "محرر نصوص PDF"
desc = "مراجعة وتحرير صادرات Stirling PDF بصيغة JSON مع تحرير نصوص مجمّعة وإعادة إنشاء PDF"
title = "محرر نص PDF"
desc = "حرّر النصوص والصور الموجودة داخل ملفات PDF"
[home.addText]
tags = "نص,تعليق,تسمية"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "توقيع مرسوم"
defaultImageLabel = "توقيع مرفوع"
defaultTextLabel = "توقيع مكتوب"
saveButton = "حفظ التوقيع"
savePersonal = "حفظ شخصي"
saveShared = "حفظ مشترك"
saveUnavailable = "أنشئ توقيعاً أولاً لحفظه."
noChanges = "التوقيع الحالي محفوظ بالفعل."
tempStorageTitle = "تخزين مؤقت في المتصفح"
tempStorageDescription = "يتم تخزين التواقيع في متصفحك فقط. ستُفقد إذا حذفت بيانات المتصفح أو بدّلت المتصفح."
personalHeading = "تواقيع شخصية"
sharedHeading = "تواقيع مشتركة"
personalDescription = "أنت فقط من يمكنه رؤية هذه التواقيع."
sharedDescription = "يمكن لجميع المستخدمين رؤية هذه التواقيع واستخدامها."
[sign.saved.type]
canvas = "رسم"
@@ -3023,6 +3036,91 @@ title = "الحصول على معلومات عن PDF"
header = "الحصول على معلومات عن PDF"
submit = "الحصول على المعلومات"
downloadJson = "تحميل JSON"
processing = "جارٍ استخراج المعلومات..."
results = "النتائج"
noResults = "شغّل الأداة لإنشاء تقرير."
downloads = "التنزيلات"
noneDetected = "لم يتم اكتشاف أي شيء"
indexTitle = "الفهرس"
[getPdfInfo.report]
entryLabel = "ملخص المعلومات الكامل"
shortTitle = "معلومات PDF"
[getPdfInfo.sections]
metadata = "البيانات الوصفية"
formFields = "حقول النماذج"
basicInfo = "معلومات أساسية"
documentInfo = "معلومات المستند"
compliance = "الامتثال"
encryption = "التشفير"
permissions = "الأذونات"
other = "أخرى"
perPageInfo = "معلومات لكل صفحة"
tableOfContents = "جدول المحتويات"
[getPdfInfo.other]
attachments = "المرفقات"
embeddedFiles = "ملفات مضمنة"
javaScript = "JavaScript"
layers = "الطبقات"
structureTree = "شجرة البنية"
xmp = "بيانات XMP الوصفية"
[getPdfInfo.perPage]
size = "الحجم"
annotations = "التعليقات التوضيحية"
images = "الصور"
links = "الروابط"
fonts = "الخطوط"
xobjects = "عدد كائنات XObject"
multimedia = "وسائط متعددة"
[getPdfInfo.summary]
pages = "الصفحات"
fileSize = "حجم الملف"
pdfVersion = "إصدار PDF"
language = "اللغة"
title = "ملخص PDF"
author = "المؤلف"
created = "تم الإنشاء"
modified = "تم التعديل"
permsAll = "جميع الأذونات مسموح بها"
permsRestricted = "{{count}} قيود"
permsMixed = "بعض الأذونات مقيّدة"
hasCompliance = "يتضمن معايير امتثال"
noCompliance = "لا توجد معايير امتثال"
basic = "معلومات أساسية"
documentInfo = "معلومات المستند"
securityTitle = "حالة الأمان"
technical = "تقني"
overviewTitle = "نظرة عامة على PDF"
[getPdfInfo.summary.security]
encrypted = "PDF مشفّر - توجد حماية بكلمة مرور"
unencrypted = "PDF غير مُشفّر - لا توجد حماية بكلمة مرور"
[getPdfInfo.summary.tech]
images = "الصور"
fonts = "الخطوط"
formFields = "حقول النماذج"
embeddedFiles = "ملفات مضمنة"
javaScript = "JavaScript"
layers = "الطبقات"
bookmarks = "الإشارات المرجعية"
multimedia = "وسائط متعددة"
[getPdfInfo.summary.overview]
untitled = "مستند بلا عنوان"
unknown = "مؤلف غير معروف"
text = "هذا ملف PDF يحتوي على {{pages}} صفحة بعنوان {{title}} وتم إنشاؤه بواسطة {{author}} (إصدار PDF {{version}})."
[getPdfInfo.error]
partial = "تعذّر معالجة بعض الملفات."
unexpected = "حدث خطأ غير متوقع أثناء الاستخراج."
[getPdfInfo.status]
complete = "اكتمل الاستخراج"
[extractPage]
tags = "استخراج"
@@ -3441,6 +3539,9 @@ signinTitle = "الرجاء تسجيل الدخول"
ssoSignIn = "تسجيل الدخول عبر تسجيل الدخول الأحادي"
oAuth2AutoCreateDisabled = "تم تعطيل الإنشاء التلقائي لمستخدم OAuth2"
oAuth2AdminBlockedUser = "تم حظر تسجيل أو تسجيل دخول المستخدمين غير المسجلين حاليًا. يرجى الاتصال بالمسؤول."
oAuth2RequiresLicense = "يتطلب تسجيل الدخول عبر OAuth/SSO ترخيصاً مدفوعاً (Server أو Enterprise). يرجى الاتصال بالمسؤول لترقية باقتك."
saml2RequiresLicense = "يتطلب تسجيل الدخول عبر SAML ترخيصاً مدفوعاً (Server أو Enterprise). يرجى الاتصال بالمسؤول لترقية باقتك."
maxUsersReached = "تم الوصول إلى الحد الأقصى لعدد المستخدمين ضمن ترخيصك الحالي. يرجى الاتصال بالمسؤول لترقية باقتك أو إضافة مقاعد إضافية."
oauth2RequestNotFound = "لم يتم العثور على طلب التفويض"
oauth2InvalidUserInfoResponse = "استجابة معلومات المستخدم غير صالحة"
oauth2invalidRequest = "طلب غير صالح"
@@ -3774,7 +3875,7 @@ version = "الإصدار الحالي"
title = "توثيق API"
header = "توثيق API"
desc = "عرض واختبار نقاط نهاية Stirling PDF API"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,توثيق,swagger,نقاط النهاية,تطوير"
[cookieBanner.popUp]
title = "كيف نستخدم ملفات تعريف الارتباط"
@@ -3812,8 +3913,8 @@ title = "التحليلات"
description = "تساعدنا هذه الملفات على فهم كيفية استخدام أدواتنا، كي نركّز على بناء الميزات الأكثر قيمة لمجتمعنا. كن مطمئنًا—‏Stirling PDF لا يمكنه ولن يتتبع محتوى المستندات التي تعمل عليها."
[cookieBanner.services]
posthog = "PostHog Analytics"
scarf = "Scarf Pixel"
posthog = "تحليلات PostHog"
scarf = "Scarf بكسل"
[removeMetadata]
submit = "إزالة البيانات الوصفية"
@@ -3849,14 +3950,17 @@ fitToWidth = "ملاءمة للعرض"
actualSize = "الحجم الفعلي"
[viewer]
cannotPreviewFile = "لا يمكن معاينة الملف"
dualPageView = "عرض صفحتين"
firstPage = "الصفحة الأولى"
lastPage = "الصفحة الأخيرة"
previousPage = "الصفحة السابقة"
nextPage = "الصفحة التالية"
onlyPdfSupported = "عارض الملفات يدعم ملفات PDF فقط. يبدو أن هذا الملف بتنسيق مختلف."
previousPage = "الصفحة السابقة"
singlePageView = "عرض صفحة واحدة"
unknownFile = "ملف غير معروف"
zoomIn = "تكبير"
zoomOut = "تصغير"
singlePageView = "عرض صفحة واحدة"
dualPageView = "عرض صفحتين"
[rightRail]
closeSelected = "إغلاق الصفحات المحددة"
@@ -3880,6 +3984,7 @@ toggleSidebar = "تبديل الشريط الجانبي"
exportSelected = "تصدير الصفحات المحددة"
toggleAnnotations = "تبديل ظهور التعليقات التوضيحية"
annotationMode = "تبديل وضع التعليقات"
print = "طباعة PDF"
draw = "رسم"
save = "حفظ"
saveChanges = "حفظ التغييرات"
@@ -4497,6 +4602,7 @@ description = "عنوان URL أو اسم الملف الخاص بـ Impressum (
title = "الممتاز والمؤسسي"
description = "تهيئة مفتاح الترخيص للمزايا الممتازة أو المؤسسية."
license = "تهيئة الترخيص"
noInput = "يرجى تقديم مفتاح ترخيص أو ملف"
[admin.settings.premium.licenseKey]
toggle = "هل لديك مفتاح ترخيص أو ملف شهادة؟"
@@ -4514,6 +4620,25 @@ line1 = "لا يمكن التراجع عن استبدال مفتاح الترخ
line2 = "سيُفقد ترخيصك السابق نهائياً ما لم تكن قد احتفظت بنسخة احتياطية منه في مكان آخر."
line3 = "مهم: احتفظ بمفاتيح الترخيص خاصة وآمنة. لا تشاركها علناً أبداً."
[admin.settings.premium.inputMethod]
text = "مفتاح الترخيص"
file = "ملف الشهادة"
[admin.settings.premium.file]
label = "ملف شهادة الترخيص"
description = "قم بتحميل ملف الترخيص .lic أو .cert من عمليات الشراء دون اتصال"
choose = "اختر ملف الترخيص"
selected = "المحدد: {{filename}} ({{size}})"
successMessage = "تم تحميل ملف الترخيص وتفعيله بنجاح. لا يلزم إعادة التشغيل."
[admin.settings.premium.currentLicense]
title = "الترخيص النشط"
file = "المصدر: ملف الترخيص ({{path}})"
key = "المصدر: مفتاح الترخيص"
type = "النوع: {{type}}"
noInput = "يرجى تقديم مفتاح ترخيص أو تحميل ملف شهادة"
success = "نجاح"
[admin.settings.premium.enabled]
label = "تمكين الميزات الممتازة"
description = "تمكين التحقق من مفتاح الترخيص لميزات Pro/المؤسسة"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} محدد"
download = "تنزيل"
delete = "حذف"
unsupported = "غير مدعوم"
active = "نشط"
addToUpload = "إضافة إلى الرفع"
closeFile = "إغلاق الملف"
deleteAll = "حذف الكل"
loadingFiles = "جارٍ تحميل الملفات..."
noFiles = "لا توجد ملفات متاحة"
@@ -5135,7 +5262,7 @@ upgrade = "الترقية الآن →"
freeTitle = "ترخيص الخادم"
overLimitTitle = "مطلوب ترخيص خادم"
overLimitBody = "ترخيصنا يسمح حتى <strong>{{freeTierLimit}}</strong> مستخدمين مجاناً لكل خادم. لديك <strong>{{overLimitUserCopy}}</strong> مستخدمي Stirling. للمتابعة دون انقطاع، ارقَ إلى خطة خادم Stirling - <strong>مقاعد غير محدودة</strong>، تحرير نصوص PDF، وتحكم إداري كامل مقابل $99/خادم/شهرياً."
freeBody = "ترخيص <strong>Open-Core</strong> لدينا يسمح حتى <strong>{{freeTierLimit}}</strong> مستخدمين مجاناً لكل خادم. للتوسع بسلاسة والحصول على وصول مبكر إلى <strong>أداة تحرير نصوص PDF</strong> الجديدة، نوصي بخطة خادم Stirling - تحرير كامل و<strong>مقاعد غير محدودة</strong> مقابل $99/خادم/شهرياً."
freeBody = "يتيح ترخيصنا <strong>Open-Core</strong> ما يصل إلى <strong>{{freeTierLimit}}</strong> مستخدمًا مجانًا لكل خادم. للتوسع دون انقطاع، نوصي بخطة Stirling Server - <strong>مقاعد غير محدودة</strong> و<strong>دعم SSO</strong> مقابل $99/server/mo."
[onboarding.desktopInstall]
title = "تنزيل"
@@ -5240,6 +5367,31 @@ error = "فشل تحديث حالة المستخدم"
success = "تم حذف المستخدم بنجاح"
error = "فشل حذف المستخدم"
[workspace.people.changePassword]
action = "تغيير كلمة المرور"
title = "تغيير كلمة المرور"
subtitle = "تحديث كلمة المرور لـ"
newPassword = "كلمة مرور جديدة"
confirmPassword = "تأكيد كلمة المرور"
placeholder = "أدخل كلمة مرور جديدة"
confirmPlaceholder = "أعد إدخال كلمة المرور الجديدة"
passwordRequired = "يرجى إدخال كلمة مرور جديدة"
passwordMismatch = "كلمتا المرور غير متطابقتين"
generateRandom = "إنشاء كلمة مرور آمنة"
generatedPreview = "كلمة المرور المُنشأة:"
copyTooltip = "نسخ إلى الحافظة"
copiedToClipboard = "تم نسخ كلمة المرور إلى الحافظة"
copyFailed = "فشل نسخ كلمة المرور"
sendEmail = "إرسال بريد إلكتروني للمستخدم حول هذا التغيير"
includePassword = "تضمين كلمة المرور الجديدة في البريد الإلكتروني"
forcePasswordChange = "إلزام المستخدم بتغيير كلمة المرور عند تسجيل الدخول التالي"
emailUnavailable = "بريد هذا المستخدم الإلكتروني غير صالح. تم تعطيل الإشعارات."
smtpDisabled = "تتطلب إشعارات البريد الإلكتروني تفعيل SMTP في الإعدادات."
notifyOnly = "سيتم إرسال بريد إلكتروني بدون كلمة المرور لإبلاغ المستخدم بأن المشرف قد غيّرها."
submit = "تحديث كلمة المرور"
success = "تم تحديث كلمة المرور بنجاح"
error = "فشل تحديث كلمة المرور"
[workspace.people.emailInvite]
tab = "دعوة عبر البريد الإلكتروني"
description = "اكتب أو الصق عناوين البريد الإلكتروني أدناه مفصولة بفواصل. سيتلقى المستخدمون بيانات اعتماد تسجيل الدخول عبر البريد الإلكتروني."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "مطلوب عنوان بريد إلكتروني واحد على الأقل"
submit = "إرسال الدعوات"
success = "تمت دعوة المستخدم/المستخدمين بنجاح"
partialSuccess = "فشلت بعض الدعوات"
partialFailure = "فشل بعض الدعوات"
allFailed = "فشلت دعوة المستخدمين"
error = "فشل إرسال الدعوات"
@@ -5773,6 +5925,7 @@ subtitle = "سجّل الدخول بحساب Stirling الخاص بك"
[setup.selfhosted]
title = "سجّل الدخول إلى الخادم"
subtitle = "أدخل بيانات اعتماد الخادم"
link = "أو الاتصال بحساب مُستضاف ذاتيًا"
[setup.server]
title = "الاتصال بالخادم"
@@ -5791,6 +5944,14 @@ description = "أدخل عنوان URL الكامل لخادم Stirling PDF ال
emptyUrl = "يرجى إدخال عنوان URL للخادم"
unreachable = "تعذّر الاتصال بالخادم"
testFailed = "فشل اختبار الاتصال"
configFetch = "فشل في جلب إعدادات الخادم. يرجى التحقق من عنوان URL والمحاولة مرة أخرى."
[setup.server.error.securityDisabled]
title = "تسجيل الدخول غير مفعّل"
body = "لا يحتوي هذا الخادم على تسجيل دخول مفعّل. للاتصال بهذا الخادم، يجب تمكين المصادقة:"
step1 = "عيّن DOCKER_ENABLE_SECURITY=true في بيئتك"
step2 = "أو عيّن security.enableLogin=true في settings.yml"
step3 = "أعد تشغيل الخادم"
[setup.login]
title = "تسجيل الدخول"
@@ -5800,6 +5961,13 @@ submit = "تسجيل الدخول"
signInWith = "تسجيل الدخول باستخدام"
oauthPending = "جارٍ فتح المتصفح للمصادقة..."
orContinueWith = "أو المتابعة بالبريد الإلكتروني"
serverRequirement = "ملاحظة: يجب أن يكون تسجيل الدخول مفعّلاً على الخادم."
showInstructions = "كيفية التمكين؟"
hideInstructions = "إخفاء الإرشادات"
instructions = "لتمكين تسجيل الدخول على خادم Stirling PDF الخاص بك:"
instructionsEnvVar = "عيّن متغيّر البيئة:"
instructionsOrYml = "أو في settings.yml:"
instructionsRestart = "ثم أعد تشغيل الخادم لتصبح التغييرات نافذة."
[setup.login.username]
label = "اسم المستخدم"
@@ -5856,6 +6024,7 @@ earlyAccess = "وصول مبكر"
reset = "إعادة تعيين التغييرات"
downloadJson = "تنزيل JSON"
generatePdf = "توليد PDF"
saveChanges = "حفظ التغييرات"
[pdfTextEditor.options.autoScaleText]
title = "ضبط النص تلقائياً ليتناسب مع الصناديق"
@@ -5893,6 +6062,8 @@ alpha = "هذا العارض بنسخة ألفا ولا يزال يتطور—ق
[pdfTextEditor.empty]
title = "لم يتم تحميل مستند"
subtitle = "حمّل ملف PDF أو JSON لبدء تحرير محتوى النص."
dropzone = "اسحب وأفلت ملف PDF أو JSON هنا، أو انقر للاستعراض"
dropzoneWithFiles = "حدد ملفًا من علامة تبويب الملفات، أو اسحب وأفلت ملف PDF أو JSON هنا، أو انقر للاستعراض"
[pdfTextEditor.welcomeBanner]
title = "مرحباً بك في محرر نصوص PDF (وصول مبكر)"
+180 -9
View File
@@ -163,6 +163,11 @@ unfavorite = "Seçilmişlərdən çıxar"
fullscreen = "Tam ekran rejiminə keç"
sidebar = "Yan panel rejiminə keç"
[backendStartup]
notFoundTitle = "Backend tapılmadı"
retry = "Yenidən cəhd et"
unreachable = "Tətbiq hazırda backend-ə qoşula bilmir. Backend-in vəziyyətini və şəbəkə bağlantısını yoxlayın, sonra yenidən cəhd edin."
[zipWarning]
title = "Böyük ZIP faylı"
message = "Bu ZIP {{count}} fayl ehtiva edir. Yenə də çıxarılsın?"
@@ -914,7 +919,7 @@ title = "Üst-Üstə Qoy"
[home.pdfTextEditor]
title = "PDF Mətn Redaktoru"
desc = "Qruplaşdırılmış mətn redaktəsi və PDF yenidən yaradılması ilə Stirling PDF JSON ixraclarını nəzərdən keçirin və redaktə edin"
desc = "PDF-lərin içindəki mövcud mətn və şəkilləri redaktə edin"
[home.addText]
tags = "mətn,şərh,etiket"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Çəkilmiş imza"
defaultImageLabel = "Yüklənmiş imza"
defaultTextLabel = "Yazılmış imza"
saveButton = "İmzanı saxla"
savePersonal = "Şəxsi yadda saxla"
saveShared = "Paylaşılanı yadda saxla"
saveUnavailable = "Saxlamaq üçün əvvəlcə imza yaradın."
noChanges = "Cari imza artıq saxlanıb."
tempStorageTitle = "Müvəqqəti brauzer yaddaşı"
tempStorageDescription = "İmzalar yalnız brauzerinizdə saxlanılır. Brauzer məlumatlarını təmizləsəniz və ya brauzer dəyişsəniz, itəcək."
personalHeading = "Şəxsi imzalar"
sharedHeading = "Paylaşılan imzalar"
personalDescription = "Bu imzaları yalnız siz görə bilirsiniz."
sharedDescription = "Bütün istifadəçilər bu imzaları görə və istifadə edə bilərlər."
[sign.saved.type]
canvas = "Rəsm"
@@ -3023,6 +3036,91 @@ title = "PDF Barəsində Məlumat Əldə Et"
header = "PDF Barəsində Məlumat Əldə Et"
submit = "Məlumat Əldə Et"
downloadJson = "JSON yüklə"
processing = "Məlumat çıxarılır..."
results = "Nəticələr"
noResults = "Hesabat yaratmaq üçün aləti işə salın."
downloads = "Yükləmələr"
noneDetected = "Heç nə aşkar edilmədi"
indexTitle = "İndeks"
[getPdfInfo.report]
entryLabel = "Tam məlumat xülasəsi"
shortTitle = "PDF Məlumatı"
[getPdfInfo.sections]
metadata = "Metaməlumat"
formFields = "Forma sahələri"
basicInfo = "Əsas Məlumat"
documentInfo = "Sənəd Məlumatı"
compliance = "Uyğunluq"
encryption = "Şifrələmə"
permissions = "İcazələr"
other = "Digər"
perPageInfo = "Hər səhifə üzrə məlumat"
tableOfContents = "Mündəricat"
[getPdfInfo.other]
attachments = "Əlavələr"
embeddedFiles = "Gömülü fayllar"
javaScript = "JavaScript"
layers = "Qatlar"
structureTree = "Struktur ağacı"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Ölçü"
annotations = "Annotasiyalar"
images = "Şəkillər"
links = "Keçidlər"
fonts = "Şriftlər"
xobjects = "XObject sayları"
multimedia = "Multimedia"
[getPdfInfo.summary]
pages = "Səhifələr"
fileSize = "Fayl Ölçüsü"
pdfVersion = "PDF Versiyası"
language = "Dil"
title = "PDF Xülasəsi"
author = "Müəllif"
created = "Yaradılıb"
modified = "Dəyişdirilib"
permsAll = "Bütün icazələr verilib"
permsRestricted = "{{count}} məhdudiyyət"
permsMixed = "Bəzi icazələr məhdudlaşdırılıb"
hasCompliance = "Uyğunluq standartları mövcuddur"
noCompliance = "Uyğunluq standartları yoxdur"
basic = "Əsas Məlumat"
documentInfo = "Sənəd Məlumatı"
securityTitle = "Təhlükəsizlik Vəziyyəti"
technical = "Texniki"
overviewTitle = "PDF İcmalı"
[getPdfInfo.summary.security]
encrypted = "Şifrələnmiş PDF - Parol ilə qorunur"
unencrypted = "Şifrələnməmiş PDF - Parol qorunması yoxdur"
[getPdfInfo.summary.tech]
images = "Şəkillər"
fonts = "Şriftlər"
formFields = "Forma sahələri"
embeddedFiles = "Gömülü fayllar"
javaScript = "JavaScript"
layers = "Qatlar"
bookmarks = "Əlfəcinlər"
multimedia = "Multimedia"
[getPdfInfo.summary.overview]
untitled = "adsız sənəd"
unknown = "Naməlum müəllif"
text = "Bu, {{author}} tərəfindən yaradılmış, {{title}} adlı, {{pages}} səhifəlik PDF-dir (PDF versiyası {{version}})."
[getPdfInfo.error]
partial = "Bəzi faylları emal etmək mümkün olmadı."
unexpected = "Çıxarılma zamanı gözlənilməz xəta baş verdi."
[getPdfInfo.status]
complete = "Çıxarılma tamamlandı"
[extractPage]
tags = "çıxar"
@@ -3441,6 +3539,9 @@ signinTitle = "Zəhmət olmasa, daxil olun"
ssoSignIn = "Single Sign-on vasitəsilə daxil olun"
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create İstifadəçisi Deaktivləşdirilmişdir"
oAuth2AdminBlockedUser = "Qeydiyyatdan keçməmiş istifadəçilərin qeydiyyatı və daxil olması hal-hazırda bloklanmışdır. Zəhmət olmasa, administratorla əlaqə saxlayın."
oAuth2RequiresLicense = "OAuth/SSO ilə giriş üçün ödənişli lisenziya (Server və ya Enterprise) tələb olunur. Planınızı yüksəltmək üçün administratorla əlaqə saxlayın."
saml2RequiresLicense = "SAML ilə giriş üçün ödənişli lisenziya (Server və ya Enterprise) tələb olunur. Planınızı yüksəltmək üçün administratorla əlaqə saxlayın."
maxUsersReached = "Mövcud lisenziyanız üçün maksimum istifadəçi sayına çatılıb. Planınızı yüksəltmək və ya əlavə yerlər əlavə etmək üçün administratorla əlaqə saxlayın."
oauth2RequestNotFound = "Təsdiqlənmə sorğusu tapılmadı"
oauth2InvalidUserInfoResponse = "Yanlış İstifadəçi Məlumatı Cavabı"
oauth2invalidRequest = "Etibarsız Sorğu"
@@ -3849,14 +3950,17 @@ fitToWidth = "Eninə sığdır"
actualSize = "Həqiqi ölçü"
[viewer]
cannotPreviewFile = "Faylın önizlənməsi mümkün deyil"
dualPageView = "İki Səhifə Görünüşü"
firstPage = "Birinci səhifə"
lastPage = "Son səhifə"
previousPage = "Əvvəlki səhifə"
nextPage = "Növbəti səhifə"
onlyPdfSupported = "Görüntüləyici yalnız PDF fayllarını dəstəkləyir. Bu fayl fərqli formatda görünür."
previousPage = "Əvvəlki səhifə"
singlePageView = "Tək Səhifə Görünüşü"
unknownFile = "Naməlum fayl"
zoomIn = "Böyüt"
zoomOut = "Kiçilt"
singlePageView = "Tək Səhifə Görünüşü"
dualPageView = "İki Səhifə Görünüşü"
[rightRail]
closeSelected = "Seçilmiş faylları bağla"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Yan paneli aç/bağla"
exportSelected = "Seçilmiş səhifələri ixrac et"
toggleAnnotations = "Annotasiyaların görünməsini dəyiş"
annotationMode = "Annotasiya rejimini dəyiş"
print = "PDF-i çap et"
draw = "Rəsm çək"
save = "Yadda saxla"
saveChanges = "Dəyişiklikləri yadda saxla"
@@ -4410,7 +4515,7 @@ description = "Daha geniş sistem müvəqqəti qovluğunu təmizləyib-təmizlə
label = "Proses İcraedicisi Limitləri"
description = "Hər icraedici üçün sessiya limitlərini və taym-outları konfiqurasiya edin"
libreOffice = "LibreOffice"
pdfToHtml = "PDF to HTML"
pdfToHtml = "PDF-dən HTML"
qpdf = "QPDF"
tesseract = "Tesseract OCR"
pythonOpenCv = "Python OpenCV"
@@ -4497,6 +4602,7 @@ description = "Impressum üçün URL və ya fayl adı (bəzi yurisdiksiyalarda t
title = "Premium və Enterprise"
description = "Premium və ya enterprise lisenziya açarınızı konfiqurasiya edin."
license = "Lisenziya Konfiqurasiyası"
noInput = "Zəhmət olmasa lisenziya açarı və ya fayl təqdim edin"
[admin.settings.premium.licenseKey]
toggle = "Lisenziya açarınız və ya sertifikat faylınız var?"
@@ -4514,6 +4620,25 @@ line1 = "Cari lisenziya açarının üzərinə yazmaq geri alına bilməz."
line2 = "Ehtiyat nüsxəsi yoxdursa, əvvəlki lisenziyanız birdəfəlik itəcək."
line3 = "Vacibdir: Lisenziya açarlarını məxfi və təhlükəsiz saxlayın. Heç vaxt onları ictimai paylaşmayın."
[admin.settings.premium.inputMethod]
text = "Lisenziya açarı"
file = "Sertifikat faylı"
[admin.settings.premium.file]
label = "Lisenziya sertifikat faylı"
description = "Oflayn alışdan əldə etdiyiniz .lic və ya .cert lisenziya faylını yükləyin"
choose = "Lisenziya faylını seçin"
selected = "Seçildi: {{filename}} ({{size}})"
successMessage = "Lisenziya faylı uğurla yüklənib və aktivləşdirilib. Yenidən başlatmağa ehtiyac yoxdur."
[admin.settings.premium.currentLicense]
title = "Aktiv lisenziya"
file = "Mənbə: Lisenziya faylı ({{path}})"
key = "Mənbə: Lisenziya açarı"
type = "Növ: {{type}}"
noInput = "Zəhmət olmasa lisenziya açarı verin və ya sertifikat faylı yükləyin"
success = "Uğurlu"
[admin.settings.premium.enabled]
label = "Premium Xüsusiyyətlərini aktiv et"
description = "Pro/enterprise xüsusiyyətləri üçün lisenziya açarı yoxlamalarını aktiv et"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} seçildi"
download = "Endir"
delete = "Sil"
unsupported = "Dəstəklənmir"
active = "Aktiv"
addToUpload = "Yükləməyə əlavə et"
closeFile = "Faylı bağla"
deleteAll = "Hamısını sil"
loadingFiles = "Fayllar yüklənir..."
noFiles = "Fayl mövcud deyil"
@@ -5135,7 +5262,7 @@ upgrade = "İndi yüksəlt →"
freeTitle = "Server lisenziyası"
overLimitTitle = "Server lisenziyası tələb olunur"
overLimitBody = "Lisenziyalaşmamız hər server üçün pulsuz olaraq maksimum <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Sizdə <strong>{{overLimitUserCopy}}</strong> Stirling istifadəçisi var. Fasiləsiz davam etmək üçün Stirling Server planına yüksəldin - <strong>limitsiz yerlər</strong>, PDF mətn redaktəsi və tam admin nəzarəti cəmi $99/server/ay."
freeBody = "Bizim <strong>Open-Core</strong> lisenziyası hər server üçün pulsuz olaraq maksimum <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Fasiləsiz miqyaslanmaq və yeni <strong>PDF mətn redaktəsi alətimizə</strong> erkən çıxış əldə etmək üçün Stirling Server planını tövsiyə edirik — tam redaktə və <strong>limitsiz yerlər</strong> $99/server/ay."
freeBody = "Bizim <strong>Open-Core</strong> lisenziyalaşdırmamız hər server üçün pulsuz olaraq ən çox <strong>{{freeTierLimit}}</strong> istifadəçiyə icazə verir. Fasiləsiz miqyaslama üçün Stirling Server planını tövsiyə edirik - <strong>limitsiz yerlər</strong> və <strong>SSO dəstəyi</strong> $99/server/ay."
[onboarding.desktopInstall]
title = "Yüklə"
@@ -5240,6 +5367,31 @@ error = "İstifadəçi statusunu yeniləmək alınmadı"
success = "İstifadəçi uğurla silindi"
error = "İstifadəçini silmək alınmadı"
[workspace.people.changePassword]
action = "Parolu dəyiş"
title = "Parolu dəyiş"
subtitle = "Aşağıdakı istifadəçi üçün parolu yeniləyin"
newPassword = "Yeni parol"
confirmPassword = "Parolu təsdiq edin"
placeholder = "Yeni parolu daxil edin"
confirmPlaceholder = "Yeni parolu yenidən daxil edin"
passwordRequired = "Zəhmət olmasa yeni parolu daxil edin"
passwordMismatch = "Parollar uyğun gəlmir"
generateRandom = "Təhlükəsiz parol yaradın"
generatedPreview = "Yaradılmış parol:"
copyTooltip = "Buferə kopyala"
copiedToClipboard = "Parol buferə kopyalandı"
copyFailed = "Parolu kopyalamaq alınmadı"
sendEmail = "Bu dəyişiklik barədə istifadəçiyə e-poçt göndərin"
includePassword = "E-poçta yeni parolu daxil edin"
forcePasswordChange = "Növbəti girişdə istifadəçini parolu dəyişməyə məcbur et"
emailUnavailable = "Bu istifadəçinin e-poçtu etibarlı e-poçt ünvanı deyil. Bildirişlər söndürülüb."
smtpDisabled = "E-poçt bildirişləri üçün parametrlərdə SMTP aktiv olmalıdır."
notifyOnly = "Parol olmadan e-poçt göndəriləcək; istifadəçiyə adminin onu dəyişdiyi bildiriləcək."
submit = "Parolu yenilə"
success = "Parol uğurla yeniləndi"
error = "Parolu yeniləmək alınmadı"
[workspace.people.emailInvite]
tab = "E-poçt Dəvəti"
description = "Aşağıya vergüllə ayrılmış e-poçtları yazın və ya yapışdırın. İstifadəçilərə giriş məlumatları e-poçtla göndəriləcək."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Ən azı bir e-poçt ünvanı tələb olunur"
submit = "Dəvətnamələri göndər"
success = "istifadəçi(lər) uğurla dəvət olundu"
partialSuccess = "Bəzi dəvətnamələr alınmadı"
partialFailure = "Bəzi dəvətlər uğursuz oldu"
allFailed = "İstifadəçiləri dəvət etmək alınmadı"
error = "Dəvətnamələri göndərmək alınmadı"
@@ -5291,8 +5443,8 @@ emailDisabled = "E-poçt dəvətləri üçün ayarlarda SMTP konfiqurasiyası v
[workspace.people.license]
users = "istifadəçi"
availableSlots = "Mövcud yerlər"
grandfathered = "Grandfathered"
grandfatheredShort = "{{count}} grandfathered"
grandfathered = "Əvvəlki şərtlərlə"
grandfatheredShort = "{{count}} əvvəlki şərtlərlə"
fromLicense = "lisenziyadan"
slotsAvailable = "{{count}} istifadəçi yeri mövcuddur"
noSlotsAvailable = "Mövcud yer yoxdur"
@@ -5773,6 +5925,7 @@ subtitle = "Stirling hesabınızla daxil olun"
[setup.selfhosted]
title = "Serverə daxil olun"
subtitle = "Server məlumatlarınızı daxil edin"
link = "və ya self-hosted hesaba qoşulun"
[setup.server]
title = "Serverə qoşulun"
@@ -5791,6 +5944,14 @@ description = "Öz Stirling PDF serverinizin tam URL ünvanını daxil edin"
emptyUrl = "Zəhmət olmasa server URL-i daxil edin"
unreachable = "Serverə qoşulmaq mümkün olmadı"
testFailed = "Bağlantı testi uğursuz oldu"
configFetch = "Server konfiqurasiyasını əldə etmək mümkün olmadı. URL-i yoxlayın və yenidən cəhd edin."
[setup.server.error.securityDisabled]
title = "Giriş aktiv deyil"
body = "Bu serverdə giriş aktiv deyil. Bu serverə qoşulmaq üçün autentifikasiya aktiv edilməlidir:"
step1 = "Mühitinizdə DOCKER_ENABLE_SECURITY=true təyin edin"
step2 = "Yaxud settings.yml faylında security.enableLogin=true təyin edin"
step3 = "Serveri yenidən başladın"
[setup.login]
title = "Daxil ol"
@@ -5800,6 +5961,13 @@ submit = "Daxil ol"
signInWith = "Bununla daxil ol"
oauthPending = "Təsdiqləmə üçün brauzer açılır..."
orContinueWith = "Və ya e-poçt ilə davam edin"
serverRequirement = "Qeyd: Serverdə giriş funksiyası aktiv olmalıdır."
showInstructions = "Necə aktivləşdirmək olar?"
hideInstructions = "Təlimatları gizlət"
instructions = "Stirling PDF serverinizdə girişi aktivləşdirmək üçün:"
instructionsEnvVar = "Mühit dəyişənini təyin edin:"
instructionsOrYml = "Və ya settings.yml faylında:"
instructionsRestart = "Dəyişikliklərin qüvvəyə minməsi üçün serveri yenidən başladın."
[setup.login.username]
label = "İstifadəçi adı"
@@ -5856,6 +6024,7 @@ earlyAccess = "Erkən Giriş"
reset = "Dəyişiklikləri sıfırla"
downloadJson = "JSON-u endir"
generatePdf = "PDF yarat"
saveChanges = "Dəyişiklikləri yadda saxla"
[pdfTextEditor.options.autoScaleText]
title = "Mətni avtomatik miqyasla"
@@ -5893,6 +6062,8 @@ alpha = "Bu alfa görüntüləyici hələ inkişaf edir—bəzi şriftlər, rən
[pdfTextEditor.empty]
title = "Sənəd yüklənməyib"
subtitle = "Mətn məzmununu redaktə etməyə başlamaq üçün PDF və ya JSON faylı yükləyin."
dropzone = "Buraya PDF və ya JSON faylını sürükləyib buraxın və ya baxmaq üçün klikləyin"
dropzoneWithFiles = "Fayllar vərəqindən bir fayl seçin və ya buraya PDF və ya JSON faylını sürükləyib buraxın, yaxud baxmaq üçün klikləyin"
[pdfTextEditor.welcomeBanner]
title = "PDF Text Editor-ə xoş gəldiniz (Erkən Giriş)"
+178 -7
View File
@@ -163,6 +163,11 @@ unfavorite = "Премахване от любими"
fullscreen = "Превключване към режим на цял екран"
sidebar = "Превключване към режим със странична лента"
[backendStartup]
notFoundTitle = "Бекендът не е намерен"
retry = "Опитай отново"
unreachable = "Приложението в момента не може да се свърже с бекенда. Проверете състоянието на бекенда и мрежовата свързаност, след което опитайте отново."
[zipWarning]
title = "Голям ZIP файл"
message = "Този ZIP съдържа {{count}} файла. Да се извлече въпреки това?"
@@ -913,8 +918,8 @@ desc = "Наслагва PDF файлове върху друг PDF"
title = "Наслагване PDF-и"
[home.pdfTextEditor]
title = "PDF текстов редактор"
desc = "Преглеждайте и редактирайте JSON експорти на Stirling PDF с групово редактиране на текст и повторно генериране на PDF"
title = "Редактор на текст в PDF"
desc = "Редактирайте съществуващ текст и изображения в PDF файлове"
[home.addText]
tags = "текст,анотация,етикет"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Нарисуван подпис"
defaultImageLabel = "Качен подпис"
defaultTextLabel = "Въведен подпис"
saveButton = "Запази подписа"
savePersonal = "Запази като личен"
saveShared = "Запази като споделен"
saveUnavailable = "Първо създайте подпис, за да го запазите."
noChanges = "Текущият подпис вече е запазен."
tempStorageTitle = "Временно съхранение в браузъра"
tempStorageDescription = "Подписите се съхраняват само във вашия браузър. Ще бъдат загубени, ако изчистите данните на браузъра или смените браузър."
personalHeading = "Лични подписи"
sharedHeading = "Споделени подписи"
personalDescription = "Само вие можете да виждате тези подписи."
sharedDescription = "Всички потребители могат да виждат и използват тези подписи."
[sign.saved.type]
canvas = "Рисунка"
@@ -3023,6 +3036,91 @@ title = "Вземете информация за PDF"
header = "Вземете информация за PDF"
submit = "Вземете информация"
downloadJson = "Изтеглете JSON"
processing = "Извличане на информация..."
results = "Резултати"
noResults = "Стартирайте инструмента, за да генерирате отчет."
downloads = "Изтегляния"
noneDetected = "Нищо не е открито"
indexTitle = "Индекс"
[getPdfInfo.report]
entryLabel = "Пълно резюме на информацията"
shortTitle = "Информация за PDF"
[getPdfInfo.sections]
metadata = "Метаданни"
formFields = "Полета на формуляра"
basicInfo = "Основна информация"
documentInfo = "Информация за документа"
compliance = "Съответствие"
encryption = "Шифриране"
permissions = "Разрешения"
other = "Друго"
perPageInfo = "Информация по страници"
tableOfContents = "Съдържание"
[getPdfInfo.other]
attachments = "Прикачени файлове"
embeddedFiles = "Вградени файлове"
javaScript = "JavaScript"
layers = "Слоеве"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Размер"
annotations = "Анотации"
images = "Изображения"
links = "Връзки"
fonts = "Шрифтове"
xobjects = "Брой XObject"
multimedia = "Мултимедия"
[getPdfInfo.summary]
pages = "Страници"
fileSize = "Размер на файла"
pdfVersion = "Версия на PDF"
language = "Език"
title = "Обобщение на PDF"
author = "Автор"
created = "Създаден"
modified = "Променен"
permsAll = "Всички разрешения са позволени"
permsRestricted = "{{count}} ограничения"
permsMixed = "Някои разрешения са ограничени"
hasCompliance = "Има стандарти за съответствие"
noCompliance = "Няма стандарти за съответствие"
basic = "Основна информация"
documentInfo = "Информация за документа"
securityTitle = "Състояние на сигурността"
technical = "Технически"
overviewTitle = "Преглед на PDF"
[getPdfInfo.summary.security]
encrypted = "Шифриран PDF - налична защита с парола"
unencrypted = "Нешифриран PDF - няма защита с парола"
[getPdfInfo.summary.tech]
images = "Изображения"
fonts = "Шрифтове"
formFields = "Полета на формуляра"
embeddedFiles = "Вградени файлове"
javaScript = "JavaScript"
layers = "Слоеве"
bookmarks = "Отметки"
multimedia = "Мултимедия"
[getPdfInfo.summary.overview]
untitled = "неозаглавен документ"
unknown = "Неизвестен автор"
text = "Това е {{pages}}-страничен PDF със заглавие {{title}}, създаден от {{author}} (версия на PDF {{version}})."
[getPdfInfo.error]
partial = "Някои файлове не можаха да бъдат обработени."
unexpected = "Неочаквана грешка по време на извличане."
[getPdfInfo.status]
complete = "Извличането е завършено"
[extractPage]
tags = "извличане"
@@ -3441,6 +3539,9 @@ signinTitle = "Моля впишете се"
ssoSignIn = "Влизане чрез еднократно влизане"
oAuth2AutoCreateDisabled = "OAUTH2 Автоматично създаване на потребител е деактивирано"
oAuth2AdminBlockedUser = "Регистрацията или влизането на нерегистрирани потребители в момента е блокирано. Моля, свържете се с администратора."
oAuth2RequiresLicense = "Вход с OAuth/SSO изисква платен лиценз (Server или Enterprise). Моля, свържете се с администратора, за да надстроите плана си."
saml2RequiresLicense = "Вход със SAML изисква платен лиценз (Server или Enterprise). Моля, свържете се с администратора, за да надстроите плана си."
maxUsersReached = "Достигнат е максималният брой потребители за текущия ви лиценз. Моля, свържете се с администратора, за да надстроите плана си или да добавите още места."
oauth2RequestNotFound = "Заявката за оторизация не е намерена"
oauth2InvalidUserInfoResponse = "Невалидна информация за потребителя"
oauth2invalidRequest = "Невалидна заявка"
@@ -3849,14 +3950,17 @@ fitToWidth = "Побиране по ширина"
actualSize = "Действителен размер"
[viewer]
cannotPreviewFile = "Не може да се визуализира файлът"
dualPageView = "Изглед: две страници"
firstPage = "Първа страница"
lastPage = "Последна страница"
previousPage = "Предишна страница"
nextPage = "Следваща страница"
onlyPdfSupported = "Прегледачът поддържа само PDF файлове. Този файл изглежда е в друг формат."
previousPage = "Предишна страница"
singlePageView = "Изглед: една страница"
unknownFile = "Непознат файл"
zoomIn = "Увеличи"
zoomOut = "Намали"
singlePageView = "Изглед: една страница"
dualPageView = "Изглед: две страници"
[rightRail]
closeSelected = "Затвори избраните файлове"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Показване/скриване на страничната
exportSelected = "Експорт на избраните страници"
toggleAnnotations = "Показване/скриване на анотациите"
annotationMode = "Превключи режим на анотации"
print = "Печат на PDF"
draw = "Рисуване"
save = "Запази"
saveChanges = "Запази промените"
@@ -4497,6 +4602,7 @@ description = "URL или име на файл към импресум (задъ
title = "Премиум и Enterprise"
description = "Конфигурирайте вашия премиум или enterprise лицензионен ключ."
license = "Конфигурация на лиценз"
noInput = "Моля, предоставете лицензен ключ или файл"
[admin.settings.premium.licenseKey]
toggle = "Имате лицензен ключ или сертификат?"
@@ -4514,6 +4620,25 @@ line1 = "Презаписването на текущия лицензен кл
line2 = "Предишният лиценз ще бъде окончателно загубен, освен ако не сте го архивирали другаде."
line3 = "Важно: Пазете лицензните ключове поверителни и сигурни. Никога не ги споделяйте публично."
[admin.settings.premium.inputMethod]
text = "Лицензен ключ"
file = "Файл със сертификат"
[admin.settings.premium.file]
label = "Файл с лицензен сертификат"
description = "Качете вашия .lic или .cert лицензен файл от офлайн покупки"
choose = "Изберете лицензен файл"
selected = "Избрано: {{filename}} ({{size}})"
successMessage = "Лицензният файл беше качен и активиран успешно. Не е необходимо рестартиране."
[admin.settings.premium.currentLicense]
title = "Активен лиценз"
file = "Източник: Лицензен файл ({{path}})"
key = "Източник: Лицензен ключ"
type = "Тип: {{type}}"
noInput = "Моля, предоставете лицензен ключ или качете файл със сертификат"
success = "Успешно"
[admin.settings.premium.enabled]
label = "Активирай премиум функции"
description = "Активира проверки на лицензионния ключ за pro/enterprise функции"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} избрани"
download = "Изтегли"
delete = "Изтрий"
unsupported = "Неподдържано"
active = "Активен"
addToUpload = "Добави към качването"
closeFile = "Затвори файла"
deleteAll = "Изтрий всички"
loadingFiles = "Зареждане на файлове..."
noFiles = "Няма налични файлове"
@@ -5135,7 +5262,7 @@ upgrade = "Надградете сега →"
freeTitle = "Лиценз за сървър"
overLimitTitle = "Необходим е лиценз за сървър"
overLimitBody = "Нашият лиценз позволява до <strong>{{freeTierLimit}}</strong> безплатни потребители на сървър. Имате <strong>{{overLimitUserCopy}}</strong> потребители на Stirling. За да продължите без прекъсвания, надградете до плана Stirling Server <strong>неограничени места</strong>, редакция на PDF текст и пълен админ контрол за $99/сървър/месец."
freeBody = "Нашият <strong>Open-Core</strong> лиценз позволява до <strong>{{freeTierLimit}}</strong> безплатни потребители на сървър. За да мащабирате без прекъсвания и да получите ранен достъп до нашия нов <strong>инструмент за редакция на PDF текст</strong>, препоръчваме плана Stirling Server – пълно редактиране и <strong>неограничени места</strong> за $99/сървър/месец."
freeBody = "Нашият лицензен модел <strong>Open-Core</strong> позволява до <strong>{{freeTierLimit}}</strong> потребители безплатно на сървър. За безпрепятствено мащабиране препоръчваме плана Stirling Server - <strong>неограничени места</strong> и <strong>поддръжка на SSO</strong> за $99/server/mo."
[onboarding.desktopInstall]
title = "Изтегляне"
@@ -5240,6 +5367,31 @@ error = "Неуспешно обновяване на статус на потр
success = "Потребителят е изтрит успешно"
error = "Неуспешно изтриване на потребител"
[workspace.people.changePassword]
action = "Промяна на парола"
title = "Промяна на парола"
subtitle = "Актуализирайте паролата за"
newPassword = "Нова парола"
confirmPassword = "Потвърдете паролата"
placeholder = "Въведете нова парола"
confirmPlaceholder = "Въведете отново новата парола"
passwordRequired = "Моля, въведете нова парола"
passwordMismatch = "Паролите не съвпадат"
generateRandom = "Генерирайте сигурна парола"
generatedPreview = "Генерирана парола:"
copyTooltip = "Копиране в клипборда"
copiedToClipboard = "Паролата е копирана в клипборда"
copyFailed = "Неуспешно копиране на паролата"
sendEmail = "Изпратете имейл на потребителя за тази промяна"
includePassword = "Включете новата парола в имейла"
forcePasswordChange = "Принудете потребителя да смени паролата при следващо влизане"
emailUnavailable = "Имейлът на този потребител не е валиден адрес. Известията са изключени."
smtpDisabled = "Имейл известията изискват SMTP да е активиран в настройките."
notifyOnly = "Ще бъде изпратен имейл без паролата, за да уведоми потребителя, че администратор я е променил."
submit = "Актуализиране на паролата"
success = "Паролата е актуализирана успешно"
error = "Неуспешно актуализиране на паролата"
[workspace.people.emailInvite]
tab = "Покана по имейл"
description = "Въведете или поставете имейли по-долу, разделени със запетаи. Потребителите ще получат данни за вход по имейл."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Изисква се поне един имейл адрес"
submit = "Изпрати покани"
success = "Потребител(и) поканени успешно"
partialSuccess = "Някои покани не успяха"
partialFailure = "Някои покани бяха неуспешни"
allFailed = "Неуспешно канене на потребители"
error = "Неуспешно изпращане на покани"
@@ -5773,6 +5925,7 @@ subtitle = "Впишете се с вашия Stirling акаунт"
[setup.selfhosted]
title = "Впишете се в сървъра"
subtitle = "Въведете своите данни за сървъра"
link = "или се свържете със самостоятелно хостван акаунт"
[setup.server]
title = "Свързване към сървър"
@@ -5791,6 +5944,14 @@ description = "Въведете пълния URL на вашия самосто
emptyUrl = "Моля, въведете URL на сървър"
unreachable = "Неуспешна връзка със сървъра"
testFailed = "Тестът на връзката е неуспешен"
configFetch = "Неуспешно извличане на конфигурацията на сървъра. Моля, проверете URL адреса и опитайте отново."
[setup.server.error.securityDisabled]
title = "Входът не е активиран"
body = "На този сървър не е активиран вход. За да се свържете, трябва да активирате удостоверяване:"
step1 = "Задайте DOCKER_ENABLE_SECURITY=true във вашата среда"
step2 = "Или задайте security.enableLogin=true в settings.yml"
step3 = "Рестартирайте сървъра"
[setup.login]
title = "Вписване"
@@ -5800,6 +5961,13 @@ submit = "Вход"
signInWith = "Вписване с"
oauthPending = "Отваряне на браузър за удостоверяване..."
orContinueWith = "Или продължете с имейл"
serverRequirement = "Забележка: Сървърът трябва да има активиран вход."
showInstructions = "Как да се активира?"
hideInstructions = "Скрий инструкциите"
instructions = "За да активирате вход на вашия Stirling PDF сървър:"
instructionsEnvVar = "Задайте променливата на средата:"
instructionsOrYml = "Или в settings.yml:"
instructionsRestart = "След това рестартирайте сървъра, за да влязат промените в сила."
[setup.login.username]
label = "Потребителско име"
@@ -5856,6 +6024,7 @@ earlyAccess = "Ранен достъп"
reset = "Отмени промените"
downloadJson = "Изтегли JSON"
generatePdf = "Генерирай PDF"
saveChanges = "Запази промените"
[pdfTextEditor.options.autoScaleText]
title = "Авто-мащабиране на текст за напасване в полетата"
@@ -5893,6 +6062,8 @@ alpha = "Този алфа визуализатор все още се разв
[pdfTextEditor.empty]
title = "Няма зареден документ"
subtitle = "Заредете PDF или JSON файл, за да започнете да редактирате текстовото съдържание."
dropzone = "Плъзнете и пуснете тук PDF или JSON файл или щракнете, за да прегледате"
dropzoneWithFiles = "Изберете файл от раздела Файлове или плъзнете и пуснете тук PDF или JSON файл, или щракнете, за да прегледате"
[pdfTextEditor.welcomeBanner]
title = "Добре дошли в PDF Text Editor (ранен достъп)"
+182 -11
View File
@@ -163,6 +163,11 @@ unfavorite = "Elimina dels preferits"
fullscreen = "Canvia al mode de pantalla completa"
sidebar = "Canvia al mode de barra lateral"
[backendStartup]
notFoundTitle = "Backend no trobat"
retry = "Torneu-ho a intentar"
unreachable = "L'aplicació no pot connectar-se al backend ara mateix. Verifiqueu l'estat del backend i la connectivitat de xarxa i torneu-ho a intentar."
[zipWarning]
title = "Fitxer ZIP gran"
message = "Aquest ZIP conté {{count}} fitxers. Vols extreure'l igualment?"
@@ -347,7 +352,7 @@ teams = "Equips"
title = "Configuració"
systemSettings = "Configuració del sistema"
features = "Funcions"
endpoints = "Endpoints"
endpoints = "Punts finals"
database = "Base de dades"
advanced = "Avançat"
@@ -556,7 +561,7 @@ totalEndpoints = "Total d'endpoints"
totalVisits = "Total de visites"
showing = "Mostrant"
selectedVisits = "Visites seleccionades"
endpoint = "Endpoint"
endpoint = "Punt final"
visits = "Visites"
percentage = "Percentatge"
loading = "Carregant..."
@@ -914,7 +919,7 @@ title = "Superposar PDFs"
[home.pdfTextEditor]
title = "Editor de text PDF"
desc = "Revisa i edita exportacions JSON de Stirling PDF amb edició de text agrupada i regeneració del PDF"
desc = "Edita el text i les imatges existents dins dels PDF"
[home.addText]
tags = "text,anotació,etiqueta"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Signatura dibuixada"
defaultImageLabel = "Signatura pujada"
defaultTextLabel = "Signatura teclejada"
saveButton = "Desa la signatura"
savePersonal = "Desa com a personal"
saveShared = "Desa com a compartida"
saveUnavailable = "Crea una signatura primer per poder-la desar."
noChanges = "La signatura actual ja està desada."
tempStorageTitle = "Emmagatzematge temporal del navegador"
tempStorageDescription = "Les signatures només s'emmagatzemen al vostre navegador. Es perdran si netegeu les dades del navegador o canvieu de navegador."
personalHeading = "Signatures personals"
sharedHeading = "Signatures compartides"
personalDescription = "Només vosaltres podeu veure aquestes signatures."
sharedDescription = "Tots els usuaris poden veure i utilitzar aquestes signatures."
[sign.saved.type]
canvas = "Dibuix"
@@ -3023,6 +3036,91 @@ title = "Obteniu Informació del PDF"
header = "Obteniu Informació del PDF"
submit = "Obteniu Informació"
downloadJson = "Descarrega JSON"
processing = "Extraient informació..."
results = "Resultats"
noResults = "Executeu l'eina per generar un informe."
downloads = "Descàrregues"
noneDetected = "No se n'ha detectat cap"
indexTitle = "Índex"
[getPdfInfo.report]
entryLabel = "Resum d'informació complet"
shortTitle = "Informació del PDF"
[getPdfInfo.sections]
metadata = "Metadades"
formFields = "Camps de formulari"
basicInfo = "Informació bàsica"
documentInfo = "Informació del document"
compliance = "Conformitat"
encryption = "Xifratge"
permissions = "Permisos"
other = "Altres"
perPageInfo = "Informació per pàgina"
tableOfContents = "Taula de continguts"
[getPdfInfo.other]
attachments = "Fitxers adjunts"
embeddedFiles = "Fitxers incrustats"
javaScript = "JavaScript"
layers = "Capes"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Mida"
annotations = "Anotacions"
images = "Imatges"
links = "Enllaços"
fonts = "Tipus de lletra"
xobjects = "Recompte d'XObject"
multimedia = "Multimèdia"
[getPdfInfo.summary]
pages = "Pàgines"
fileSize = "Mida del fitxer"
pdfVersion = "Versió del PDF"
language = "Idioma"
title = "Resum del PDF"
author = "Autor"
created = "Creat"
modified = "Modificat"
permsAll = "Tots els permisos permesos"
permsRestricted = "{{count}} restriccions"
permsMixed = "Alguns permisos restringits"
hasCompliance = "Té estàndards de conformitat"
noCompliance = "Sense estàndards de conformitat"
basic = "Informació bàsica"
documentInfo = "Informació del document"
securityTitle = "Estat de seguretat"
technical = "Tècnic"
overviewTitle = "Visió general del PDF"
[getPdfInfo.summary.security]
encrypted = "PDF xifrat - Protecció amb contrasenya present"
unencrypted = "PDF no xifrat - Sense protecció amb contrasenya"
[getPdfInfo.summary.tech]
images = "Imatges"
fonts = "Tipus de lletra"
formFields = "Camps de formulari"
embeddedFiles = "Fitxers incrustats"
javaScript = "JavaScript"
layers = "Capes"
bookmarks = "Marcadors"
multimedia = "Multimèdia"
[getPdfInfo.summary.overview]
untitled = "un document sense títol"
unknown = "Autor desconegut"
text = "Aquest és un PDF de {{pages}} pàgines titulat {{title}} creat per {{author}} (versió del PDF {{version}})."
[getPdfInfo.error]
partial = "Alguns fitxers no s'han pogut processar."
unexpected = "Error inesperat durant l'extracció."
[getPdfInfo.status]
complete = "Extracció completada"
[extractPage]
tags = "extreure"
@@ -3441,6 +3539,9 @@ signinTitle = "Autenticat"
ssoSignIn = "Inicia sessió mitjançant inici de sessió únic"
oAuth2AutoCreateDisabled = "La creació automàtica d'usuaris OAUTH2 està desactivada"
oAuth2AdminBlockedUser = "El registre o inici de sessió d'usuaris no registrats està actualment bloquejat. Si us plau, contacta amb l'administrador."
oAuth2RequiresLicense = "L'inici de sessió OAuth/SSO requereix una llicència de pagament (Server o Enterprise). Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla."
saml2RequiresLicense = "L'inici de sessió SAML requereix una llicència de pagament (Server o Enterprise). Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla."
maxUsersReached = "S'ha assolit el nombre màxim d'usuaris de la vostra llicència actual. Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla o afegir més places."
oauth2RequestNotFound = "Sol·licitud d'autorització no trobada"
oauth2InvalidUserInfoResponse = "Resposta d'informació d'usuari no vàlida"
oauth2invalidRequest = "Sol·licitud no vàlida"
@@ -3849,14 +3950,17 @@ fitToWidth = "Ajusta a l'amplada"
actualSize = "Mida real"
[viewer]
cannotPreviewFile = "No es pot previsualitzar el fitxer"
dualPageView = "Vista de dues pàgines"
firstPage = "Primera pàgina"
lastPage = "Última pàgina"
previousPage = "Pàgina anterior"
nextPage = "Pàgina següent"
onlyPdfSupported = "El visualitzador només admet fitxers PDF. Aquest fitxer sembla ser d'un format diferent."
previousPage = "Pàgina anterior"
singlePageView = "Vista d'una sola pàgina"
unknownFile = "Fitxer desconegut"
zoomIn = "Amplia"
zoomOut = "Redueix"
singlePageView = "Vista d'una sola pàgina"
dualPageView = "Vista de dues pàgines"
[rightRail]
closeSelected = "Tanca els fitxers seleccionats"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Mostra/oculta la barra lateral"
exportSelected = "Exporta les pàgines seleccionades"
toggleAnnotations = "Mostra/oculta les anotacions"
annotationMode = "Activa/desactiva el mode d'anotació"
print = "Imprimeix el PDF"
draw = "Dibuixa"
save = "Desa"
saveChanges = "Desa els canvis"
@@ -3928,7 +4033,7 @@ files = "Fitxers"
activity = "Registre"
help = "Ajuda"
account = "Compte"
config = "Config"
config = "Configuració"
settings = "Ajustos"
adminSettings = "Ajustos admin"
allTools = "All Tools"
@@ -4346,7 +4451,7 @@ features = "Banderes de funcions"
processing = "Processament"
[admin.settings.advanced.endpoints]
label = "Endpoints"
label = "Punts finals"
manage = "Gestiona els endpoints de l'API"
description = "La gestió d'endpoints es configura via YAML. Consulteu la documentació per a detalls sobre com habilitar/deshabilitar endpoints específics."
@@ -4497,6 +4602,7 @@ description = "URL o nom de fitxer de l'impressum (requerit en algunes jurisdicc
title = "Premium i Enterprise"
description = "Configureu la clau de llicència Premium o Enterprise."
license = "Configuració de llicència"
noInput = "Proporcioneu una clau de llicència o un fitxer"
[admin.settings.premium.licenseKey]
toggle = "Tens una clau de llicència o un fitxer de certificat?"
@@ -4514,6 +4620,25 @@ line1 = "Sobreescriure la clau de llicència actual no es pot desfer."
line2 = "La llicència anterior es perdrà permanentment si no en tens una còpia de seguretat."
line3 = "Important: mantén les claus de llicència privades i segures. No les comparteixis mai públicament."
[admin.settings.premium.inputMethod]
text = "Clau de llicència"
file = "Fitxer de certificat"
[admin.settings.premium.file]
label = "Fitxer de certificat de llicència"
description = "Pugeu el vostre fitxer de llicència .lic o .cert de compres fora de línia"
choose = "Trieu el fitxer de llicència"
selected = "Seleccionat: {{filename}} ({{size}})"
successMessage = "Fitxer de llicència pujat i activat correctament. No cal reiniciar."
[admin.settings.premium.currentLicense]
title = "Llicència activa"
file = "Origen: Fitxer de llicència ({{path}})"
key = "Origen: Clau de llicència"
type = "Tipus: {{type}}"
noInput = "Proporcioneu una clau de llicència o pugeu un fitxer de certificat"
success = "Èxit"
[admin.settings.premium.enabled]
label = "Habilita les funcions Premium"
description = "Habilita les comprovacions de clau per a funcions pro/enterprise"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} seleccionats"
download = "Descarrega"
delete = "Esborra"
unsupported = "No compatible"
active = "Actiu"
addToUpload = "Afegeix a la pujada"
closeFile = "Tanca el fitxer"
deleteAll = "Suprimeix-ho tot"
loadingFiles = "Carregant fitxers..."
noFiles = "No hi ha fitxers disponibles"
@@ -5135,7 +5262,7 @@ upgrade = "Actualitza ara →"
freeTitle = "Llicència del servidor"
overLimitTitle = "Cal una llicència de servidor"
overLimitBody = "La nostra llicència permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Tens <strong>{{overLimitUserCopy}}</strong> usuaris de Stirling. Per continuar sense interrupcions, actualitza al pla Stirling Server: <strong>seients il·limitats</strong>, edició de text de PDF i control d'administració complet per 99 $/servidor/mes."
freeBody = "La nostra llicència <strong>Open-Core</strong> permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Per escalar sense interrupcions i obtenir accés anticipat a la nova <strong>eina d'edició de text PDF</strong>, recomanem el pla Stirling Server: edició completa i <strong>seients il·limitats</strong> per 99 $/servidor/mes."
freeBody = "La nostra llicència <strong>Open-Core</strong> permet fins a <strong>{{freeTierLimit}}</strong> usuaris gratuïts per servidor. Per escalar sense interrupcions, recomanem el pla Stirling Server - <strong>places il·limitades</strong> i <strong>suport SSO</strong> per $99/servidor/mes."
[onboarding.desktopInstall]
title = "Baixa"
@@ -5240,6 +5367,31 @@ error = "No sha pogut actualitzar lestat de lusuari"
success = "Usuari suprimit correctament"
error = "No sha pogut suprimir lusuari"
[workspace.people.changePassword]
action = "Canvieu la contrasenya"
title = "Canvi de contrasenya"
subtitle = "Actualitza la contrasenya de"
newPassword = "Contrasenya nova"
confirmPassword = "Confirma la contrasenya"
placeholder = "Introduïu una contrasenya nova"
confirmPlaceholder = "Torneu a introduir la contrasenya nova"
passwordRequired = "Introduïu una contrasenya nova"
passwordMismatch = "Les contrasenyes no coincideixen"
generateRandom = "Genereu una contrasenya segura"
generatedPreview = "Contrasenya generada:"
copyTooltip = "Copieu al portapapers"
copiedToClipboard = "Contrasenya copiada al portapapers"
copyFailed = "No s'ha pogut copiar la contrasenya"
sendEmail = "Envieu un correu a l'usuari sobre aquest canvi"
includePassword = "Incloeu la contrasenya nova al correu"
forcePasswordChange = "Obligueu l'usuari a canviar la contrasenya en el pròxim inici de sessió"
emailUnavailable = "El correu d'aquest usuari no és una adreça de correu vàlida. Les notificacions estan desactivades."
smtpDisabled = "Les notificacions per correu electrònic requereixen habilitar SMTP als paràmetres."
notifyOnly = "S'enviarà un correu sense la contrasenya, informant l'usuari que un administrador l'ha canviada."
submit = "Actualitzeu la contrasenya"
success = "La contrasenya s'ha actualitzat correctament"
error = "No s'ha pogut actualitzar la contrasenya"
[workspace.people.emailInvite]
tab = "Invitació per correu"
description = "Escriviu o enganxeu correus a continuació, separats per comes. Els usuaris rebran credencials dinici de sessió per correu electrònic."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Cal almenys una adreça de correu"
submit = "Envia invitacions"
success = "usuari(s) convidat(s) correctament"
partialSuccess = "Algunes invitacions han fallat"
partialFailure = "Algunes invitacions han fallat"
allFailed = "No sha pogut convidar els usuaris"
error = "No shan pogut enviar les invitacions"
@@ -5712,7 +5864,7 @@ title = "Gràfic d'ús dels endpoints"
[usage.table]
title = "Estadístiques detallades"
endpoint = "Endpoint"
endpoint = "Punt final"
visits = "Visites"
percentage = "Percentatge"
noData = "No hi ha dades disponibles"
@@ -5773,6 +5925,7 @@ subtitle = "Inicia sessió amb el teu compte de Stirling"
[setup.selfhosted]
title = "Inicia sessió al servidor"
subtitle = "Introdueix les credencials del servidor"
link = "o connecteu-vos a un compte autoallotjat"
[setup.server]
title = "Connecta't al servidor"
@@ -5791,6 +5944,14 @@ description = "Introdueix la URL completa del teu servidor autoallotjat de Stirl
emptyUrl = "Introdueix una URL de servidor"
unreachable = "No s'ha pogut connectar amb el servidor"
testFailed = "Ha fallat la prova de connexió"
configFetch = "No s'ha pogut obtenir la configuració del servidor. Comproveu l'URL i torneu-ho a provar."
[setup.server.error.securityDisabled]
title = "Inici de sessió no habilitat"
body = "Aquest servidor no té l'inici de sessió habilitat. Per connectar-hi, heu d'habilitar l'autenticació:"
step1 = "Establiu DOCKER_ENABLE_SECURITY=true al vostre entorn"
step2 = "O establiu security.enableLogin=true a settings.yml"
step3 = "Reinicieu el servidor"
[setup.login]
title = "Inicia sessió"
@@ -5800,6 +5961,13 @@ submit = "Inicia sessió"
signInWith = "Inicia sessió amb"
oauthPending = "Obrint el navegador per autenticar-te..."
orContinueWith = "O continua amb el correu electrònic"
serverRequirement = "Nota: el servidor ha de tenir l'inici de sessió habilitat."
showInstructions = "Com s'habilita?"
hideInstructions = "Amagueu les instruccions"
instructions = "Per habilitar l'inici de sessió al vostre servidor de Stirling PDF:"
instructionsEnvVar = "Establiu la variable d'entorn:"
instructionsOrYml = "O a settings.yml:"
instructionsRestart = "A continuació, reinicieu el servidor perquè els canvis tinguin efecte."
[setup.login.username]
label = "Nom d'usuari"
@@ -5856,6 +6024,7 @@ earlyAccess = "Accés anticipat"
reset = "Restableix els canvis"
downloadJson = "Descarrega JSON"
generatePdf = "Genera PDF"
saveChanges = "Deseu els canvis"
[pdfTextEditor.options.autoScaleText]
title = "Autoajusta el text a les caixes"
@@ -5893,6 +6062,8 @@ alpha = "Aquest visor alfa encara evoluciona—certs tipus de lletra, colors, ef
[pdfTextEditor.empty]
title = "No s'ha carregat cap document"
subtitle = "Carrega un fitxer PDF o JSON per començar a editar el contingut de text."
dropzone = "Arrossegueu i deixeu anar un fitxer PDF o JSON aquí, o feu clic per explorar"
dropzoneWithFiles = "Seleccioneu un fitxer de la pestanya Fitxers o arrossegueu i deixeu anar aquí un fitxer PDF o JSON, o feu clic per explorar"
[pdfTextEditor.welcomeBanner]
title = "Benvingut a l'Editor de text PDF (accés anticipat)"
+184 -13
View File
@@ -163,6 +163,11 @@ unfavorite = "Odebrat z oblíbených"
fullscreen = "Přepnout na režim na celou obrazovku"
sidebar = "Přepnout na režim postranního panelu"
[backendStartup]
notFoundTitle = "Backend nebyl nalezen"
retry = "Zkusit znovu"
unreachable = "Aplikace se nyní nemůže připojit k backendu. Ověřte stav backendu a síťové připojení a poté to zkuste znovu."
[zipWarning]
title = "Velký soubor ZIP"
message = "Tento ZIP obsahuje {{count}} souborů. Přesto rozbalit?"
@@ -347,7 +352,7 @@ teams = "Týmy"
title = "Konfigurace"
systemSettings = "Systémová nastavení"
features = "Funkce"
endpoints = "Endpoints"
endpoints = "Koncové body"
database = "Databáze"
advanced = "Pokročilé"
@@ -914,7 +919,7 @@ title = "Překrýt PDF"
[home.pdfTextEditor]
title = "Editor textu PDF"
desc = "Prohlížejte a upravujte exporty JSON ze Stirling PDF se skupinovými úpravami textu a regenerací PDF"
desc = "Upravujte existující text a obrázky v PDF"
[home.addText]
tags = "text,anotace,štítek"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Nakreslený podpis"
defaultImageLabel = "Nahraný podpis"
defaultTextLabel = "Napsaný podpis"
saveButton = "Uložit podpis"
savePersonal = "Uložit osobní"
saveShared = "Uložit sdílené"
saveUnavailable = "Nejprve vytvořte podpis, abyste jej mohli uložit."
noChanges = "Aktuální podpis je již uložen."
tempStorageTitle = "Dočasné úložiště prohlížeče"
tempStorageDescription = "Podpisy jsou uloženy pouze ve vašem prohlížeči. Při vymazání dat prohlížeče nebo při přepnutí na jiný prohlížeč budou ztraceny."
personalHeading = "Osobní podpisy"
sharedHeading = "Sdílené podpisy"
personalDescription = "Tyto podpisy vidíte pouze vy."
sharedDescription = "Všichni uživatelé mohou tyto podpisy vidět a používat."
[sign.saved.type]
canvas = "Kresba"
@@ -3023,6 +3036,91 @@ title = "Získat informace o PDF"
header = "Získat informace o PDF"
submit = "Získat informace"
downloadJson = "Stáhnout JSON"
processing = "Probíhá extrahování informací..."
results = "Výsledky"
noResults = "Spusťte nástroj pro vygenerování zprávy."
downloads = "Stažení"
noneDetected = "Nic nebylo zjištěno"
indexTitle = "Rejstřík"
[getPdfInfo.report]
entryLabel = "Úplné shrnutí informací"
shortTitle = "Informace o PDF"
[getPdfInfo.sections]
metadata = "Metadata"
formFields = "Formulářová pole"
basicInfo = "Základní informace"
documentInfo = "Informace o dokumentu"
compliance = "Shoda"
encryption = "Šifrování"
permissions = "Oprávnění"
other = "Ostatní"
perPageInfo = "Informace po stránkách"
tableOfContents = "Obsah"
[getPdfInfo.other]
attachments = "Přílohy"
embeddedFiles = "Vložené soubory"
javaScript = "JavaScript"
layers = "Vrstvy"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Velikost"
annotations = "Anotace"
images = "Obrázky"
links = "Odkazy"
fonts = "Písma"
xobjects = "Počty XObjectů"
multimedia = "Multimédia"
[getPdfInfo.summary]
pages = "Stránky"
fileSize = "Velikost souboru"
pdfVersion = "Verze PDF"
language = "Jazyk"
title = "Souhrn PDF"
author = "Autor"
created = "Vytvořeno"
modified = "Upraveno"
permsAll = "Všechna oprávnění povolena"
permsRestricted = "{{count}} omezení"
permsMixed = "Některá oprávnění jsou omezena"
hasCompliance = "Obsahuje standardy shody"
noCompliance = "Žádné standardy shody"
basic = "Základní informace"
documentInfo = "Informace o dokumentu"
securityTitle = "Stav zabezpečení"
technical = "Technické"
overviewTitle = "Přehled PDF"
[getPdfInfo.summary.security]
encrypted = "Šifrované PDF chráněno heslem"
unencrypted = "Nešifrované PDF bez ochrany heslem"
[getPdfInfo.summary.tech]
images = "Obrázky"
fonts = "Písma"
formFields = "Formulářová pole"
embeddedFiles = "Vložené soubory"
javaScript = "JavaScript"
layers = "Vrstvy"
bookmarks = "Záložky"
multimedia = "Multimédia"
[getPdfInfo.summary.overview]
untitled = "nepojmenovaný dokument"
unknown = "Neznámý autor"
text = "Toto je PDF o {{pages}} stránkách s názvem {{title}} od autora {{author}} (verze PDF {{version}})."
[getPdfInfo.error]
partial = "Některé soubory se nepodařilo zpracovat."
unexpected = "Během extrahování došlo k neočekávané chybě."
[getPdfInfo.status]
complete = "Extrahování dokončeno"
[extractPage]
tags = "extrahovat"
@@ -3441,6 +3539,9 @@ signinTitle = "Prosím přihlaste se"
ssoSignIn = "Přihlásit se přes Single Sign-on"
oAuth2AutoCreateDisabled = "Automatické vytváření OAUTH2 uživatelů je zakázáno"
oAuth2AdminBlockedUser = "Registrace nebo přihlášení neregistrovaných uživatelů je momentálně blokováno. Kontaktujte prosím správce."
oAuth2RequiresLicense = "Přihlášení pomocí OAuth/SSO vyžaduje placenou licenci (Server nebo Enterprise). Kontaktujte prosím administrátora kvůli upgradu vašeho plánu."
saml2RequiresLicense = "Přihlášení pomocí SAML vyžaduje placenou licenci (Server nebo Enterprise). Kontaktujte prosím administrátora kvůli upgradu vašeho plánu."
maxUsersReached = "Byl dosažen maximální počet uživatelů pro vaši aktuální licenci. Kontaktujte prosím administrátora kvůli upgradu vašeho plánu nebo přidání dalších míst."
oauth2RequestNotFound = "Požadavek na autorizaci nebyl nalezen"
oauth2InvalidUserInfoResponse = "Neplatná odpověď s informacemi o uživateli"
oauth2invalidRequest = "Neplatný požadavek"
@@ -3849,14 +3950,17 @@ fitToWidth = "Přizpůsobit šířce"
actualSize = "Skutečná velikost"
[viewer]
cannotPreviewFile = "Nelze zobrazit náhled souboru"
dualPageView = "Zobrazení dvou stránek"
firstPage = "První stránka"
lastPage = "Poslední stránka"
previousPage = "Předchozí stránka"
nextPage = "Další stránka"
onlyPdfSupported = "Prohlížeč podporuje pouze soubory PDF. Tento soubor má zřejmě jiný formát."
previousPage = "Předchozí stránka"
singlePageView = "Zobrazení jedné stránky"
unknownFile = "Neznámý soubor"
zoomIn = "Přiblížit"
zoomOut = "Oddálit"
singlePageView = "Zobrazení jedné stránky"
dualPageView = "Zobrazení dvou stránek"
[rightRail]
closeSelected = "Zavřít vybrané soubory"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Přepnout postranní panel"
exportSelected = "Exportovat vybrané stránky"
toggleAnnotations = "Přepnout viditelnost anotací"
annotationMode = "Přepnout režim anotací"
print = "Tisk PDF"
draw = "Kreslit"
save = "Uložit"
saveChanges = "Uložit změny"
@@ -4156,7 +4261,7 @@ description = "Sledovat akce uživatelů a systémové události pro compliance
[admin.settings.security.audit.level]
label = "Úroveň auditu"
description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE"
description = "0=VYPNUTO, 1=ZÁKLADNÍ, 2=STANDARD, 3=PODROBNÝ"
[admin.settings.security.audit.retentionDays]
label = "Doba uchování auditů (dny)"
@@ -4497,6 +4602,7 @@ description = "URL nebo název souboru k Impressu (vyžadováno v některých ju
title = "Premium a Enterprise"
description = "Nakonfigurujte svůj prémiový nebo enterprise licenční klíč."
license = "Konfigurace licence"
noInput = "Zadejte licenční klíč nebo soubor"
[admin.settings.premium.licenseKey]
toggle = "Máte licenční klíč nebo certifikační soubor?"
@@ -4514,6 +4620,25 @@ line1 = "Přepsání aktuálního licenčního klíče nelze vrátit zpět."
line2 = "Předchozí licence bude trvale ztracena, pokud ji nemáte zálohovanou jinde."
line3 = "Důležité: Uchovávejte licenční klíče v soukromí a v bezpečí. Nikdy je nesdílejte veřejně."
[admin.settings.premium.inputMethod]
text = "Licenční klíč"
file = "Soubor certifikátu"
[admin.settings.premium.file]
label = "Soubor licenčního certifikátu"
description = "Nahrajte svůj licenční soubor .lic nebo .cert z offline nákupu"
choose = "Vybrat licenční soubor"
selected = "Vybráno: {{filename}} ({{size}})"
successMessage = "Licenční soubor byl úspěšně nahrán a aktivován. Restart není vyžadován."
[admin.settings.premium.currentLicense]
title = "Aktivní licence"
file = "Zdroj: Licenční soubor ({{path}})"
key = "Zdroj: Licenční klíč"
type = "Typ: {{type}}"
noInput = "Zadejte licenční klíč nebo nahrajte soubor certifikátu"
success = "Úspěch"
[admin.settings.premium.enabled]
label = "Povolit prémiové funkce"
description = "Povolit kontrolu licenčního klíče pro pro/enterprise funkce"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} vybráno"
download = "Stáhnout"
delete = "Smazat"
unsupported = "Nepodporováno"
active = "Aktivní"
addToUpload = "Přidat k nahrání"
closeFile = "Zavřít soubor"
deleteAll = "Smazat vše"
loadingFiles = "Načítání souborů..."
noFiles = "Nejsou k dispozici žádné soubory"
@@ -5135,7 +5262,7 @@ upgrade = "Upgradovat nyní →"
freeTitle = "Serverová licence"
overLimitTitle = "Vyžadována serverová licence"
overLimitBody = "Naše licencování umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Máte <strong>{{overLimitUserCopy}}</strong> uživatelů Stirling. Pro nepřerušené používání přejděte na plán Stirling Server <strong>neomezený počet míst</strong>, úpravy textu PDF a plná správa za 99 $/server/měsíc."
freeBody = "Naše licencování <strong>Open-Core</strong> umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Pro nepřerušený růst a přednostní přístup k našemu novému <strong>nástroji pro úpravu textu PDF</strong> doporučujeme plán Stirling Server plné úpravy a <strong>neomezený počet míst</strong> za 99 $/server/měsíc."
freeBody = "Naše licencování <strong>Open-Core</strong> umožňuje až <strong>{{freeTierLimit}}</strong> uživatelů zdarma na server. Pro nepřerušované škálování doporučujeme plán Stirling Server - <strong>neomezený počet míst</strong> a <strong>podpora SSO</strong> za $99/server/měs."
[onboarding.desktopInstall]
title = "Stáhnout"
@@ -5240,6 +5367,31 @@ error = "Nepodařilo se aktualizovat stav uživatele"
success = "Uživatel úspěšně smazán"
error = "Nepodařilo se smazat uživatele"
[workspace.people.changePassword]
action = "Změnit heslo"
title = "Změna hesla"
subtitle = "Aktualizovat heslo pro"
newPassword = "Nové heslo"
confirmPassword = "Potvrzení hesla"
placeholder = "Zadejte nové heslo"
confirmPlaceholder = "Zadejte nové heslo znovu"
passwordRequired = "Zadejte prosím nové heslo"
passwordMismatch = "Hesla se neshodují"
generateRandom = "Vygenerovat bezpečné heslo"
generatedPreview = "Vygenerované heslo:"
copyTooltip = "Zkopírovat do schránky"
copiedToClipboard = "Heslo zkopírováno do schránky"
copyFailed = "Heslo se nepodařilo zkopírovat"
sendEmail = "Odeslat uživateli e-mail o této změně"
includePassword = "Zahrnout nové heslo do e-mailu"
forcePasswordChange = "Vynutit změnu hesla při příštím přihlášení"
emailUnavailable = "E-mailová adresa tohoto uživatele není platná. Oznámení jsou deaktivována."
smtpDisabled = "E-mailová oznámení vyžadují, aby bylo v nastavení povoleno SMTP."
notifyOnly = "Bude odeslán e-mail bez hesla, který uživateli oznámí, že ho změnil administrátor."
submit = "Aktualizovat heslo"
success = "Heslo bylo úspěšně aktualizováno"
error = "Heslo se nepodařilo aktualizovat"
[workspace.people.emailInvite]
tab = "Pozvánka emailem"
description = "Níže napište nebo vložte emaily oddělené čárkami. Uživatelé obdrží přihlašovací údaje emailem."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "uzivatel1@priklad.cz, uzivatel2@priklad.cz"
emailsRequired = "Je vyžadována alespoň jedna emailová adresa"
submit = "Odeslat pozvánky"
success = "uživatel(é) úspěšně pozváni"
partialSuccess = "Některé pozvánky se nepodařilo odeslat"
partialFailure = "Některá pozvání selhala"
allFailed = "Nepodařilo se pozvat uživatele"
error = "Nepodařilo se odeslat pozvánky"
@@ -5712,7 +5864,7 @@ title = "Graf využití endpointů"
[usage.table]
title = "Podrobné statistiky"
endpoint = "Endpoint"
endpoint = "Koncový bod"
visits = "Návštěvy"
percentage = "Procenta"
noData = "Žádná data nejsou k dispozici"
@@ -5773,6 +5925,7 @@ subtitle = "Přihlaste se svým účtem Stirling"
[setup.selfhosted]
title = "Přihlásit se k serveru"
subtitle = "Zadejte přihlašovací údaje k vašemu serveru"
link = "nebo se připojte k účtu s vlastním hostováním"
[setup.server]
title = "Připojit k serveru"
@@ -5791,6 +5944,14 @@ description = "Zadejte úplnou URL vašeho samohostovaného serveru Stirling PDF
emptyUrl = "Zadejte URL serveru"
unreachable = "Nelze se připojit k serveru"
testFailed = "Test připojení selhal"
configFetch = "Nepodařilo se načíst konfiguraci serveru. Zkontrolujte prosím URL a zkuste to znovu."
[setup.server.error.securityDisabled]
title = "Přihlášení není povoleno"
body = "Na tomto serveru není povoleno přihlašování. Pokud se chcete připojit, musíte povolit ověřování:"
step1 = "Nastavte DOCKER_ENABLE_SECURITY=true ve svém prostředí"
step2 = "Nebo nastavte security.enableLogin=true v souboru settings.yml"
step3 = "Restartujte server"
[setup.login]
title = "Přihlášení"
@@ -5800,6 +5961,13 @@ submit = "Přihlásit se"
signInWith = "Přihlásit se pomocí"
oauthPending = "Otevírám prohlížeč pro ověření..."
orContinueWith = "Nebo pokračovat e-mailem"
serverRequirement = "Poznámka: Na serveru musí být povoleno přihlášení."
showInstructions = "Jak povolit?"
hideInstructions = "Skrýt pokyny"
instructions = "Chcete-li povolit přihlášení na vašem serveru Stirling PDF:"
instructionsEnvVar = "Nastavte proměnnou prostředí:"
instructionsOrYml = "Nebo v settings.yml:"
instructionsRestart = "Poté restartujte server, aby se změny projevily."
[setup.login.username]
label = "Uživatelské jméno"
@@ -5843,7 +6011,7 @@ paragraph = "Odstavcová stránka"
sparse = "Řídký text"
[pdfTextEditor.groupingMode]
auto = "Auto"
auto = "Automaticky"
paragraph = "Odstavec"
singleLine = "Jeden řádek"
@@ -5856,6 +6024,7 @@ earlyAccess = "Předběžný přístup"
reset = "Obnovit změny"
downloadJson = "Stáhnout JSON"
generatePdf = "Vytvořit PDF"
saveChanges = "Uložit změny"
[pdfTextEditor.options.autoScaleText]
title = "Automaticky přizpůsobit text rámečkům"
@@ -5893,6 +6062,8 @@ alpha = "Tento alfa prohlížeč se stále vyvíjí — některé fonty, barvy,
[pdfTextEditor.empty]
title = "Není načten žádný dokument"
subtitle = "Načtěte soubor PDF nebo JSON a začněte upravovat text."
dropzone = "Sem přetáhněte soubor PDF nebo JSON, případně kliknutím vyberte"
dropzoneWithFiles = "Vyberte soubor na kartě Soubory, nebo sem přetáhněte soubor PDF či JSON, případně kliknutím vyberte"
[pdfTextEditor.welcomeBanner]
title = "Vítejte v editoru textu PDF (předběžný přístup)"
@@ -5935,13 +6106,13 @@ warnings = "Varování"
suggestions = "Poznámky"
currentPageFonts = "Fonty na této stránce"
allFonts = "Všechny fonty"
fallback = "fallback"
fallback = "náhradní"
missing = "chybí"
perfectMessage = "Všechny fonty lze reprodukovat dokonale."
warningMessage = "Některé fonty se nemusí vykreslit správně."
infoMessage = "K dispozici jsou informace o reprodukci fontů."
perfect = "perfect"
subset = "subset"
perfect = "dokonalé"
subset = "podmnožina"
[pdfTextEditor.errors]
invalidJson = "Nelze přečíst soubor JSON. Ujistěte se, že byl vytvořen nástrojem PDF to JSON."
+192 -21
View File
@@ -163,6 +163,11 @@ unfavorite = "Fjern fra favoritter"
fullscreen = "Skift til fuldskærmstilstand"
sidebar = "Skift til sidepanel-tilstand"
[backendStartup]
notFoundTitle = "Backend ikke fundet"
retry = "Prøv igen"
unreachable = "Programmet kan i øjeblikket ikke forbinde til backend. Kontroller backend-status og netværksforbindelse, og prøv igen."
[zipWarning]
title = "Stor ZIP-fil"
message = "Denne ZIP indeholder {{count}} filer. Udpak alligevel?"
@@ -347,7 +352,7 @@ teams = "Teams"
title = "Konfiguration"
systemSettings = "Systemindstillinger"
features = "Funktioner"
endpoints = "Endpoints"
endpoints = "Slutpunkter"
database = "Database"
advanced = "Avanceret"
@@ -359,7 +364,7 @@ connections = "Forbindelser"
[settings.licensingAnalytics]
title = "Licensering & Analytics"
plan = "Plan"
audit = "Audit"
audit = "Revision"
usageAnalytics = "Brugsanalyse"
[settings.policiesPrivacy]
@@ -556,13 +561,13 @@ totalEndpoints = "Endpoints i alt"
totalVisits = "Besøg i alt"
showing = "Viser"
selectedVisits = "Valgte besøg"
endpoint = "Endpoint"
endpoint = "Slutpunkt"
visits = "Besøg"
percentage = "Procent"
loading = "Laster..."
failedToLoad = "Kunne ikke indlæse endpoint-data. Prøv at opdatere."
home = "Hjem"
login = "Login"
login = "Log ind"
top = "Top"
numberOfVisits = "Antal besøg"
visitsTooltip = "Besøg: {0} ({1}% af totalen)"
@@ -914,7 +919,7 @@ title = "Overlejr PDF'er"
[home.pdfTextEditor]
title = "PDF-teksteditor"
desc = "Gennemse og rediger Stirling PDF JSON-eksporter med grupperet tekstredigering og regenerering af PDF"
desc = "Rediger eksisterende tekst og billeder i PDF'er"
[home.addText]
tags = "tekst,annotering,etiket"
@@ -1176,7 +1181,7 @@ selectFilesPlaceholder = "Vælg filer i hovedvisningen for at komme i gang"
settings = "Indstillinger"
conversionCompleted = "Konvertering fuldført"
results = "Resultater"
defaultFilename = "converted_file"
defaultFilename = "konverteret_fil"
conversionResults = "Konverteringsresultater"
convertFrom = "Konvertér fra"
convertTo = "Konvertér til"
@@ -1216,9 +1221,9 @@ pdfaDigitalSignatureWarning = "PDF'en indeholder en digital signatur. Dette vil
fileFormat = "Filformat"
wordDoc = "Word-dokument"
wordDocExt = "Word-dokument (.docx)"
odtExt = "OpenDocument Text (.odt)"
odtExt = "OpenDocument-tekst (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "OpenDocument Presentation (.odp)"
odpExt = "OpenDocument-præsentation (.odp)"
txtExt = "Almindelig tekst (.txt)"
rtfExt = "Rich Text Format (.rtf)"
selectedFiles = "Valgte filer"
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Tegnet signatur"
defaultImageLabel = "Uploadet signatur"
defaultTextLabel = "Indtastet signatur"
saveButton = "Gem signatur"
savePersonal = "Gem personlig"
saveShared = "Gem delt"
saveUnavailable = "Opret først en signatur for at gemme den."
noChanges = "Nuværende signatur er allerede gemt."
tempStorageTitle = "Midlertidig browserlagring"
tempStorageDescription = "Signaturer gemmes kun i din browser. De går tabt, hvis du rydder browserdata eller skifter browser."
personalHeading = "Personlige signaturer"
sharedHeading = "Delte signaturer"
personalDescription = "Kun du kan se disse signaturer."
sharedDescription = "Alle brugere kan se og bruge disse signaturer."
[sign.saved.type]
canvas = "Tegning"
@@ -3023,6 +3036,91 @@ title = "Få Info om PDF"
header = "Få Info om PDF"
submit = "Få Info"
downloadJson = "Download JSON"
processing = "Udtrækker oplysninger..."
results = "Resultater"
noResults = "Kør værktøjet for at generere en rapport."
downloads = "Downloads"
noneDetected = "Ingen registreret"
indexTitle = "Indeks"
[getPdfInfo.report]
entryLabel = "Fuldt informationsresumé"
shortTitle = "PDF-oplysninger"
[getPdfInfo.sections]
metadata = "Metadata"
formFields = "Formularfelter"
basicInfo = "Grundlæggende info"
documentInfo = "Dokumentinfo"
compliance = "Overensstemmelse"
encryption = "Kryptering"
permissions = "Tilladelser"
other = "Andet"
perPageInfo = "Info pr. side"
tableOfContents = "Indholdsfortegnelse"
[getPdfInfo.other]
attachments = "Vedhæftninger"
embeddedFiles = "Indlejrede filer"
javaScript = "JavaScript"
layers = "Lag"
structureTree = "StructureTree"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Størrelse"
annotations = "Anmærkninger"
images = "Billeder"
links = "Links"
fonts = "Skrifttyper"
xobjects = "Antal XObjects"
multimedia = "Multimedie"
[getPdfInfo.summary]
pages = "Sider"
fileSize = "Filstørrelse"
pdfVersion = "PDF-version"
language = "Sprog"
title = "PDF-resumé"
author = "Forfatter"
created = "Oprettet"
modified = "Ændret"
permsAll = "Alle tilladelser tilladt"
permsRestricted = "{{count}} begrænsninger"
permsMixed = "Nogle tilladelser er begrænsede"
hasCompliance = "Har overensstemmelsesstandarder"
noCompliance = "Ingen overensstemmelsesstandarder"
basic = "Grundlæggende oplysninger"
documentInfo = "Dokumentoplysninger"
securityTitle = "Sikkerhedsstatus"
technical = "Teknisk"
overviewTitle = "PDF-oversigt"
[getPdfInfo.summary.security]
encrypted = "Krypteret PDF - med adgangskodebeskyttelse"
unencrypted = "Ukrypteret PDF - ingen adgangskodebeskyttelse"
[getPdfInfo.summary.tech]
images = "Billeder"
fonts = "Skrifttyper"
formFields = "Formularfelter"
embeddedFiles = "Indlejrede filer"
javaScript = "JavaScript"
layers = "Lag"
bookmarks = "Bogmærker"
multimedia = "Multimedie"
[getPdfInfo.summary.overview]
untitled = "et dokument uden titel"
unknown = "Ukendt forfatter"
text = "Dette er en PDF på {{pages}} sider med titlen {{title}}, oprettet af {{author}} (PDF-version {{version}})."
[getPdfInfo.error]
partial = "Nogle filer kunne ikke behandles."
unexpected = "Uventet fejl under udtrækning."
[getPdfInfo.status]
complete = "Udtrækning fuldført"
[extractPage]
tags = "udtræk"
@@ -3441,6 +3539,9 @@ signinTitle = "Log venligst ind"
ssoSignIn = "Log ind via Single Sign-on"
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Opret Bruger Deaktiveret"
oAuth2AdminBlockedUser = "Registrering eller login af ikke-registrerede brugere er i øjeblikket blokeret. Kontakt venligst administratoren."
oAuth2RequiresLicense = "OAuth/SSO-login kræver en betalt licens (Server eller Enterprise). Kontakt administratoren for at opgradere din plan."
saml2RequiresLicense = "SAML-login kræver en betalt licens (Server eller Enterprise). Kontakt administratoren for at opgradere din plan."
maxUsersReached = "Maksimalt antal brugere er nået for din nuværende licens. Kontakt administratoren for at opgradere din plan eller tilføje flere pladser."
oauth2RequestNotFound = "Autorisationsanmodning ikke fundet"
oauth2InvalidUserInfoResponse = "Ugyldigt Brugerinfo Svar"
oauth2invalidRequest = "Ugyldig Anmodning"
@@ -3774,7 +3875,7 @@ version = "Nuværende udgivelse"
title = "API-dokumentation"
header = "API-dokumentation"
desc = "Se og test Stirling PDF API-endpoints"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,dokumentation,swagger,endepunkter,udvikling"
[cookieBanner.popUp]
title = "Sådan bruger vi cookies"
@@ -3849,14 +3950,17 @@ fitToWidth = "Tilpas til bredde"
actualSize = "Faktisk størrelse"
[viewer]
cannotPreviewFile = "Kan ikke forhåndsvise fil"
dualPageView = "To-siders visning"
firstPage = "Første side"
lastPage = "Sidste side"
previousPage = "Forrige side"
nextPage = "Næste side"
onlyPdfSupported = "Visningen understøtter kun PDF-filer. Denne fil ser ud til at være et andet format."
previousPage = "Forrige side"
singlePageView = "Enkelt-sides visning"
unknownFile = "Ukendt fil"
zoomIn = "Zoom ind"
zoomOut = "Zoom ud"
singlePageView = "Enkelt-sides visning"
dualPageView = "To-siders visning"
[rightRail]
closeSelected = "Luk valgte filer"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Skift sidepanel"
exportSelected = "Eksporter valgte sider"
toggleAnnotations = "Skift visning af annoteringer"
annotationMode = "Skift annoteringstilstand"
print = "Udskriv PDF"
draw = "Tegn"
save = "Gem"
saveChanges = "Gem ændringer"
@@ -4238,11 +4343,11 @@ label = "Issuer-URL"
description = "OAuth2-udbyderens issuer-URL"
[admin.settings.connections.oauth2.clientId]
label = "Client ID"
label = "Klient-ID"
description = "OAuth2 Client ID fra din udbyder"
[admin.settings.connections.oauth2.clientSecret]
label = "Client Secret"
label = "Klienthemmelighed"
description = "OAuth2 Client Secret fra din udbyder"
[admin.settings.connections.oauth2.useAsUsername]
@@ -4346,7 +4451,7 @@ features = "Funktionsflag"
processing = "Behandling"
[admin.settings.advanced.endpoints]
label = "Endpoints"
label = "Slutpunkter"
manage = "Administrer API-endpoints"
description = "Endpointstyring konfigureres via YAML. Se dokumentationen for detaljer om aktivering/deaktivering af specifikke endpoints."
@@ -4497,6 +4602,7 @@ description = "URL eller filnavn til impressum (påkrævet i nogle jurisdiktione
title = "Premium og Enterprise"
description = "Konfigurer din premium- eller enterprise-licensnøgle."
license = "Licenskonfiguration"
noInput = "Angiv en licensnøgle eller fil"
[admin.settings.premium.licenseKey]
toggle = "Har du en licensnøgle eller en certifikatfil?"
@@ -4514,6 +4620,25 @@ line1 = "Overskrivning af din nuværende licensnøgle kan ikke fortrydes."
line2 = "Din tidligere licens går permanent tabt, medmindre du har sikkerhedskopieret den andetsteds."
line3 = "Vigtigt: Hold licensnøgler private og sikre. Del dem aldrig offentligt."
[admin.settings.premium.inputMethod]
text = "Licensnøgle"
file = "Certifikatfil"
[admin.settings.premium.file]
label = "Licenscertifikatfil"
description = "Upload din .lic- eller .cert-licensfil fra offlinekøb"
choose = "Vælg licensfil"
selected = "Valgt: {{filename}} ({{size}})"
successMessage = "Licensfil uploadet og aktiveret. Genstart er ikke påkrævet."
[admin.settings.premium.currentLicense]
title = "Aktiv licens"
file = "Kilde: Licensfil ({{path}})"
key = "Kilde: Licensnøgle"
type = "Type: {{type}}"
noInput = "Angiv en licensnøgle eller upload en certifikatfil"
success = "Succes"
[admin.settings.premium.enabled]
label = "Aktivér premium-funktioner"
description = "Aktivér licensnøgletjek for pro-/enterprise-funktioner"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} valgt"
download = "Download"
delete = "Slet"
unsupported = "Ikke understøttet"
active = "Aktiv"
addToUpload = "Føj til upload"
closeFile = "Luk fil"
deleteAll = "Slet alle"
loadingFiles = "Indlæser filer..."
noFiles = "Ingen filer tilgængelige"
@@ -5135,7 +5262,7 @@ upgrade = "Opgrader nu →"
freeTitle = "Serverlicens"
overLimitTitle = "Serverlicens påkrævet"
overLimitBody = "Vores licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. Du har <strong>{{overLimitUserCopy}}</strong> Stirling-brugere. For at fortsætte uden afbrydelser skal du opgradere til Stirling Server-abonnementet <strong>ubegrænsede pladser</strong>, PDF-tekstredigering og fuld admin-kontrol for $99/server/md."
freeBody = "Vores <strong>Open-Core</strong>-licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. For at skalere uden afbrydelser og få tidlig adgang til vores nye <strong>PDF-tekstredigeringsværktøj</strong> anbefaler vi Stirling Server-planen fuld redigering og <strong>ubegrænsede pladser</strong> for $99/server/md."
freeBody = "Vores <strong>Open-Core</strong>-licens tillader op til <strong>{{freeTierLimit}}</strong> brugere gratis pr. server. For at skalere uden afbrydelser anbefaler vi Stirling Server-planen <strong>ubegrænsede pladser</strong> og <strong>SSO-understøttelse</strong> for $99/server/md."
[onboarding.desktopInstall]
title = "Download"
@@ -5240,6 +5367,31 @@ error = "Kunne ikke opdatere brugerstatus"
success = "Bruger slettet"
error = "Kunne ikke slette bruger"
[workspace.people.changePassword]
action = "Skift adgangskode"
title = "Skift adgangskode"
subtitle = "Opdater adgangskoden for"
newPassword = "Ny adgangskode"
confirmPassword = "Bekræft adgangskode"
placeholder = "Indtast en ny adgangskode"
confirmPlaceholder = "Indtast den nye adgangskode igen"
passwordRequired = "Angiv en ny adgangskode"
passwordMismatch = "Adgangskoderne matcher ikke"
generateRandom = "Generér sikker adgangskode"
generatedPreview = "Genereret adgangskode:"
copyTooltip = "Kopiér til udklipsholder"
copiedToClipboard = "Adgangskode kopieret til udklipsholderen"
copyFailed = "Kunne ikke kopiere adgangskoden"
sendEmail = "Send en e-mail til brugeren om denne ændring"
includePassword = "Medtag den nye adgangskode i e-mailen"
forcePasswordChange = "Tving brugeren til at ændre adgangskode ved næste login"
emailUnavailable = "Denne brugers e-mail er ikke en gyldig e-mailadresse. Meddelelser er deaktiveret."
smtpDisabled = "E-mailmeddelelser kræver, at SMTP er aktiveret i indstillingerne."
notifyOnly = "Der sendes en e-mail uden adgangskoden, som informerer brugeren om, at en administrator har ændret den."
submit = "Opdater adgangskode"
success = "Adgangskoden blev opdateret"
error = "Kunne ikke opdatere adgangskoden"
[workspace.people.emailInvite]
tab = "E-mailinvitation"
description = "Skriv eller indsæt e-mails nedenfor, adskilt af kommaer. Brugere modtager loginoplysninger via e-mail."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Mindst én e-mailadresse er påkrævet"
submit = "Send invitationer"
success = "Bruger(e) inviteret"
partialSuccess = "Nogle invitationer mislykkedes"
partialFailure = "Nogle invitationer mislykkedes"
allFailed = "Kunne ikke invitere brugere"
error = "Kunne ikke sende invitationer"
@@ -5291,8 +5443,8 @@ emailDisabled = "E-mailinvitationer kræver SMTP-konfiguration og mail.enableInv
[workspace.people.license]
users = "brugere"
availableSlots = "Tilgængelige pladser"
grandfathered = "Grandfathered"
grandfatheredShort = "{{count}} grandfathered"
grandfathered = "På gamle vilkår"
grandfatheredShort = "{{count}} på gamle vilkår"
fromLicense = "fra licens"
slotsAvailable = "{{count}} ledig(e) brugerplads(er)"
noSlotsAvailable = "Ingen pladser tilgængelige"
@@ -5712,7 +5864,7 @@ title = "Diagram over endpoint-brug"
[usage.table]
title = "Detaljeret statistik"
endpoint = "Endpoint"
endpoint = "Slutpunkt"
visits = "Besøg"
percentage = "Procent"
noData = "Ingen data tilgængelige"
@@ -5755,7 +5907,7 @@ label = "Vælg server"
description = "Selvhostet server"
[setup.step3]
label = "Login"
label = "Log ind"
description = "Indtast loginoplysninger"
[setup.mode.saas]
@@ -5773,6 +5925,7 @@ subtitle = "Log ind med din Stirling-konto"
[setup.selfhosted]
title = "Log ind på server"
subtitle = "Indtast dine server-loginoplysninger"
link = "eller opret forbindelse til en selvhostet konto"
[setup.server]
title = "Forbind til server"
@@ -5791,6 +5944,14 @@ description = "Indtast den fulde URL til din selvhostede Stirling PDF-server"
emptyUrl = "Indtast en server-URL"
unreachable = "Kunne ikke forbinde til server"
testFailed = "Forbindelsestest mislykkedes"
configFetch = "Kunne ikke hente serverkonfiguration. Kontrollér URL'en, og prøv igen."
[setup.server.error.securityDisabled]
title = "Login ikke aktiveret"
body = "Denne server har ikke login aktiveret. For at oprette forbindelse til denne server skal du aktivere godkendelse:"
step1 = "Sæt DOCKER_ENABLE_SECURITY=true i dit miljø"
step2 = "Eller sæt security.enableLogin=true i settings.yml"
step3 = "Genstart serveren"
[setup.login]
title = "Log ind"
@@ -5800,6 +5961,13 @@ submit = "Log ind"
signInWith = "Log ind med"
oauthPending = "Åbner browser for godkendelse..."
orContinueWith = "Eller fortsæt med email"
serverRequirement = "Bemærk: Serveren skal have login aktiveret."
showInstructions = "Hvordan aktiveres det?"
hideInstructions = "Skjul instruktioner"
instructions = "Sådan aktiverer du login på din Stirling PDF-server:"
instructionsEnvVar = "Sæt miljøvariablen:"
instructionsOrYml = "Eller i settings.yml:"
instructionsRestart = "Genstart derefter serveren, så ændringerne træder i kraft."
[setup.login.username]
label = "Brugernavn"
@@ -5856,6 +6024,7 @@ earlyAccess = "Tidlig adgang"
reset = "Nulstil ændringer"
downloadJson = "Download JSON"
generatePdf = "Generer PDF"
saveChanges = "Gem ændringer"
[pdfTextEditor.options.autoScaleText]
title = "Autoskalér tekst, så den passer i bokse"
@@ -5893,6 +6062,8 @@ alpha = "Denne alpha-fremviser er stadig under udvikling—visse skrifttyper, fa
[pdfTextEditor.empty]
title = "Intet dokument indlæst"
subtitle = "Indlæs en PDF- eller JSON-fil for at begynde at redigere tekstindhold."
dropzone = "Træk og slip en PDF- eller JSON-fil her, eller klik for at gennemse"
dropzoneWithFiles = "Vælg en fil fra fanen Filer, eller træk og slip en PDF- eller JSON-fil her, eller klik for at gennemse"
[pdfTextEditor.welcomeBanner]
title = "Velkommen til PDF-teksteditor (Early Access)"
+189 -18
View File
@@ -163,6 +163,11 @@ unfavorite = "Aus Favoriten entfernen"
fullscreen = "In den Vollbildmodus wechseln"
sidebar = "In den Seitenleistenmodus wechseln"
[backendStartup]
notFoundTitle = "Backend nicht gefunden"
retry = "Erneut versuchen"
unreachable = "Die Anwendung kann derzeit keine Verbindung zum Backend herstellen. Überprüfen Sie den Backend-Status und die Netzwerkverbindung und versuchen Sie es dann erneut."
[zipWarning]
title = "Große ZIP-Datei"
message = "Dieses ZIP enthält {{count}} Dateien. Trotzdem extrahieren?"
@@ -347,7 +352,7 @@ teams = "Teams"
title = "Konfiguration"
systemSettings = "Systemeinstellungen"
features = "Funktionen"
endpoints = "Endpoints"
endpoints = "Endpunkte"
database = "Datenbank"
advanced = "Erweitert"
@@ -383,7 +388,7 @@ logout = "Abmelden"
[settings.connection.mode]
saas = "Stirling Cloud"
selfhosted = "Self-Hosted"
selfhosted = "Selbst gehostet"
[settings.general]
title = "Allgemein"
@@ -612,7 +617,7 @@ desc = "Anzeigen, Kommentieren, Text oder Bilder hinzufügen"
brandAlt = "Stirling PDF-Logo"
openFiles = "Dateien öffnen"
swipeHint = "Zum Wechseln der Ansicht nach links oder rechts wischen"
tools = "Tools"
tools = "Werkzeuge"
toolsSlide = "Bereich für Toolauswahl"
viewSwitcher = "Ansicht des Arbeitsbereichs wechseln"
workbenchSlide = "Arbeitsbereichs-Panel"
@@ -914,10 +919,10 @@ title = "PDFs überlagern"
[home.pdfTextEditor]
title = "PDF-Texteditor"
desc = "Stirling PDF JSON-Exporte prüfen und bearbeiten mit gruppierter Textbearbeitung und PDF-Neuerzeugung"
desc = "Vorhandenen Text und Bilder in PDFs bearbeiten"
[home.addText]
tags = "text,annotation,label"
tags = "text,anmerkung,beschriftung"
title = "Text hinzufügen"
desc = "Beliebigen Text überall in Ihrem PDF hinzufügen"
@@ -1216,7 +1221,7 @@ pdfaDigitalSignatureWarning = "Das PDF enthält eine digitale Signatur. Sie wird
fileFormat = "Dateiformat"
wordDoc = "Word-Dokument"
wordDocExt = "Word-Dokument (.docx)"
odtExt = "OpenDocument Text (.odt)"
odtExt = "OpenDocument-Text (.odt)"
pptExt = "PowerPoint (.pptx)"
odpExt = "OpenDocument Präsentation (.odp)"
txtExt = "Einfacher Text (.txt)"
@@ -2262,12 +2267,20 @@ defaultCanvasLabel = "Gezeichnete Unterschrift"
defaultImageLabel = "Hochgeladene Unterschrift"
defaultTextLabel = "Getippte Unterschrift"
saveButton = "Unterschrift speichern"
savePersonal = "Persönlich speichern"
saveShared = "Geteilt speichern"
saveUnavailable = "Erstellen Sie zuerst eine Unterschrift, um sie zu speichern."
noChanges = "Die aktuelle Unterschrift ist bereits gespeichert."
tempStorageTitle = "Temporärer Browser-Speicher"
tempStorageDescription = "Signaturen werden nur in Ihrem Browser gespeichert. Sie gehen verloren, wenn Sie Browserdaten löschen oder den Browser wechseln."
personalHeading = "Persönliche Signaturen"
sharedHeading = "Geteilte Signaturen"
personalDescription = "Nur Sie können diese Signaturen sehen."
sharedDescription = "Alle Benutzer können diese Signaturen sehen und verwenden."
[sign.saved.type]
canvas = "Zeichnung"
image = "Upload"
image = "Hochladen"
text = "Text"
[sign.saved.status]
@@ -3023,6 +3036,91 @@ title = "Alle Informationen anzeigen"
header = "Alle Informationen anzeigen"
submit = "Informationen anzeigen"
downloadJson = "Als JSON herunterladen"
processing = "Informationen werden extrahiert..."
results = "Ergebnisse"
noResults = "Führen Sie das Tool aus, um einen Bericht zu erstellen."
downloads = "Downloads"
noneDetected = "Keine erkannt"
indexTitle = "Index"
[getPdfInfo.report]
entryLabel = "Vollständige Informationsübersicht"
shortTitle = "PDF-Informationen"
[getPdfInfo.sections]
metadata = "Metadaten"
formFields = "Formularfelder"
basicInfo = "Grundlegende Informationen"
documentInfo = "Dokumentinformationen"
compliance = "Compliance"
encryption = "Verschlüsselung"
permissions = "Berechtigungen"
other = "Sonstiges"
perPageInfo = "Informationen pro Seite"
tableOfContents = "Inhaltsverzeichnis"
[getPdfInfo.other]
attachments = "Anhänge"
embeddedFiles = "Eingebettete Dateien"
javaScript = "JavaScript"
layers = "Ebenen"
structureTree = "Strukturbaum"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Größe"
annotations = "Anmerkungen"
images = "Bilder"
links = "Links"
fonts = "Schriftarten"
xobjects = "XObject-Anzahl"
multimedia = "Multimedia"
[getPdfInfo.summary]
pages = "Seiten"
fileSize = "Dateigröße"
pdfVersion = "PDF-Version"
language = "Sprache"
title = "PDF-Zusammenfassung"
author = "Autor"
created = "Erstellt"
modified = "Geändert"
permsAll = "Alle Berechtigungen erlaubt"
permsRestricted = "{{count}} Einschränkungen"
permsMixed = "Einige Berechtigungen eingeschränkt"
hasCompliance = "Entspricht Compliance-Standards"
noCompliance = "Keine Compliance-Standards"
basic = "Grundlegende Informationen"
documentInfo = "Dokumentinformationen"
securityTitle = "Sicherheitsstatus"
technical = "Technisch"
overviewTitle = "PDF-Übersicht"
[getPdfInfo.summary.security]
encrypted = "Verschlüsseltes PDF - Passwortschutz vorhanden"
unencrypted = "Unverschlüsseltes PDF - Kein Passwortschutz"
[getPdfInfo.summary.tech]
images = "Bilder"
fonts = "Schriftarten"
formFields = "Formularfelder"
embeddedFiles = "Eingebettete Dateien"
javaScript = "JavaScript"
layers = "Ebenen"
bookmarks = "Lesezeichen"
multimedia = "Multimedia"
[getPdfInfo.summary.overview]
untitled = "ein unbenanntes Dokument"
unknown = "Unbekannter Autor"
text = "Dies ist ein {{pages}}-seitiges PDF mit dem Titel {{title}}, erstellt von {{author}} (PDF-Version {{version}})."
[getPdfInfo.error]
partial = "Einige Dateien konnten nicht verarbeitet werden."
unexpected = "Unerwarteter Fehler während der Extraktion."
[getPdfInfo.status]
complete = "Extraktion abgeschlossen"
[extractPage]
tags = "extrahieren,seite"
@@ -3441,6 +3539,9 @@ signinTitle = "Bitte melden Sie sich an."
ssoSignIn = "Anmeldung per Single Sign-On"
oAuth2AutoCreateDisabled = "OAUTH2 Benutzer automatisch erstellen deaktiviert"
oAuth2AdminBlockedUser = "Die Registrierung bzw. das anmelden von nicht registrierten Benutzern ist derzeit gesperrt. Bitte wenden Sie sich an den Administrator."
oAuth2RequiresLicense = "OAuth/SSO-Anmeldung erfordert eine kostenpflichtige Lizenz (Server oder Enterprise). Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren."
saml2RequiresLicense = "SAML-Anmeldung erfordert eine kostenpflichtige Lizenz (Server oder Enterprise). Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren."
maxUsersReached = "Die maximale Benutzeranzahl für Ihre aktuelle Lizenz wurde erreicht. Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren oder weitere Benutzerplätze hinzuzufügen."
oauth2RequestNotFound = "Autorisierungsanfrage nicht gefunden"
oauth2InvalidUserInfoResponse = "Ungültige Benutzerinformationsantwort"
oauth2invalidRequest = "ungültige Anfrage"
@@ -3849,14 +3950,17 @@ fitToWidth = "An Breite anpassen"
actualSize = "Originalgröße"
[viewer]
cannotPreviewFile = "Datei kann nicht in der Vorschau angezeigt werden"
dualPageView = "Doppelseitenansicht"
firstPage = "Erste Seite"
lastPage = "Letzte Seite"
previousPage = "Vorherige Seite"
nextPage = "Nächste Seite"
onlyPdfSupported = "Der Viewer unterstützt nur PDF-Dateien. Diese Datei scheint ein anderes Format zu haben."
previousPage = "Vorherige Seite"
singlePageView = "Einzelseitenansicht"
unknownFile = "Unbekannte Datei"
zoomIn = "Vergrößern"
zoomOut = "Verkleinern"
singlePageView = "Einzelseitenansicht"
dualPageView = "Doppelseitenansicht"
[rightRail]
closeSelected = "Ausgewählte Dateien schließen"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Seitenleiste umschalten"
exportSelected = "Ausgewählte Seiten exportieren"
toggleAnnotations = "Anmerkungen ein-/ausblenden"
annotationMode = "Anmerkungsmodus umschalten"
print = "PDF drucken"
draw = "Zeichnen"
save = "Speichern"
saveChanges = "Änderungen speichern"
@@ -3931,7 +4036,7 @@ account = "Konto"
config = "Konfig"
settings = "Optionen"
adminSettings = "Admin Optionen"
allTools = "Tools"
allTools = "Werkzeuge"
reader = "Reader"
[quickAccess.helpMenu]
@@ -4497,6 +4602,7 @@ description = "URL oder Dateiname zum Impressum (in einigen Rechtsordnungen erfo
title = "Premium & Enterprise"
description = "Ihren Premium- oder Enterprise-Lizenzschlüssel konfigurieren."
license = "Lizenzkonfiguration"
noInput = "Bitte geben Sie einen Lizenzschlüssel oder eine Datei an"
[admin.settings.premium.licenseKey]
toggle = "Lizenzschlüssel oder Zertifikatsdatei vorhanden?"
@@ -4514,6 +4620,25 @@ line1 = "Das Überschreiben Ihres aktuellen Lizenzschlüssels kann nicht rückg
line2 = "Ihre vorherige Lizenz geht dauerhaft verloren, sofern Sie sie nicht anderweitig gesichert haben."
line3 = "Wichtig: Halten Sie Lizenzschlüssel privat und sicher. Geben Sie sie niemals öffentlich weiter."
[admin.settings.premium.inputMethod]
text = "Lizenzschlüssel"
file = "Zertifikatsdatei"
[admin.settings.premium.file]
label = "Lizenz-Zertifikatsdatei"
description = "Laden Sie Ihre .lic- oder .cert-Lizenzdatei aus Offline-Käufen hoch"
choose = "Lizenzdatei auswählen"
selected = "Ausgewählt: {{filename}} ({{size}})"
successMessage = "Lizenzdatei erfolgreich hochgeladen und aktiviert. Kein Neustart erforderlich."
[admin.settings.premium.currentLicense]
title = "Aktive Lizenz"
file = "Quelle: Lizenzdatei ({{path}})"
key = "Quelle: Lizenzschlüssel"
type = "Typ: {{type}}"
noInput = "Bitte geben Sie einen Lizenzschlüssel an oder laden Sie eine Zertifikatdatei hoch"
success = "Erfolg"
[admin.settings.premium.enabled]
label = "Premium-Funktionen aktivieren"
description = "Lizenzschlüssel-Prüfungen für Pro-/Enterprise-Funktionen aktivieren"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} ausgewählt"
download = "Herunterladen"
delete = "Löschen"
unsupported = "Nicht unterstützt"
active = "Aktiv"
addToUpload = "Zum Upload hinzufügen"
closeFile = "Datei schließen"
deleteAll = "Alle löschen"
loadingFiles = "Dateien werden geladen..."
noFiles = "Keine Dateien verfügbar"
@@ -5135,7 +5262,7 @@ upgrade = "Jetzt upgraden →"
freeTitle = "Server-Lizenz"
overLimitTitle = "Server-Lizenz erforderlich"
overLimitBody = "Unsere Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzer pro Server kostenlos. Sie haben <strong>{{overLimitUserCopy}}</strong> Stirling-Nutzer. Um ohne Unterbrechung fortzufahren, upgraden Sie auf den Stirling-Server-Plan <strong>unbegrenzte Plätze</strong>, PDF-Textbearbeitung und volle Admin-Kontrolle für $99/Server/Monat."
freeBody = "Unsere <strong>Open-Core</strong>-Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzer pro Server kostenlos. Für unterbrechungsfreies Skalieren und frühen Zugriff auf unser neues <strong>PDF-Textbearbeitungs-Tool</strong> empfehlen wir den Stirling-Server-Plan volle Bearbeitung und <strong>unbegrenzte Plätze</strong> für $99/Server/Monat."
freeBody = "Unsere <strong>Open-Core</strong>-Lizenz erlaubt bis zu <strong>{{freeTierLimit}}</strong> Nutzern pro Server kostenlos. Um unterbrechungsfrei zu skalieren, empfehlen wir den Stirling Server-Plan - <strong>unbegrenzte Plätze</strong> und <strong>SSO-Unterstützung</strong> für $99/Server/Monat."
[onboarding.desktopInstall]
title = "Download"
@@ -5240,6 +5367,31 @@ error = "Benutzerstatus konnte nicht aktualisiert werden"
success = "Benutzer erfolgreich gelöscht"
error = "Benutzer konnte nicht gelöscht werden"
[workspace.people.changePassword]
action = "Passwort ändern"
title = "Passwort ändern"
subtitle = "Passwort aktualisieren für"
newPassword = "Neues Passwort"
confirmPassword = "Passwort bestätigen"
placeholder = "Neues Passwort eingeben"
confirmPlaceholder = "Neues Passwort erneut eingeben"
passwordRequired = "Bitte geben Sie ein neues Passwort ein"
passwordMismatch = "Passwörter stimmen nicht überein"
generateRandom = "Sicheres Passwort generieren"
generatedPreview = "Generiertes Passwort:"
copyTooltip = "In Zwischenablage kopieren"
copiedToClipboard = "Passwort in die Zwischenablage kopiert"
copyFailed = "Kopieren des Passworts fehlgeschlagen"
sendEmail = "Den Benutzer per E-Mail über diese Änderung informieren"
includePassword = "Neues Passwort in die E-Mail aufnehmen"
forcePasswordChange = "Benutzer zwingen, das Passwort bei der nächsten Anmeldung zu ändern"
emailUnavailable = "Die E-Mail-Adresse dieses Benutzers ist keine gültige E-Mail-Adresse. Benachrichtigungen sind deaktiviert."
smtpDisabled = "E-Mail-Benachrichtigungen erfordern, dass SMTP in den Einstellungen aktiviert ist."
notifyOnly = "Es wird eine E-Mail ohne das Passwort gesendet, die den Benutzer darüber informiert, dass ein Admin es geändert hat."
submit = "Passwort aktualisieren"
success = "Passwort erfolgreich aktualisiert"
error = "Aktualisieren des Passworts fehlgeschlagen"
[workspace.people.emailInvite]
tab = "E-Mail-Einladung"
description = "Geben Sie unten E-Mails ein oder fügen Sie sie ein, getrennt durch Kommas. Benutzer erhalten Anmeldedaten per E-Mail."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Mindestens eine E-Mail-Adresse ist erforderlich"
submit = "Einladungen senden"
success = "Benutzer erfolgreich eingeladen"
partialSuccess = "Einige Einladungen sind fehlgeschlagen"
partialFailure = "Einige Einladungen sind fehlgeschlagen"
allFailed = "Benutzer konnten nicht eingeladen werden"
error = "Einladungen konnten nicht gesendet werden"
@@ -5755,7 +5907,7 @@ label = "Server auswählen"
description = "Self-Hosted-Server"
[setup.step3]
label = "Login"
label = "Anmeldung"
description = "Anmeldedaten eingeben"
[setup.mode.saas]
@@ -5773,6 +5925,7 @@ subtitle = "Mit Ihrem Stirling-Konto anmelden"
[setup.selfhosted]
title = "Am Server anmelden"
subtitle = "Geben Sie Ihre Server-Anmeldedaten ein"
link = "oder mit einem selbstgehosteten Konto verbinden"
[setup.server]
title = "Mit Server verbinden"
@@ -5791,15 +5944,30 @@ description = "Geben Sie die vollständige URL Ihres selbst gehosteten Stirling
emptyUrl = "Bitte eine Server-URL eingeben"
unreachable = "Verbindung zum Server konnte nicht hergestellt werden"
testFailed = "Verbindungstest fehlgeschlagen"
configFetch = "Serverkonfiguration konnte nicht abgerufen werden. Bitte überprüfen Sie die URL und versuchen Sie es erneut."
[setup.server.error.securityDisabled]
title = "Anmeldung nicht aktiviert"
body = "Auf diesem Server ist die Anmeldung nicht aktiviert. Um eine Verbindung zu diesem Server herzustellen, müssen Sie die Authentifizierung aktivieren:"
step1 = "Setzen Sie DOCKER_ENABLE_SECURITY=true in Ihrer Umgebung"
step2 = "Oder setzen Sie security.enableLogin=true in der settings.yml"
step3 = "Starten Sie den Server neu"
[setup.login]
title = "Anmelden"
subtitle = "Geben Sie Ihre Anmeldedaten ein, um fortzufahren"
connectingTo = "Verbinden mit:"
submit = "Login"
submit = "Anmelden"
signInWith = "Anmelden mit"
oauthPending = "Browser zur Authentifizierung wird geöffnet..."
orContinueWith = "Oder mit E-Mail fortfahren"
serverRequirement = "Hinweis: Auf dem Server muss die Anmeldung aktiviert sein."
showInstructions = "Wie aktivieren?"
hideInstructions = "Anleitung ausblenden"
instructions = "So aktivieren Sie die Anmeldung auf Ihrem Stirling PDF-Server:"
instructionsEnvVar = "Setzen Sie die Umgebungsvariable:"
instructionsOrYml = "Oder in der settings.yml:"
instructionsRestart = "Starten Sie anschließend Ihren Server neu, damit die Änderungen wirksam werden."
[setup.login.username]
label = "Benutzername"
@@ -5850,12 +6018,13 @@ singleLine = "Einzeilig"
[pdfTextEditor.badges]
unsaved = "Bearbeitet"
modified = "Bearbeitet"
earlyAccess = "Early Access"
earlyAccess = "Früher Zugriff"
[pdfTextEditor.actions]
reset = "Änderungen zurücksetzen"
downloadJson = "JSON herunterladen"
generatePdf = "PDF generieren"
saveChanges = "Änderungen speichern"
[pdfTextEditor.options.autoScaleText]
title = "Text automatisch in Rahmen einpassen"
@@ -5893,6 +6062,8 @@ alpha = "Dieser Alpha-Viewer entwickelt sich noch weiter bestimmte Schriften
[pdfTextEditor.empty]
title = "Kein Dokument geladen"
subtitle = "Laden Sie eine PDF- oder JSON-Datei, um mit der Textbearbeitung zu beginnen."
dropzone = "Ziehen Sie eine PDF- oder JSON-Datei hierher, oder klicken Sie zum Durchsuchen"
dropzoneWithFiles = "Wählen Sie eine Datei auf der Registerkarte Dateien aus oder ziehen Sie eine PDF- oder JSON-Datei hierher, oder klicken Sie zum Durchsuchen"
[pdfTextEditor.welcomeBanner]
title = "Willkommen beim PDF-Texteditor (Early Access)"
@@ -5960,8 +6131,8 @@ tags = "text,anmerkung,beschriftung"
applySignatures = "Text anwenden"
[addText.text]
name = "Textinhalt"
placeholder = "Geben Sie den hinzuzufügenden Text ein"
name = "Text"
placeholder = "Text eingeben"
fontLabel = "Schriftart"
fontSizeLabel = "Schriftgröße"
fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)"
+191 -20
View File
@@ -163,6 +163,11 @@ unfavorite = "Αφαίρεση από τα Αγαπημένα"
fullscreen = "Μετάβαση σε λειτουργία πλήρους οθόνης"
sidebar = "Μετάβαση σε λειτουργία πλευρικής γραμμής"
[backendStartup]
notFoundTitle = "Το backend δεν βρέθηκε"
retry = "Επανάληψη"
unreachable = "Η εφαρμογή δεν μπορεί προς το παρόν να συνδεθεί με το backend. Ελέγξτε την κατάσταση του backend και τη συνδεσιμότητα δικτύου, μετά δοκιμάστε ξανά."
[zipWarning]
title = "Μεγάλο αρχείο ZIP"
message = "Αυτό το ZIP περιέχει {{count}} αρχεία. Να γίνει αποσυμπίεση ούτως ή άλλως;"
@@ -287,7 +292,7 @@ help = "Βοήθεια Pipeline"
scanHelp = "Βοήθεια σάρωσης φακέλων"
deletePrompt = "Είστε βέβαιοι ότι θέλετε να διαγράψετε το pipeline;"
tags = "αυτοματοποίηση,ακολουθία,προγραμματισμένο,επεξεργασία-παρτίδας"
title = "Pipeline"
title = "Ροή"
[pipelineOptions]
header = "Διαμόρφωση Pipeline"
@@ -296,7 +301,7 @@ saveSettings = "Αποθήκευση ρυθμίσεων λειτουργίας"
pipelineNamePrompt = "Εισάγετε όνομα pipeline εδώ"
selectOperation = "Επιλογή λειτουργίας"
addOperationButton = "Προσθήκη λειτουργίας"
pipelineHeader = "Pipeline:"
pipelineHeader = "Ροή:"
saveButton = "Λήψη"
validateButton = "Επικύρωση"
@@ -347,7 +352,7 @@ teams = "Ομάδες"
title = "Διαμόρφωση"
systemSettings = "Ρυθμίσεις συστήματος"
features = "Δυνατότητες"
endpoints = "Endpoints"
endpoints = "Σημεία τερματισμού"
database = "Βάση δεδομένων"
advanced = "Προχωρημένα"
@@ -369,7 +374,7 @@ privacy = "Απόρρητο"
[settings.developer]
title = "Προγραμματιστής"
apiKeys = "API Keys"
apiKeys = "Κλειδιά API"
[settings.tooltips]
enableLoginFirst = "Ενεργοποιήστε πρώτα τη λειτουργία σύνδεσης"
@@ -383,7 +388,7 @@ logout = "Αποσύνδεση"
[settings.connection.mode]
saas = "Stirling Cloud"
selfhosted = "Self-Hosted"
selfhosted = "Αυτο-φιλοξενούμενο"
[settings.general]
title = "Γενικά"
@@ -914,7 +919,7 @@ title = "Επικάλυψη PDF"
[home.pdfTextEditor]
title = "Επεξεργαστής κειμένου PDF"
desc = "Επιθεωρήστε και επεξεργαστείτε εξαγωγές JSON του Stirling PDF με ομαδοποιημένη επεξεργασία κειμένου και αναδημιουργία PDF"
desc = "Επεξεργαστείτε υπάρχον κείμενο και εικόνες μέσα σε αρχεία PDF"
[home.addText]
tags = "κείμενο,σχολιασμός,ετικέτα"
@@ -1176,7 +1181,7 @@ selectFilesPlaceholder = "Επιλέξτε αρχεία στην κύρια πρ
settings = "Ρυθμίσεις"
conversionCompleted = "Η μετατροπή ολοκληρώθηκε"
results = "Αποτελέσματα"
defaultFilename = "converted_file"
defaultFilename = "μετατραπμένο_αρχείο"
conversionResults = "Αποτελέσματα μετατροπής"
convertFrom = "Μετατροπή από"
convertTo = "Μετατροπή σε"
@@ -1363,7 +1368,7 @@ title = "Προσθήκη υδατογραφήματος"
desc = "Προσθέστε υδατογραφήματα κειμένου ή εικόνας σε αρχεία PDF"
completed = "Το υδατογράφημα προστέθηκε"
submit = "Προσθήκη υδατογραφήματος"
filenamePrefix = "watermarked"
filenamePrefix = "υδατογραφημένο"
[watermark.error]
failed = "Παρουσιάστηκε σφάλμα κατά την προσθήκη υδατογραφήματος στο PDF."
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Σχεδιασμένη υπογραφή"
defaultImageLabel = "Ανεβασμένη υπογραφή"
defaultTextLabel = "Πληκτρολογημένη υπογραφή"
saveButton = "Αποθήκευση υπογραφής"
savePersonal = "Αποθήκευση ως Προσωπική"
saveShared = "Αποθήκευση ως Κοινόχρηστη"
saveUnavailable = "Δημιουργήστε πρώτα μια υπογραφή για να την αποθηκεύσετε."
noChanges = "Η τρέχουσα υπογραφή είναι ήδη αποθηκευμένη."
tempStorageTitle = "Προσωρινή αποθήκευση στον περιηγητή"
tempStorageDescription = "Οι υπογραφές αποθηκεύονται μόνο στον περιηγητή σας. Θα χαθούν αν καθαρίσετε τα δεδομένα του περιηγητή ή αλλάξετε περιηγητή."
personalHeading = "Προσωπικές υπογραφές"
sharedHeading = "Κοινόχρηστες υπογραφές"
personalDescription = "Μόνο εσείς μπορείτε να δείτε αυτές τις υπογραφές."
sharedDescription = "Όλοι οι χρήστες μπορούν να βλέπουν και να χρησιμοποιούν αυτές τις υπογραφές."
[sign.saved.type]
canvas = "Σχέδιο"
@@ -2704,7 +2717,7 @@ header = "Αφαίρεση της ψηφιακής υπογραφής από τ
selectPDF = "Επιλέξτε ένα αρχείο PDF:"
submit = "Αφαίρεση υπογραφής"
description = "Αυτό το εργαλείο θα αφαιρέσει τις υπογραφές ψηφιακού πιστοποιητικού από το PDF σας."
filenamePrefix = "unsigned"
filenamePrefix = "ανυπόγραφο"
[removeCertSign.files]
placeholder = "Επιλέξτε ένα αρχείο PDF στην κύρια προβολή για να ξεκινήσετε"
@@ -3023,6 +3036,91 @@ title = "Λήψη πληροφοριών PDF"
header = "Λήψη πληροφοριών PDF"
submit = "Λήψη πληροφοριών"
downloadJson = "Λήψη JSON"
processing = "Εξαγωγή πληροφοριών..."
results = "Αποτελέσματα"
noResults = "Εκτελέστε το εργαλείο για να δημιουργήσετε αναφορά."
downloads = "Λήψεις"
noneDetected = "Δεν εντοπίστηκε κανένα"
indexTitle = "Ευρετήριο"
[getPdfInfo.report]
entryLabel = "Πλήρης σύνοψη πληροφοριών"
shortTitle = "Πληροφορίες PDF"
[getPdfInfo.sections]
metadata = "Μεταδεδομένα"
formFields = "Πεδία φόρμας"
basicInfo = "Βασικές πληροφορίες"
documentInfo = "Πληροφορίες εγγράφου"
compliance = "Συμμόρφωση"
encryption = "Κρυπτογράφηση"
permissions = "Δικαιώματα"
other = "Άλλα"
perPageInfo = "Πληροφορίες ανά σελίδα"
tableOfContents = "Πίνακας περιεχομένων"
[getPdfInfo.other]
attachments = "Συνημμένα"
embeddedFiles = "Ενσωματωμένα αρχεία"
javaScript = "JavaScript"
layers = "Επίπεδα"
structureTree = "Δέντρο δομής"
xmp = "XMPMetadata"
[getPdfInfo.perPage]
size = "Μέγεθος"
annotations = "Επισημειώσεις"
images = "Εικόνες"
links = "Σύνδεσμοι"
fonts = "Γραμματοσειρές"
xobjects = "Πλήθος XObject"
multimedia = "Πολυμέσα"
[getPdfInfo.summary]
pages = "Σελίδες"
fileSize = "Μέγεθος αρχείου"
pdfVersion = "Έκδοση PDF"
language = "Γλώσσα"
title = "Σύνοψη PDF"
author = "Συγγραφέας"
created = "Δημιουργήθηκε"
modified = "Τροποποιήθηκε"
permsAll = "Όλα τα δικαιώματα επιτρέπονται"
permsRestricted = "{{count}} περιορισμοί"
permsMixed = "Ορισμένα δικαιώματα είναι περιορισμένα"
hasCompliance = "Διαθέτει πρότυπα συμμόρφωσης"
noCompliance = "Χωρίς πρότυπα συμμόρφωσης"
basic = "Βασικές πληροφορίες"
documentInfo = "Πληροφορίες εγγράφου"
securityTitle = "Κατάσταση ασφάλειας"
technical = "Τεχνικά"
overviewTitle = "Επισκόπηση PDF"
[getPdfInfo.summary.security]
encrypted = "Κρυπτογραφημένο PDF - Υπάρχει προστασία με κωδικό πρόσβασης"
unencrypted = "Μη κρυπτογραφημένο PDF - Χωρίς προστασία με κωδικό πρόσβασης"
[getPdfInfo.summary.tech]
images = "Εικόνες"
fonts = "Γραμματοσειρές"
formFields = "Πεδία φόρμας"
embeddedFiles = "Ενσωματωμένα αρχεία"
javaScript = "JavaScript"
layers = "Επίπεδα"
bookmarks = "Σελιδοδείκτες"
multimedia = "Πολυμέσα"
[getPdfInfo.summary.overview]
untitled = "ένα έγγραφο χωρίς τίτλο"
unknown = "Άγνωστος συγγραφέας"
text = "Πρόκειται για ένα PDF {{pages}} σελίδων με τίτλο {{title}} που δημιουργήθηκε από τον/την {{author}} (έκδοση PDF {{version}})."
[getPdfInfo.error]
partial = "Δεν ήταν δυνατή η επεξεργασία ορισμένων αρχείων."
unexpected = "Μη αναμενόμενο σφάλμα κατά την εξαγωγή."
[getPdfInfo.status]
complete = "Η εξαγωγή ολοκληρώθηκε"
[extractPage]
tags = "εξαγωγή"
@@ -3441,6 +3539,9 @@ signinTitle = "Παρακαλώ συνδεθείτε"
ssoSignIn = "Σύνδεση μέσω Single Sign-on"
oAuth2AutoCreateDisabled = "Η αυτόματη δημιουργία χρήστη OAUTH2 είναι απενεργοποιημένη"
oAuth2AdminBlockedUser = "Η εγγραφή ή σύνδεση μη εγγεγραμμένων χρηστών είναι προς το παρόν αποκλεισμένη. Παρακαλώ επικοινωνήστε με τον διαχειριστή."
oAuth2RequiresLicense = "Η σύνδεση μέσω OAuth/SSO απαιτεί επί πληρωμή άδεια (Server ή Enterprise). Παρακαλούμε επικοινωνήστε με τον διαχειριστή για να αναβαθμίσετε το πλάνο σας."
saml2RequiresLicense = "Η σύνδεση μέσω SAML απαιτεί επί πληρωμή άδεια (Server ή Enterprise). Παρακαλούμε επικοινωνήστε με τον διαχειριστή για να αναβαθμίσετε το πλάνο σας."
maxUsersReached = "Έχει επιτευχθεί ο μέγιστος αριθμός χρηστών για την τρέχουσα άδειά σας. Παρακαλούμε επικοινωνήστε με τον διαχειριστή για να αναβαθμίσετε το πλάνο σας ή να προσθέσετε περισσότερες θέσεις."
oauth2RequestNotFound = "Το αίτημα εξουσιοδότησης δεν βρέθηκε"
oauth2InvalidUserInfoResponse = "Μη έγκυρη απόκριση πληροφοριών χρήστη"
oauth2invalidRequest = "Μη έγκυρο αίτημα"
@@ -3536,7 +3637,7 @@ title = "PDF σε μία σελίδα"
header = "PDF σε μία σελίδα"
submit = "Μετατροπή σε μία σελίδα"
description = "Αυτό το εργαλείο θα συγχωνεύσει όλες τις σελίδες του PDF σας σε μία μεγάλη ενιαία σελίδα. Το πλάτος θα παραμείνει ίδιο με των αρχικών σελίδων, αλλά το ύψος θα είναι το άθροισμα όλων των υψών."
filenamePrefix = "single_page"
filenamePrefix = "μονοσέλιδο"
[pdfToSinglePage.files]
placeholder = "Επιλέξτε ένα αρχείο PDF στην κύρια προβολή για να ξεκινήσετε"
@@ -3774,7 +3875,7 @@ version = "Τρέχουσα έκδοση"
title = "Τεκμηρίωση API"
header = "Τεκμηρίωση API"
desc = "Προβάλετε και δοκιμάστε τα endpoints του Stirling PDF API"
tags = "api,documentation,swagger,endpoints,development"
tags = "api,τεκμηρίωση,swagger,τελικά σημεία,ανάπτυξη"
[cookieBanner.popUp]
title = "Πώς χρησιμοποιούμε τα cookies"
@@ -3849,14 +3950,17 @@ fitToWidth = "Προσαρμογή στο πλάτος"
actualSize = "Πραγματικό μέγεθος"
[viewer]
cannotPreviewFile = "Δεν είναι δυνατή η προεπισκόπηση του αρχείου"
dualPageView = "Προβολή διπλής σελίδας"
firstPage = "Πρώτη σελίδα"
lastPage = "Τελευταία σελίδα"
previousPage = "Προηγούμενη σελίδα"
nextPage = "Επόμενη σελίδα"
onlyPdfSupported = "Ο προβολέας υποστηρίζει μόνο αρχεία PDF. Αυτό το αρχείο φαίνεται να είναι διαφορετικής μορφής."
previousPage = "Προηγούμενη σελίδα"
singlePageView = "Προβολή μίας σελίδας"
unknownFile = "Άγνωστο αρχείο"
zoomIn = "Μεγέθυνση"
zoomOut = "Σμίκρυνση"
singlePageView = "Προβολή μίας σελίδας"
dualPageView = "Προβολή διπλής σελίδας"
[rightRail]
closeSelected = "Κλείσιμο επιλεγμένων αρχείων"
@@ -3880,6 +3984,7 @@ toggleSidebar = "Εναλλαγή πλευρικής γραμμής"
exportSelected = "Εξαγωγή επιλεγμένων σελίδων"
toggleAnnotations = "Εναλλαγή ορατότητας σχολιασμών"
annotationMode = "Εναλλαγή λειτουργίας σχολιασμού"
print = "Εκτύπωση PDF"
draw = "Σχεδίαση"
save = "Αποθήκευση"
saveChanges = "Αποθήκευση αλλαγών"
@@ -4238,11 +4343,11 @@ label = "URL εκδότη"
description = "Το URL εκδότη του παρόχου OAuth2"
[admin.settings.connections.oauth2.clientId]
label = "Client ID"
label = "Αναγνωριστικό πελάτη (Client ID)"
description = "Το Client ID OAuth2 από τον πάροχό σας"
[admin.settings.connections.oauth2.clientSecret]
label = "Client Secret"
label = "Μυστικό πελάτη (Client Secret)"
description = "Το Client Secret OAuth2 από τον πάροχό σας"
[admin.settings.connections.oauth2.useAsUsername]
@@ -4462,7 +4567,7 @@ label = "Ενεργοποίηση προσκλήσεων μέσω email"
description = "Να επιτρέπεται στους διαχειριστές να προσκαλούν χρήστες μέσω email με αυτόματα παραγόμενους κωδικούς"
[admin.settings.mail.frontendUrl]
label = "Frontend URL"
label = "URL front-end"
description = "Βασικό URL για το frontend (π.χ. https://pdf.example.com). Χρησιμοποιείται για τη δημιουργία συνδέσμων πρόσκλησης στα email. Αφήστε κενό για χρήση του backend URL."
[admin.settings.legal]
@@ -4497,6 +4602,7 @@ description = "URL ή όνομα αρχείου για το impressum (απαι
title = "Premium & Enterprise"
description = "Ρυθμίστε το κλειδί άδειας premium ή enterprise."
license = "Διαμόρφωση άδειας"
noInput = "Παρακαλώ δώστε ένα κλειδί άδειας ή αρχείο"
[admin.settings.premium.licenseKey]
toggle = "Έχετε κλειδί άδειας ή αρχείο πιστοποιητικού;"
@@ -4514,6 +4620,25 @@ line1 = "Η αντικατάσταση του τρέχοντος κλειδιο
line2 = "Η προηγούμενη άδεια θα χαθεί οριστικά εκτός αν την έχετε αποθηκεύσει αλλού."
line3 = "Σημαντικό: Κρατήστε τα κλειδιά άδειας ιδιωτικά και ασφαλή. Μην τα κοινοποιείτε δημόσια."
[admin.settings.premium.inputMethod]
text = "Κλειδί άδειας"
file = "Αρχείο πιστοποιητικού"
[admin.settings.premium.file]
label = "Αρχείο πιστοποιητικού άδειας"
description = "Μεταφορτώστε το αρχείο άδειας .lic ή .cert από αγορές εκτός σύνδεσης"
choose = "Επιλέξτε αρχείο άδειας"
selected = "Επιλεγμένο: {{filename}} ({{size}})"
successMessage = "Το αρχείο άδειας μεταφορτώθηκε και ενεργοποιήθηκε με επιτυχία. Δεν απαιτείται επανεκκίνηση."
[admin.settings.premium.currentLicense]
title = "Ενεργή άδεια"
file = "Πηγή: Αρχείο άδειας ({{path}})"
key = "Πηγή: Κλειδί άδειας"
type = "Τύπος: {{type}}"
noInput = "Παρακαλώ δώστε ένα κλειδί άδειας ή μεταφορτώστε ένα αρχείο πιστοποιητικού"
success = "Επιτυχία"
[admin.settings.premium.enabled]
label = "Ενεργοποίηση λειτουργιών premium"
description = "Ενεργοποίηση ελέγχων κλειδιού άδειας για λειτουργίες pro/enterprise"
@@ -4647,7 +4772,9 @@ selectedCount = "{{count}} επιλεγμένα"
download = "Λήψη"
delete = "Διαγραφή"
unsupported = "Μη υποστηριζόμενο"
active = "Ενεργό"
addToUpload = "Προσθήκη στη μεταφόρτωση"
closeFile = "Κλείσιμο αρχείου"
deleteAll = "Διαγραφή όλων"
loadingFiles = "Φόρτωση αρχείων..."
noFiles = "Δεν υπάρχουν διαθέσιμα αρχεία"
@@ -5135,7 +5262,7 @@ upgrade = "Αναβάθμιση τώρα →"
freeTitle = "Άδεια διακομιστή"
overLimitTitle = "Απαιτείται άδεια διακομιστή"
overLimitBody = "Η αδειοδότηση μας επιτρέπει έως <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Έχετε <strong>{{overLimitUserCopy}}</strong> χρήστες Stirling. Για να συνεχίσετε χωρίς διακοπές, αναβαθμίστε στο πλάνο Stirling Server - <strong>απεριόριστες θέσεις</strong>, επεξεργασία κειμένου PDF και πλήρης έλεγχος διαχειριστή για $99/server/μήνα."
freeBody = "Η αδειοδότηση <strong>Open-Core</strong> μας επιτρέπει έως <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση και έγκαιρη πρόσβαση στο νέο <strong>εργαλείο επεξεργασίας κειμένου PDF</strong>, προτείνουμε το πλάνο Stirling Server - πλήρης επεξεργασία και <strong>απεριόριστες θέσεις</strong> για $99/server/μήνα."
freeBody = "Οι άδειες χρήσης <strong>Open-Core</strong> επιτρέπουν έως και <strong>{{freeTierLimit}}</strong> χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση, προτείνουμε το πλάνο Stirling Server - <strong>απεριόριστες θέσεις</strong> και <strong>υποστήριξη SSO</strong> με $99/διακομιστή/μήνα."
[onboarding.desktopInstall]
title = "Λήψη"
@@ -5240,6 +5367,31 @@ error = "Αποτυχία ενημέρωσης κατάστασης χρήστη
success = "Ο χρήστης διαγράφηκε με επιτυχία"
error = "Αποτυχία διαγραφής χρήστη"
[workspace.people.changePassword]
action = "Αλλαγή κωδικού πρόσβασης"
title = "Αλλαγή κωδικού πρόσβασης"
subtitle = "Ενημέρωση κωδικού πρόσβασης για"
newPassword = "Νέος κωδικός πρόσβασης"
confirmPassword = "Επιβεβαίωση κωδικού πρόσβασης"
placeholder = "Εισαγάγετε νέο κωδικό πρόσβασης"
confirmPlaceholder = "Εισαγάγετε ξανά τον νέο κωδικό πρόσβασης"
passwordRequired = "Παρακαλούμε εισαγάγετε νέο κωδικό πρόσβασης"
passwordMismatch = "Οι κωδικοί πρόσβασης δεν ταιριάζουν"
generateRandom = "Δημιουργία ασφαλούς κωδικού πρόσβασης"
generatedPreview = "Δημιουργημένος κωδικός πρόσβασης:"
copyTooltip = "Αντιγραφή στο πρόχειρο"
copiedToClipboard = "Ο κωδικός πρόσβασης αντιγράφηκε στο πρόχειρο"
copyFailed = "Αποτυχία αντιγραφής κωδικού πρόσβασης"
sendEmail = "Αποστολή email στον χρήστη για αυτήν την αλλαγή"
includePassword = "Να συμπεριληφθεί ο νέος κωδικός πρόσβασης στο email"
forcePasswordChange = "Υποχρεωτική αλλαγή κωδικού πρόσβασης κατά την επόμενη σύνδεση"
emailUnavailable = "Το email αυτού του χρήστη δεν είναι έγκυρη διεύθυνση email. Οι ειδοποιήσεις είναι απενεργοποιημένες."
smtpDisabled = "Οι ειδοποιήσεις μέσω email απαιτούν την ενεργοποίηση του SMTP στις ρυθμίσεις."
notifyOnly = "Θα σταλεί email χωρίς τον κωδικό πρόσβασης, ενημερώνοντας τον χρήστη ότι ένας διαχειριστής τον άλλαξε."
submit = "Ενημέρωση κωδικού πρόσβασης"
success = "Ο κωδικός πρόσβασης ενημερώθηκε με επιτυχία"
error = "Αποτυχία ενημέρωσης κωδικού πρόσβασης"
[workspace.people.emailInvite]
tab = "Πρόσκληση μέσω Email"
description = "Πληκτρολογήστε ή επικολλήστε emails παρακάτω, χωρισμένα με κόμμα. Οι χρήστες θα λάβουν στοιχεία σύνδεσης μέσω email."
@@ -5248,7 +5400,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "Απαιτείται τουλάχιστον μία διεύθυνση email"
submit = "Αποστολή προσκλήσεων"
success = "στάλθηκαν προσκλήσεις με επιτυχία"
partialSuccess = "Κάποιες προσκλήσεις απέτυχαν"
partialFailure = "Ορισμένες προσκλήσεις απέτυχαν"
allFailed = "Αποτυχία πρόσκλησης χρηστών"
error = "Αποτυχία αποστολής προσκλήσεων"
@@ -5712,7 +5864,7 @@ title = "Διάγραμμα χρήσης Endpoints"
[usage.table]
title = "Αναλυτικά στατιστικά"
endpoint = "Endpoint"
endpoint = "Σημείο τερματισμού"
visits = "Επισκέψεις"
percentage = "Ποσοστό"
noData = "Δεν υπάρχουν διαθέσιμα δεδομένα"
@@ -5773,6 +5925,7 @@ subtitle = "Συνδεθείτε με τον λογαριασμό Stirling"
[setup.selfhosted]
title = "Σύνδεση στον διακομιστή"
subtitle = "Εισαγάγετε τα διαπιστευτήρια του διακομιστή σας"
link = "ή συνδεθείτε σε έναν self-hosted λογαριασμό"
[setup.server]
title = "Σύνδεση σε διακομιστή"
@@ -5791,6 +5944,14 @@ description = "Εισαγάγετε το πλήρες URL του self-hosted δ
emptyUrl = "Εισαγάγετε URL διακομιστή"
unreachable = "Αδυναμία σύνδεσης με τον διακομιστή"
testFailed = "Αποτυχία ελέγχου σύνδεσης"
configFetch = "Αποτυχία ανάκτησης της διαμόρφωσης του διακομιστή. Ελέγξτε το URL και δοκιμάστε ξανά."
[setup.server.error.securityDisabled]
title = "Η σύνδεση δεν είναι ενεργοποιημένη"
body = "Σε αυτόν τον διακομιστή δεν είναι ενεργοποιημένη η σύνδεση. Για να συνδεθείτε σε αυτόν τον διακομιστή, πρέπει να ενεργοποιήσετε τον έλεγχο ταυτότητας:"
step1 = "Ορίστε το DOCKER_ENABLE_SECURITY=true στο περιβάλλον σας"
step2 = "Ή ορίστε security.enableLogin=true στο settings.yml"
step3 = "Επανεκκινήστε τον διακομιστή"
[setup.login]
title = "Σύνδεση"
@@ -5800,6 +5961,13 @@ submit = "Σύνδεση"
signInWith = "Σύνδεση με"
oauthPending = "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας..."
orContinueWith = "Ή συνεχίστε με email"
serverRequirement = "Σημείωση: Ο διακομιστής πρέπει να έχει ενεργοποιημένη τη σύνδεση."
showInstructions = "Πώς ενεργοποιείται;"
hideInstructions = "Απόκρυψη οδηγιών"
instructions = "Για να ενεργοποιήσετε τη σύνδεση στον διακομιστή Stirling PDF:"
instructionsEnvVar = "Ορίστε τη μεταβλητή περιβάλλοντος:"
instructionsOrYml = "Ή στο settings.yml:"
instructionsRestart = "Στη συνέχεια, επανεκκινήστε τον διακομιστή σας για να εφαρμοστούν οι αλλαγές."
[setup.login.username]
label = "Όνομα χρήστη"
@@ -5856,6 +6024,7 @@ earlyAccess = "Πρόωρη πρόσβαση"
reset = "Επαναφορά αλλαγών"
downloadJson = "Λήψη JSON"
generatePdf = "Δημιουργία PDF"
saveChanges = "Αποθήκευση αλλαγών"
[pdfTextEditor.options.autoScaleText]
title = "Αυτόματη προσαρμογή κειμένου στα πλαίσια"
@@ -5893,6 +6062,8 @@ alpha = "Αυτός ο προβολέας άλφα εξελίσσεται ακό
[pdfTextEditor.empty]
title = "Δεν φορτώθηκε έγγραφο"
subtitle = "Φορτώστε ένα αρχείο PDF ή JSON για να ξεκινήσετε την επεξεργασία κειμένου."
dropzone = "Σύρετε και αποθέστε εδώ ένα αρχείο PDF ή JSON, ή κάντε κλικ για περιήγηση"
dropzoneWithFiles = "Επιλέξτε ένα αρχείο από την καρτέλα Αρχεία, ή σύρετε και αποθέστε εδώ ένα αρχείο PDF ή JSON, ή κάντε κλικ για περιήγηση"
[pdfTextEditor.welcomeBanner]
title = "Καλώς ορίσατε στο PDF Text Editor (Πρώιμη πρόσβαση)"

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