Compare commits

...
15 Commits
Author SHA1 Message Date
pixeebot[bot] 7f096297af Hardening suggestions for Stirling-PDF / multiFileAsync (#3923)
I've reviewed the recently opened PR ([3922 - Support multi-file async
job results and ZIP
extraction](https://github.com/Stirling-Tools/Stirling-PDF/pull/3922))
and have identified some area(s) that could benefit from additional
hardening measures.

These changes should help prevent potential security vulnerabilities and
improve overall code quality.

Thank you for your consideration!
🧚🤖  Powered by Pixeebot  

[Feedback](https://ask.pixee.ai/feedback) |
[Community](https://pixee-community.slack.com/signup#/domain-signup) |
[Docs](https://docs.pixee.ai/)
![](https://d1zaessa2hpsmj.cloudfront.net/pixel/v1/track?writeKey=2PI43jNm7atYvAuK7rJUz3Kcd6A&event=PR_HARDENING%7CStirling-Tools%2FStirling-PDF%7C624e04a783753f7728d85d32469b6a2b5d4b113f)

Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
2025-07-10 16:36:19 +01:00
Anthony Stirling 624e04a783 tests and formatting 2025-07-10 15:22:37 +01:00
Anthony Stirling bb9f1d4f8b extra fixes 2025-07-10 14:58:11 +01:00
Anthony StirlingandCopilot 0d967aa5ea Update common/src/main/java/stirling/software/common/service/TaskManager.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-10 14:55:49 +01:00
Anthony Stirling f19ff6691d cleanups 2025-07-10 14:34:43 +01:00
Anthony Stirling 98b4763c96 remove "legacy" 2025-07-10 14:30:22 +01:00
Anthony Stirling 2c9537f786 multi file async 2025-07-10 14:18:35 +01:00
Ludy d17d10b240 chore: update German translation add new keys to ignore list (#3917)
# 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

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

- [ ] 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-07-10 10:44:41 +01:00
Ludy a959e6eb2e fix(css): correct selector for free text editor overlay in PDF viewer CSS (#3916)
# Description of Changes


**What was changed:**  

- The CSS selector in `viewer-redact.css` and `viewer.css` was updated
from
  ```.annotationEditorLayer freeTextEditor .overlay.enabled```  
  to  
  ```.annotationEditorLayer .freeTextEditor .overlay.enabled```  
to properly target the `.freeTextEditor` element under
`.annotationEditorLayer`.

**Why the change was made:**  

- The previous selector was missing the dot before `freeTextEditor`, so
the overlay for free-text annotations was not being displayed as
intended.

---

## 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)
- [ ] 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-07-10 10:39:20 +01:00
吕舒君 74d3d7d2c7 Update messages_zh_CN.properties (#3912)
# Description of Changes

Improved Chinese translation.

---

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

No UI Changes

### Testing (if applicable)

I think translation needs no testing.
2025-07-10 10:38:59 +01:00
tkymmm 6f982c4db4 Update messages_ja_JP.properties (#3906)
# 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-07-10 10:38:44 +01:00
EthanHealy01 ad52ef641f This PR makes the multi-tool usable when zoomed in or when the browser window is narrowed a lot. (#3897)
# Description of Changes

This PR fixes responsiveness issues with the multi-tool interface when
zooming/narrowing the screen, implementing a zoom-responsive sidebar and
resolving navbar scaling conflicts.

## What was changed

• **Zoom-Responsive Sidebar**: Converts horizontal bottom buttons to
vertical left sidebar at 175%+ zoom using CSS media queries.

• **Fixed Navbar Scaling Bug**: Prevents desktop zoom from triggering 3x
mobile navbar scaling by requiring actual mobile device detection.

• **DPR Detection**: Uses Device Pixel Ratio to distinguish window
narrowing (3rem buttons) vs browser zoom (2.5rem buttons)

## Why the change was made

- **Accessibility**: Users zooming for accessibility reasons were
getting poor UX with bottom floating buttons
- **Bug Fix**: Desktop zoom was incorrectly triggering mobile navbar
scaling (3x size), making the interface unusable
- **Multi-context Support**: Different users narrow windows for
split-screen work vs zoom for accessibility - each needs different icon
sizes

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

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

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

### Testing (if applicable)

- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-07-09 13:14:18 +01:00
Anthony StirlingandDario Ghunney Ware 69bbb12ece Survey change to reduce prompts to users (#3913)
# 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.

---------

Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
2025-07-08 19:25:42 +01:00
Dario Ghunney WareandAnthony Stirling 2218f0bffa Fix for generateOpenApiDocs Task (#3911)
# Description of Changes

Fix for `generateOpenApiDocs` task

---

## 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-07-08 18:04:25 +01:00
Anthony Stirling 05c10d3a9f fix for #3907 resource override (#3910)
# 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-07-08 11:57:49 +01:00
25 changed files with 1606 additions and 683 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ Stirling-PDF currently supports 40 languages!
| 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) | ![91%](https://geps.dev/progress/91) |
| German (Deutsch) (de_DE) | ![99%](https://geps.dev/progress/99) |
| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) |
| Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
+32 -5
View File
@@ -15,6 +15,8 @@ plugins {
import com.github.jk1.license.render.*
import org.gradle.internal.os.OperatingSystem
import org.panteleyev.jpackage.ImageType
import java.nio.file.Files
import java.time.Year
@@ -43,9 +45,19 @@ bootJar {
enabled = false
}
// Configure main class for the root project
springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
repositories {
mavenCentral()
maven { url = 'https://build.shibboleth.net/maven/releases' }
}
allprojects {
group = 'stirling.software'
version = '1.0.1'
version = '1.0.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -53,7 +65,6 @@ allprojects {
}
}
tasks.register('writeVersion') {
def propsFile = file("$projectDir/common/src/main/resources/version.properties")
def propsDir = propsFile.parentFile
@@ -200,6 +211,14 @@ openApi {
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
}
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
tasks.named('forkedSpringBootRun') {
dependsOn ':stirling-pdf:bootRun'
doFirst {
println "Delegating forkedSpringBootRun to :stirling-pdf:bootRun"
}
}
//0.11.5 to 2024.11.5
static def getMacVersion(String version) {
def currentYear = Year.now().getValue()
@@ -251,7 +270,7 @@ jpackage {
winUpgradeUuid = "2a43ed0c-b8c2-40cf-89e1-751129b87641" // Unique identifier for updates
winHelpUrl = "https://github.com/Stirling-Tools/Stirling-PDF"
winUpdateUrl = "https://github.com/Stirling-Tools/Stirling-PDF/releases"
type = "exe"
type = ImageType.EXE
installDir = "C:/Program Files/Stirling-PDF"
}
@@ -259,7 +278,7 @@ jpackage {
mac {
appVersion = getMacVersion(project.version.toString())
icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.icns")
type = "dmg"
type = ImageType.DMG
macPackageIdentifier = "Stirling PDF"
macPackageName = "Stirling PDF"
macAppCategory = "public.app-category.productivity"
@@ -281,7 +300,7 @@ jpackage {
linux {
appVersion = project.version
icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.png")
type = "deb" // Can also use "rpm" for Red Hat-based systems
type = ImageType.DEB // Can also use "rpm" for Red Hat-based systems
// Debian package configuration
//linuxPackageName = "stirlingpdf"
@@ -514,6 +533,14 @@ swaggerhubUpload {
}
dependencies {
implementation project(':stirling-pdf')
implementation project(':common')
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
implementation project(':proprietary')
}
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
}
@@ -10,7 +10,6 @@ import java.util.Properties;
import java.util.function.Predicate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -21,6 +20,7 @@ import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ClassUtils;
import org.thymeleaf.spring6.SpringTemplateEngine;
import lombok.Getter;
@@ -148,23 +148,10 @@ public class AppConfig {
}
@Bean(name = "activeSecurity")
public boolean activeSecurity() {
String disableAdditionalFeatures = env.getProperty("DISABLE_ADDITIONAL_FEATURES");
if (disableAdditionalFeatures != null) {
// DISABLE_ADDITIONAL_FEATURES=true means security OFF, so return false
// DISABLE_ADDITIONAL_FEATURES=false means security ON, so return true
return !Boolean.parseBoolean(disableAdditionalFeatures);
}
return env.getProperty("DOCKER_ENABLE_SECURITY", Boolean.class, true);
}
@Bean(name = "missingActiveSecurity")
@ConditionalOnMissingClass(
"stirling.software.proprietary.security.configuration.SecurityConfiguration")
public boolean missingActiveSecurity() {
return true;
return ClassUtils.isPresent(
"stirling.software.proprietary.security.configuration.SecurityConfiguration",
this.getClass().getClassLoader());
}
@Bean(name = "directoryFilter")
@@ -1,6 +1,7 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -26,14 +27,8 @@ public class JobResult {
/** Error message if the job failed */
private String error;
/** The file ID of the result file, if applicable */
private String fileId;
/** Original file name, if applicable */
private String originalFileName;
/** MIME type of the result, if applicable */
private String contentType;
/** List of result files for jobs that produce files */
private List<ResultFile> resultFiles;
/** Time when the job was created */
private LocalDateTime createdAt;
@@ -64,21 +59,6 @@ public class JobResult {
.build();
}
/**
* Mark this job as complete with a file result
*
* @param fileId The file ID of the result
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void completeWithFile(String fileId, String originalFileName, String contentType) {
this.complete = true;
this.fileId = fileId;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a general result
*
@@ -101,6 +81,67 @@ public class JobResult {
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with multiple file results
*
* @param resultFiles The list of result files
*/
public void completeWithFiles(List<ResultFile> resultFiles) {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a single file result (convenience method)
*
* @param fileId The file ID of the result
* @param fileName The file name
* @param contentType The content type of the file
* @param fileSize The size of the file in bytes
*/
public void completeWithSingleFile(
String fileId, String fileName, String contentType, long fileSize) {
ResultFile resultFile =
ResultFile.builder()
.fileId(fileId)
.fileName(fileName)
.contentType(contentType)
.fileSize(fileSize)
.build();
completeWithFiles(List.of(resultFile));
}
/**
* Check if this job has file results
*
* @return true if this job has file results, false otherwise
*/
public boolean hasFiles() {
return resultFiles != null && !resultFiles.isEmpty();
}
/**
* Check if this job has multiple file results
*
* @return true if this job has multiple file results, false otherwise
*/
public boolean hasMultipleFiles() {
return resultFiles != null && resultFiles.size() > 1;
}
/**
* Get all result files
*
* @return List of result files
*/
public List<ResultFile> getAllResultFiles() {
if (resultFiles != null && !resultFiles.isEmpty()) {
return Collections.unmodifiableList(resultFiles);
}
return Collections.emptyList();
}
/**
* Add a note to this job
*
@@ -0,0 +1,26 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a single file result from a job execution */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResultFile {
/** The file ID for accessing the file */
private String fileId;
/** The original file name */
private String fileName;
/** MIME type of the file */
private String contentType;
/** Size of the file in bytes */
private long fileSize;
}
@@ -131,14 +131,46 @@ public class FileStorage {
return Files.exists(filePath);
}
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/
private Path getFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
// Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
/**
@@ -1,15 +1,25 @@
package stirling.software.common.service;
import io.github.pixee.security.ZipSecurity;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PreDestroy;
@@ -17,6 +27,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
/** Manages async tasks and their results */
@Service
@@ -80,8 +91,53 @@ public class TaskManager {
public void setFileResult(
String jobId, String fileId, String originalFileName, String contentType) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFile(fileId, originalFileName, contentType);
log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId);
// Check if this is a ZIP file that should be extracted
if (isZipFile(contentType, originalFileName)) {
try {
List<ResultFile> extractedFiles =
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
jobId,
extractedFiles.size());
return;
}
} catch (Exception e) {
log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
jobId,
e.getMessage());
}
}
// Handle as single file using new ResultFile approach
try {
long fileSize = fileStorage.getFileSize(fileId);
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize);
log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId);
} catch (Exception e) {
log.warn(
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
}
/**
* Set the result of a task as multiple files
*
* @param jobId The job ID
* @param resultFiles The list of result files
*/
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
resultFiles.size());
}
/**
@@ -104,7 +160,7 @@ public class TaskManager {
public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(jobId);
if (jobResult.getResult() == null
&& jobResult.getFileId() == null
&& !jobResult.hasFiles()
&& jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
@@ -186,7 +242,7 @@ public class TaskManager {
failedJobs++;
} else {
successfulJobs++;
if (result.getFileId() != null) {
if (result.hasFiles()) {
fileResultJobs++;
}
}
@@ -250,17 +306,8 @@ public class TaskManager {
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
// If the job has a file result, delete the file
if (result.getFileId() != null) {
try {
fileStorage.deleteFile(result.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file for job {}: {}",
entry.getKey(),
e.getMessage());
}
}
// Clean up file results
cleanupJobFiles(result, entry.getKey());
// Remove the job result
jobResults.remove(entry.getKey());
@@ -290,4 +337,128 @@ public class TaskManager {
cleanupExecutor.shutdownNow();
}
}
/** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) {
if (contentType != null
&& (contentType.equals("application/zip")
|| contentType.equals("application/x-zip-compressed"))) {
return true;
}
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true;
}
return false;
}
/** Extract a ZIP file into individual files and store them */
private List<ResultFile> extractZipToIndividualFiles(
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
// Use buffered reading for memory safety
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
byte[] fileContent = out.toByteArray();
String contentType = determineContentType(entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(individualFileId)
.fileName(entry.getName())
.contentType(contentType)
.fileSize(fileContent.length)
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
}
zipIn.closeEntry();
}
}
// Clean up the original ZIP file after extraction
try {
fileStorage.deleteFile(zipFileId);
log.debug("Cleaned up original ZIP file: {}", zipFileId);
} catch (Exception e) {
log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage());
}
return extractedFiles;
}
/** Determine content type based on file extension */
private String determineContentType(String fileName) {
if (fileName == null) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) {
return MediaType.TEXT_PLAIN_VALUE;
} else if (lowerName.endsWith(".json")) {
return MediaType.APPLICATION_JSON_VALUE;
} else if (lowerName.endsWith(".xml")) {
return MediaType.APPLICATION_XML_VALUE;
} else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) {
return MediaType.IMAGE_JPEG_VALUE;
} else if (lowerName.endsWith(".png")) {
return MediaType.IMAGE_PNG_VALUE;
} else {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
/** Clean up files associated with a job result */
private void cleanupJobFiles(JobResult result, String jobId) {
// Clean up all result files
if (result.hasFiles()) {
for (ResultFile resultFile : result.getAllResultFiles()) {
try {
fileStorage.deleteFile(resultFile.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file {} for job {}: {}",
resultFile.getFileId(),
jobId,
e.getMessage());
}
}
}
}
/** Find the ResultFile metadata for a given file ID by searching through all job results */
public ResultFile findResultFileByFileId(String fileId) {
for (JobResult jobResult : jobResults.values()) {
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return resultFile;
}
}
}
}
return null;
}
}
@@ -18,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
class TaskManagerTest {
@@ -73,13 +74,17 @@ class TaskManagerTest {
}
@Test
void testSetFileResult() {
void testSetFileResult() throws Exception {
// Arrange
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String fileId = "file-id";
String originalFileName = "test.pdf";
String contentType = "application/pdf";
long fileSize = 1024L;
// Mock the fileStorage.getFileSize() call
when(fileStorage.getFileSize(fileId)).thenReturn(fileSize);
// Act
taskManager.setFileResult(jobId, fileId, originalFileName, contentType);
@@ -88,9 +93,17 @@ class TaskManagerTest {
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(fileId, result.getFileId());
assertEquals(originalFileName, result.getOriginalFileName());
assertEquals(contentType, result.getContentType());
assertTrue(result.hasFiles());
assertFalse(result.hasMultipleFiles());
var resultFiles = result.getAllResultFiles();
assertEquals(1, resultFiles.size());
ResultFile resultFile = resultFiles.get(0);
assertEquals(fileId, resultFile.getFileId());
assertEquals(originalFileName, resultFile.getFileName());
assertEquals(contentType, resultFile.getContentType());
assertEquals(fileSize, resultFile.getFileSize());
assertNotNull(result.getCompletedAt());
}
@@ -163,8 +176,11 @@ class TaskManagerTest {
}
@Test
void testGetJobStats() {
void testGetJobStats() throws Exception {
// Arrange
// Mock fileStorage.getFileSize for file operations
when(fileStorage.getFileSize("file-id")).thenReturn(1024L);
// 1. Create active job
String activeJobId = "active-job";
taskManager.createTask(activeJobId);
@@ -216,9 +232,15 @@ class TaskManagerTest {
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true);
ReflectionTestUtils.setField(oldJob, "fileId", "file-id");
ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf");
ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf");
// Create a ResultFile and set it using the new approach
ResultFile resultFile = ResultFile.builder()
.fileId("file-id")
.fileName("test.pdf")
.contentType("application/pdf")
.fileSize(1024L)
.build();
ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile));
when(fileStorage.deleteFile("file-id")).thenReturn(true);
+3
View File
@@ -195,6 +195,7 @@ ignore = [
'PDFToBook.selectText.1',
'PDFToText.tags',
'addPageNumbers.selectText.3',
'adminUserSettings.team',
'alphabet',
'audit.dashboard.modal.id',
'audit.dashboard.status',
@@ -206,6 +207,7 @@ ignore = [
'cookieBanner.popUp.acceptAllBtn',
'endpointStatistics.top10',
'endpointStatistics.top20',
'fakeScan.quality.medium',
'fileChooser.dragAndDrop',
'home.pipeline.title',
'lang.afr',
@@ -241,6 +243,7 @@ ignore = [
'pro',
'redact.zoom',
'sponsor',
'team.status',
'text',
'validateSignature.cert.bits',
'validateSignature.cert.version',
+5
View File
@@ -146,5 +146,10 @@ bootJar {
}
}
// Configure main class for Spring Boot
springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
bootJar.dependsOn ':common:jar'
bootJar.dependsOn ':proprietary:jar'
@@ -26,8 +26,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addResourceHandler("/**")
.addResourceLocations(
"file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/");
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
// .setCachePeriod(0); // Optional: disable caching
}
}
@@ -1,7 +1,11 @@
package stirling.software.common.controller;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +18,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
@@ -78,16 +83,31 @@ public class JobController {
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
}
if (result.getFileId() != null) {
// Handle multiple files - return metadata for client to download individually
if (result.hasMultipleFiles()) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(
Map.of(
"jobId",
jobId,
"hasMultipleFiles",
true,
"files",
result.getAllResultFiles()));
}
// Handle single file (download directly)
if (result.hasFiles() && !result.hasMultipleFiles()) {
try {
byte[] fileContent = fileStorage.retrieveBytes(result.getFileId());
List<ResultFile> files = result.getAllResultFiles();
ResultFile singleFile = files.get(0);
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
return ResponseEntity.ok()
.header("Content-Type", result.getContentType())
.header("Content-Type", singleFile.getContentType())
.header(
"Content-Disposition",
"form-data; name=\"attachment\"; filename=\""
+ result.getOriginalFileName()
+ "\"")
createContentDispositionHeader(singleFile.getFileName()))
.body(fileContent);
} catch (Exception e) {
log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e);
@@ -170,4 +190,127 @@ public class JobController {
}
}
}
/**
* Get the list of files for a job
*
* @param jobId The job ID
* @return List of files for the job
*/
@GetMapping("/api/v1/general/job/{jobId}/result/files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
}
if (!result.isComplete()) {
return ResponseEntity.badRequest().body("Job is not complete yet");
}
if (result.getError() != null) {
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
}
List<ResultFile> files = result.getAllResultFiles();
return ResponseEntity.ok(
Map.of(
"jobId", jobId,
"fileCount", files.size(),
"files", files));
}
/**
* Get metadata for an individual file by its file ID
*
* @param fileId The file ID
* @return The file metadata
*/
@GetMapping("/api/v1/general/files/{fileId}/metadata")
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// Find the file metadata from any job that contains this file
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
if (resultFile != null) {
return ResponseEntity.ok(resultFile);
} else {
// File exists but no metadata found, get basic info efficiently
long fileSize = fileStorage.getFileSize(fileId);
return ResponseEntity.ok(
Map.of(
"fileId",
fileId,
"fileName",
"unknown",
"contentType",
"application/octet-stream",
"fileSize",
fileSize));
}
} catch (Exception e) {
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file metadata: " + e.getMessage());
}
}
/**
* Download an individual file by its file ID
*
* @param fileId The file ID
* @return The file content
*/
@GetMapping("/api/v1/general/files/{fileId}")
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
try {
// Verify file exists
if (!fileStorage.fileExists(fileId)) {
return ResponseEntity.notFound().build();
}
// Retrieve file content
byte[] fileContent = fileStorage.retrieveBytes(fileId);
// Find the file metadata from any job that contains this file
// This is for getting the original filename and content type
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
String fileName = resultFile != null ? resultFile.getFileName() : "download";
String contentType =
resultFile != null ? resultFile.getContentType() : "application/octet-stream";
return ResponseEntity.ok()
.header("Content-Type", contentType)
.header("Content-Disposition", createContentDispositionHeader(fileName))
.body(fileContent);
} catch (Exception e) {
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
return ResponseEntity.internalServerError()
.body("Error retrieving file: " + e.getMessage());
}
}
/**
* Create Content-Disposition header with UTF-8 filename support
*
* @param fileName The filename to encode
* @return Content-Disposition header value
*/
private String createContentDispositionHeader(String fileName) {
try {
String encodedFileName =
URLEncoder.encode(fileName, StandardCharsets.UTF_8)
.replace("+", "%20"); // URLEncoder uses + for spaces, but we want %20
return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName;
} catch (Exception e) {
// Fallback to basic filename if encoding fails
return "attachment; filename=\"" + fileName + "\"";
}
}
}
File diff suppressed because it is too large Load Diff
@@ -170,9 +170,9 @@ sizes.medium=中型尺寸
sizes.large=大型尺寸
sizes.x-large=超大型尺寸
error.pdfPassword=PDF文档有密码,未提供密码或密码不正确
error.pdfCorrupted=PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.
error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.
error.pdfCorruptedDuring=Error {0}: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.
error.pdfCorrupted=PDF文件似乎已损坏或损坏。在继续此操作之前,请先尝试使用“修复PDF”功能修复文件。
error.pdfCorruptedMultiple=一个或多个PDF文件似乎已损坏或损坏。请先对单个文件使用“修复PDF”功能,然后再尝试合并它们。
error.pdfCorruptedDuring=错误 {0} :PDF文件似乎已损坏或损坏。在继续此操作之前,请先尝试使用“修复PDF”功能修复文件。
# Frontend corruption error messages
error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding.
@@ -183,54 +183,54 @@ error.pdfEncryption=The PDF appears to have corrupted encryption data. This can
error.fileProcessing=An error occurred while processing the file during {0} operation: {1}
# Generic error message templates
error.toolNotInstalled={0} is not installed
error.toolRequired={0} is required for {1}
error.conversionFailed={0} conversion failed
error.commandFailed={0} command failed
error.toolNotInstalled={0} 未被安装
error.toolRequired={1} 依赖于 {0}
error.conversionFailed={0} 转换失败
error.commandFailed={0} 执行命令失败
error.algorithmNotAvailable={0} algorithm not available
error.optionsNotSpecified={0} options are not specified
error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL
error.endpointDisabled=该端点被管理员禁用
error.urlNotReachable=URL无法访问,请提供有效的URL
# DPI and image rendering messages - used by frontend for dynamic translation
# Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message
# Frontend parses this and replaces with localized versions using these keys
error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.
error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).
error.pageTooBigFor300Dpi=PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion.
error.dpiExceedsLimit=DPI值 {0} 超过 {1} 的最大安全限值。高DPI值可能导致内存溢出或崩溃。请尝试较低的DPI值。
error.pageTooBigForDpi=PDF页面 {0} 太大,无法以 {1} DPI呈现。请尝试较低的DPI值(建议:150或更低)。
error.pageTooBigExceedsArray=PDF页面 {0} 太大,无法以 {1} DPI呈现。生成的图像将超过Java的最大数组大小。请尝试较低的DPI值(建议:150或更低)。
error.pageTooBigFor300Dpi=PDF页面 {0} 太大,无法以300 DPI呈现。生成的图像将超过Java的最大数组大小。请使用较低的DPI值进行pdf到图像的转换。
# URL and website conversion messages
# System requirements messages
# Authentication and security messages
error.apiKeyInvalid=API key is not valid.
error.userNotFound=User not found.
error.passwordRequired=Password must not be null.
error.accountLocked=Your account has been locked due to too many failed login attempts.
error.invalidEmail=Invalid email addresses provided.
error.emailAttachmentRequired=An attachment is required to send the email.
error.signatureNotFound=Signature file not found.
error.apiKeyInvalid=API秘钥无效。
error.userNotFound=用户未找到。
error.passwordRequired=密码不应为空。
error.accountLocked=由于过多的失败登录尝试,你的账户被锁定。
error.invalidEmail=提供了无效的电子邮件地址。
error.emailAttachmentRequired=发送该电子邮件需要附件。
error.signatureNotFound=证书文件未找到。
# File processing messages
error.fileNotFound=File not found with ID: {0}
error.fileNotFound=具有此ID的文件未找到: {0}
# Database and configuration messages
error.noBackupScripts=No backup scripts were found.
error.unsupportedProvider={0} is not currently supported.
error.pathTraversalDetected=Path traversal detected for security reasons.
error.noBackupScripts=未找到备份脚本。
error.unsupportedProvider={0} 当前不被支持。
error.pathTraversalDetected=检测到存在安全问题的路径遍历操作。
# Validation messages
error.invalidArgument=Invalid argument: {0}
error.argumentRequired={0} must not be null
error.operationFailed=Operation failed: {0}
error.angleNotMultipleOf90=Angle must be a multiple of 90
error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document
error.fontLoadingFailed=Error processing font file
error.fontDirectoryReadFailed=Failed to read font directory
error.invalidArgument=不合法的参数: {0}
error.argumentRequired={0} 不应当为空
error.operationFailed=操作失败: {0}
error.angleNotMultipleOf90=角度应当为90°的倍数
error.pdfBookmarksNotFound=文档中没有找到PDF书签/大纲
error.fontLoadingFailed=处理字体文件出错
error.fontDirectoryReadFailed=无法读取字体目录
delete=删除
username=用户名
password=密码
@@ -260,7 +260,7 @@ disabledCurrentUserMessage=无法禁用当前用户。
downgradeCurrentUserLongMessage=无法降级当前用户的角色。因此,当前用户将不会显示。
userAlreadyExistsOAuthMessage=该用户已作为 OAuth2 用户存在。
userAlreadyExistsWebMessage=该用户已作为 Web 用户存在。
invalidRoleMessage=Invalid role.
invalidRoleMessage=无效角色。
error=错误
oops=哎呀!
help=帮助
@@ -272,8 +272,8 @@ donate=捐款
color=颜色
sponsor=赞助
info=信息
pro=专业版
proFeatures=Pro Features
pro=Pro版本
proFeatures=Pro版本功能
page=页面
pages=页码
loading=加载中...
@@ -281,11 +281,11 @@ addToDoc=添加至文件
reset=重置
apply=应用
noFileSelected=未选择文件,请上传一个文件。
view=View
cancel=Cancel
view=预览
cancel=取消
back.toSettings=Back to Settings
back.toHome=Back to Home
back.toSettings=回到设置
back.toHome=回到主页
back.toAdmin=Back to Admin
legal.privacy=隐私政策
@@ -450,17 +450,17 @@ adminUserSettings.teamName=团队名称
adminUserSettings.teamExists=该团队已存在
adminUserSettings.teamCreated=团队成功创建
adminUserSettings.teamChanged=用户所属团队已更新
adminUserSettings.teamHidden=Hidden
adminUserSettings.teamHidden=隐藏
adminUserSettings.totalMembers=成员总数
adminUserSettings.confirmDeleteTeam=您确定要删除此团队吗?
teamCreated=Team created successfully
teamExists=A team with that name already exists
teamNameExists=Another team with that name already exists
teamNotFound=Team not found
teamDeleted=Team deleted
teamHasUsers=Cannot delete a team with users assigned
teamRenamed=Team renamed successfully
teamCreated=团队创建成功
teamExists=同名称的团队已存在
teamNameExists=同名称的团队已存在
teamNotFound=未找到团队
teamDeleted=团队被删除
teamHasUsers=不能删除已经被分配用户的团队
teamRenamed=团队重命名成功
# Team user management
team.addUser=添加用户到团队
@@ -472,16 +472,16 @@ team.back=返回团队列表
team.internal=内部团队
team.internalTeamNotAccessible=内部团队为系统预设,无法访问
team.cannotMoveInternalUsers=内部团队中的用户无法转移至其他团队
team.hidden=Hidden
team.name=Team Name
team.totalMembers=Total Members
team.members=Members
team.username=Username
team.role=Role
team.status=Status
team.enabled=Enabled
team.disabled=Disabled
team.noMembers=This team has no members yet.
team.hidden=隐藏
team.name=团队名称
team.totalMembers=总人数
team.members=成员
team.username=用户名
team.role=角色
team.status=状态
team.enabled=启用
team.disabled=禁用
team.noMembers=这个团队还没有成员。
@@ -585,9 +585,9 @@ home.addImage.title=在 PDF 中添加图片
home.addImage.desc=将图像添加到 PDF 的指定位置。
addImage.tags=图像、JPG、图片、照片
home.attachments.title=Add Attachments
home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF
attachments.tags=embed,attach,file,attachment,attachments
home.attachments.title=添加附件
home.attachments.desc=在 PDF 中添加或删除嵌入文件(附件)
attachments.tags=嵌入、附件、文件、附加
home.watermark.title=添加水印
home.watermark.desc=在 PDF 中添加自定义水印。
@@ -724,8 +724,8 @@ home.crop.desc=裁剪 PDF 以减小其文件大小(保留文本!)
crop.tags=修剪、缩小、编辑、形状
home.autoSplitPDF.title=自动拆分页面
home.autoSplitPDF.desc=使用物理扫描页面分割器 QR 代码自动拆分扫描的 PDF
autoSplitPDF.tags=基于 QR 码、分离、扫描分割、整理
home.autoSplitPDF.desc=使用物理扫描页面分割器二维码自动拆分扫描的 PDF
autoSplitPDF.tags=基于二维码、分离、扫描分割、整理
home.sanitizePdf.title=清理
home.sanitizePdf.desc=从 PDF 文件中删除脚本和其他元素
@@ -831,11 +831,11 @@ validateSignature.tags=签名,验证,验证,PDF,证书,数字签名,
replace-color.title=替换-反转-颜色
replace-color.header=替换-反转 PDF 颜色
home.replaceColorPdf.title=替换和反转颜色
home.replaceColorPdf.desc=替换 PDF 中文本和背景的颜色,并将PDF全色反转以减小文件大小
home.replaceColorPdf.desc=替换 PDF 中文本和背景的颜色,并将PDF反转颜色以减小文件大小
replaceColorPdf.tags=更换颜色,页面操作,后端,服务器端
replace-color.selectText.1=替换或反转颜色选项
replace-color.selectText.2=默认(默认高对比度颜色)
replace-color.selectText.3=定制(定制的颜色)
replace-color.selectText.3=定制定制的颜色
replace-color.selectText.4=全反转(反转所有颜色)
replace-color.selectText.5=高对比度颜色选项
replace-color.selectText.6=黑底白字
@@ -1270,11 +1270,11 @@ addImage.upload=添加图片
addImage.submit=添加图片
#attachments
attachments.title=Add Attachments
attachments.header=Add attachments
attachments.description=Allows you to add attachments to the PDF
attachments.descriptionPlaceholder=Enter a description for the attachments...
attachments.addButton=Add Attachments
attachments.title=添加附件
attachments.header=添加附件
attachments.description=允许您向PDF添加附件
attachments.descriptionPlaceholder=为附件输入描述…
attachments.addButton=添加附件
#merge
merge.title=合并
@@ -1664,7 +1664,7 @@ fileChooser.dragAndDropPDF=拖放PDF文件
fileChooser.dragAndDropImage=拖放图片文件
fileChooser.hoveredDragAndDrop=拖放文件到此处
fileChooser.extractPDF=处理中...
fileChooser.addAttachments=drag & drop attachments here
fileChooser.addAttachments=拖放附件到此处
#release notes
releases.footer=版本
@@ -1722,14 +1722,14 @@ audit.dashboard.totalEvents=Total Events
# Audit Dashboard Tabs
audit.dashboard.tab.dashboard=Dashboard
audit.dashboard.tab.events=Audit Events
audit.dashboard.tab.export=Export
audit.dashboard.tab.export=导出
# Dashboard Charts
audit.dashboard.eventsByType=Events by Type
audit.dashboard.eventsByUser=Events by User
audit.dashboard.eventsOverTime=Events Over Time
audit.dashboard.period.7days=7 Days
audit.dashboard.period.30days=30 Days
audit.dashboard.period.90days=90 Days
audit.dashboard.period.7days=7
audit.dashboard.period.30days=30
audit.dashboard.period.90days=90
# Events Tab
audit.dashboard.auditEvents=Audit Events
@@ -1744,11 +1744,11 @@ audit.dashboard.filter.reset=Reset Filters
# Table Headers
audit.dashboard.table.id=ID
audit.dashboard.table.time=Time
audit.dashboard.table.user=User
audit.dashboard.table.type=Type
audit.dashboard.table.details=Details
audit.dashboard.table.viewDetails=View Details
audit.dashboard.table.time=时间
audit.dashboard.table.user=用户
audit.dashboard.table.type=类型
audit.dashboard.table.details=详情
audit.dashboard.table.viewDetails=查看详情
# Pagination
audit.dashboard.pagination.show=Show
@@ -98,6 +98,61 @@
font-weight: bold;
}
/* Responsive text sizing for drag & drop area */
#fileInputText {
font-size: 16px;
font-weight: bold;
color: var(--md-sys-color-on-surface);
max-width: 100%;
padding: 0 10px;
box-sizing: border-box;
}
/* Progressive font size reduction for high zoom levels */
@media only screen and (max-width: 1280px) {
#fileInputText {
font-size: 15px;
}
}
@media only screen and (max-width: 960px) {
#fileInputText {
font-size: 14px;
}
}
@media only screen and (max-width: 768px) {
#fileInputText {
font-size: 13px;
}
}
@media only screen and (max-width: 640px) {
#fileInputText {
font-size: 12px;
}
}
@media only screen and (max-width: 480px) {
#fileInputText {
font-size: 11px;
line-height: 1.3;
}
}
@media only screen and (max-width: 384px) {
#fileInputText {
font-size: 10px;
line-height: 1.4;
}
/* Also scale the container at ultra-high zoom */
.input-container {
height: 130px;
padding: 5px;
}
}
.file-input-btn {
display: inline-block;
@@ -1,8 +1,11 @@
/* Base Container Styles */
.multi-tool-container {
max-width: 95vw;
margin: 0 auto;
max-width: 100vw;
margin: 0;
padding: 0 20px;
}
/* Form Elements */
label {
text-align: left;
display: block;
@@ -17,6 +20,7 @@ label {
flex-grow: 5;
}
/* Action Bar Styles */
.mt-action-bar {
display: flex;
gap: 10px;
@@ -28,16 +32,17 @@ label {
padding: 1.25rem;
border-radius: 2rem;
margin: 0px 25px;
justify-content:center;
justify-content: center;
}
.mt-action-bar>* {
.mt-action-bar > * {
padding-bottom: 0.5rem;
}
.mt-file-uploader {
width:100%
width: 100%;
}
.mt-action-bar svg,
.mt-action-btn svg {
width: 20px;
@@ -50,20 +55,21 @@ label {
gap: 10px;
}
/* Action Button Styles */
.mt-action-btn {
position: sticky;
bottom: 10%;
margin: auto;
margin-bottom: 25px;
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
border-radius: 2rem;
z-index: 12;
background-color: var(--md-sys-color-surface-container-low) ;
background-color: var(--md-sys-color-surface-container-low);
display: flex;
gap: 10px;
padding: 12px 0px 0px;
width: fit-content;
justify-content: center;
padding: 10px 20px
padding: 10px 20px;
transition: all 0.3s ease-in-out;
}
.mt-action-btn .btn {
@@ -71,68 +77,132 @@ label {
height: 3.5rem;
border-radius: 20px;
padding: 0;
position: relative;
}
/* Card and Container Styles */
.bg-card {
background-color: var(--md-sys-color-surface-5);
border-radius: 3rem;
padding: 25px 0;
padding: 15px 0;
margin-left: 55px;
margin-right: 20px;
display: flex;
flex-direction: column;
align-items: stretch;
}
#pages-container-wrapper {
width: 100%;
display: flex;
flex-direction: column;
padding: 1rem;
justify-content: center;
padding: 0.75rem;
border-radius: 25px;
min-height: 275px;
margin: 0 0 30px 0;
margin: 0 0 20px 0;
}
#pages-container {
margin: 0 auto;
width: 95%;
font-size: 0;
display: inline-flex;
flex-wrap: wrap;
gap: 20px;
justify-content: flex-start;
width: fit-content;
max-width: 100%;
}
/* width */
/* Scrollbar Styles */
#pages-container-wrapper::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/* Track */
#pages-container-wrapper::-webkit-scrollbar-track {
background: var(--scroll-bar-color);
}
/* Handle */
#pages-container-wrapper::-webkit-scrollbar-thumb {
border-radius: 10px;
background: var(--scroll-bar-thumb);
}
/* Handle on hover */
#pages-container-wrapper::-webkit-scrollbar-thumb:hover {
background: var(--scroll-bar-thumb-hover);
}
/* Page Container Base Styles */
.page-container {
display: inline-block;
list-style-type: none;
width: 250px;
height: 250px;
width: 260px;
height: 260px;
line-height: 50px;
margin: 15px 25px;
margin-top: 15px;
box-sizing: border-box;
text-align: center;
aspect-ratio: 1;
position: relative;
user-select: none;
transition: width 1s linear;
transition: width 0.3s ease-in-out;
}
/* Responsive Page Container Sizes */
@media only screen and (max-width: 480px) {
.page-container {
width: calc(100vw - 90px);
height: calc(100vw - 90px);
max-width: 300px;
max-height: 300px;
margin: 5px auto;
}
#pages-container { gap: 10px; }
}
@media only screen and (min-width: 481px) and (max-width: 768px) {
.page-container {
width: calc((100vw - 120px - 12px) / 2);
height: calc((100vw - 120px - 12px) / 2);
max-width: 250px;
max-height: 250px;
margin: 6px;
}
#pages-container { gap: 12px; }
}
@media only screen and (min-width: 769px) and (max-width: 1199px) {
.page-container {
width: calc((100vw - 140px - 45px) / 3);
height: calc((100vw - 140px - 45px) / 3);
max-width: 220px;
max-height: 220px;
margin: 7px;
}
#pages-container { gap: 15px; }
}
@media only screen and (min-width: 1200px) and (max-width: 1280px) {
.page-container {
width: calc((100vw - 160px - 60px) / 4);
height: calc((100vw - 160px - 60px) / 4);
max-width: 200px;
max-height: 200px;
margin: 8px;
}
#pages-container { gap: 17px; }
}
@media only screen and (min-width: 1281px) {
.page-container {
width: calc((100vw - 180px - 80px) / 5);
height: calc((100vw - 180px - 80px) / 5);
max-width: 190px;
max-height: 190px;
margin: 10px;
}
#pages-container { gap: 20px; }
}
/* Split Page Styles */
.page-container.split-before {
border-left: 1px dashed var(--md-sys-color-on-surface);
padding-left: -1px;
@@ -146,56 +216,35 @@ label {
display: none;
}
/* RTL Language Support */
.page-container:last-child:lang(ar),
/* Arabic */
.page-container:last-child:lang(he),
/* Hebrew */
.page-container:last-child:lang(fa),
/* Persian */
.page-container:last-child:lang(ur),
/* Urdu */
.page-container:last-child:lang(ckb),
/* Sorani Kurdish */
.page-container:last-child:lang(ks),
/* Kashmiri */
.page-container:last-child:lang(kk),
/* Kazakh */
.page-container:last-child:lang(uz),
/* Uzbek */
.page-container:last-child:lang(ky),
/* Kyrgyz */
.page-container:last-child:lang(bal),
/* Baluchi */
.page-container:last-child:lang(dv),
/* Divehi */
.page-container:last-child:lang(ps),
/* Pashto */
.page-container:last-child:lang(sdg),
/* Southern Kurdish */
.page-container:last-child:lang(syr),
/* Syriac */
.page-container:last-child:lang(mzn),
/* Mazanderani */
.page-container:last-child:lang(tgl),
/* Tagalog */
.page-container:last-child:lang(pnb),
/* Western Punjabi */
.page-container:last-child:lang(ug),
/* Uyghur */
.page-container:last-child:lang(nqo),
/* N'Ko */
.page-container:last-child:lang(bqi)
/* Bakhtiari */
{
.page-container:last-child:lang(bqi) {
margin-left: auto !important;
margin-right: 0 !important;
}
/* Page Image Styles */
.page-container img {
max-width: calc(100% - 15px);
max-height: calc(100% - 15px);
max-width: calc(100% - 8px);
max-height: calc(100% - 8px);
display: block;
position: absolute;
left: 50%;
@@ -206,10 +255,7 @@ label {
transition: rotate 0.3s;
}
#add-pdf-button {
margin: 0 auto;
}
/* Page Number Styles */
.page-number {
position: absolute;
top: 5px;
@@ -224,14 +270,20 @@ label {
font-weight: 450;
}
/* Tool Header and Button Styles */
.tool-header {
margin: 0.5rem 1rem 2rem;
margin: 0.5rem 1rem 1rem;
}
#select-pages-button {
opacity: 0.5;
}
#add-pdf-button {
margin: 0 auto;
}
/* Selected Pages Container Styles */
.selected-pages-container {
background-color: var(--md-sys-color-surface);
border-radius: 16px;
@@ -247,12 +299,42 @@ label {
margin-bottom: 10px;
}
.selected-pages-header {
margin-bottom: 15px;
}
.selected-pages-header h5 {
margin: 0 0 8px 0 !important;
font-size: 1.1rem;
font-weight: 600;
color: var(--md-sys-color-on-surface);
}
.selected-pages-header #csv-input {
width: 100%;
height: 2.5rem;
border-radius: 8px;
border: 1px solid var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
padding: 0 12px;
font-size: 0.95rem;
}
.selected-pages-header #csv-input:focus {
outline: none;
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 2px rgba(var(--md-sys-color-primary-rgb), 0.2);
}
/* Pages List Styles */
.pages-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 0;
list-style: none;
min-height: 0;
max-height: 10rem;
overflow: auto;
}
@@ -293,12 +375,251 @@ label {
font-size: medium;
}
.btn {
position: relative;
/* Zoom Level Responsive Styles */
@media only screen and (min-width: 2000px) {
.mt-action-btn {
gap: 12px;
padding: 12px 24px;
border-radius: 2.5rem;
}
.mt-action-btn .btn {
width: 4rem;
height: 4rem;
border-radius: 22px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 1.7rem;
}
}
@media only screen and (max-width: 767px) { #pages-container { width:300px; } }
@media only screen and (min-width: 768px) and (max-width: 991px) { #pages-container { width: 600px; } }
@media only screen and (min-width: 992px) and (max-width: 1199px) { #pages-container { width: 900px; } }
@media only screen and (min-width: 1200px) and (max-width: 1399px) { #pages-container { width: 900px; } }
@media only screen and (min-width: 1399px) { #pages-container { width: 1200px; } }
@media only screen and (min-width: 2560px) {
.mt-action-btn {
gap: 15px;
padding: 15px 30px;
border-radius: 3rem;
}
.mt-action-btn .btn {
width: 5rem;
height: 5rem;
border-radius: 28px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 2.1rem;
}
}
@media only screen and (min-width: 3840px) {
.mt-action-btn {
gap: 20px;
padding: 20px 40px;
border-radius: 4rem;
}
.mt-action-btn .btn {
width: 7rem;
height: 7rem;
border-radius: 40px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 3rem;
}
}
@media only screen and (min-width: 7680px) {
.mt-action-btn {
gap: 40px;
padding: 40px 80px;
border-radius: 8rem;
}
.mt-action-btn .btn {
width: 14rem;
height: 14rem;
border-radius: 80px;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 6rem;
}
}
/* Zoom-responsive Sidebar Styles */
@media only screen and (max-width: 1280px) {
.container {
position: relative;
}
.mt-action-btn {
position: fixed !important;
left: 10px !important;
top: 80px !important;
bottom: 20px !important;
margin: 0 !important;
transform: none !important;
border-radius: 16px !important;
background-color: var(--md-sys-color-surface-container-low);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
height: calc(100vh - 100px) !important;
overflow-y: auto !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
z-index: 12;
width: 70px !important;
gap: 8px !important;
padding: 12px 8px !important;
box-sizing: border-box !important;
}
.mt-action-btn:has(.btn:nth-last-child(n+7)) {
justify-content: flex-start !important;
}
.mt-action-btn::-webkit-scrollbar {
display: none !important;
}
.mt-action-btn .btn {
width: 48px !important;
height: 48px !important;
border-radius: 12px !important;
padding: 0 !important;
margin: 0 !important;
flex-shrink: 0;
font-size: 16px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 20px !important;
line-height: 1 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
height: 100% !important;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
}
.multi-tool-container {
margin-left: 0 !important;
max-width: 100% !important;
}
.mt-action-bar {
margin-left: 25px !important;
margin-right: 25px !important;
}
.mt-action-btn:not(:has(*:nth-child(6))) {
justify-content: center !important;
}
}
/* Mobile and High Zoom Responsive Styles */
@media only screen and (max-width: 960px) {
.mt-action-btn {
left: 8px !important;
top: 75px !important;
bottom: 15px !important;
height: calc(100vh - 90px) !important;
}
}
@media only screen and (max-width: 768px) {
.mt-action-btn {
left: 5px !important;
width: 60px !important;
top: calc(var(--navbar-height, 60px) + 10px) !important;
bottom: 10px !important;
height: calc(100vh - var(--navbar-height, 60px) - 20px) !important;
}
.mt-action-btn .btn {
width: 40px !important;
height: 40px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 16px !important;
}
#pages-container {
margin: 0 auto !important;
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
width: 100% !important;
}
.page-container {
width: calc(100vw - 55px) !important;
height: calc(100vw - 60px) !important;
max-width: 350px !important;
max-height: 350px !important;
margin: 5px auto !important;
}
}
@media only screen and (max-width: 480px) {
.mt-action-btn {
left: 2px !important;
top: calc(var(--navbar-height, 60px) + 5px) !important;
bottom: 5px !important;
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
width: 50px !important;
gap: 6px !important;
padding: 10px 6px !important;
}
.mt-action-btn .btn {
width: 32px !important;
height: 32px !important;
border-radius: 10px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 14px !important;
}
}
@media only screen and (max-width: 384px) {
.mt-action-btn {
left: 2px !important;
top: calc(var(--navbar-height, 60px) + 5px) !important;
bottom: 5px !important;
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
width: 40px !important;
gap: 4px !important;
padding: 8px 4px !important;
}
.mt-action-btn .btn {
width: 28px !important;
height: 28px !important;
border-radius: 8px !important;
box-sizing: border-box !important;
}
.mt-action-btn .btn .material-symbols-rounded {
font-size: 12px !important;
}
.page-container {
width: calc(100vw - 55px) !important;
height: calc(100vw - 60px) !important;
max-width: 280px !important;
max-height: 280px !important;
margin: 3px auto !important;
}
.page-container img {
max-width: calc(100% - 4px) !important;
max-height: calc(100% - 4px) !important;
}
}
@@ -63,6 +63,79 @@
transform: translateY(-2px);
}
/* Responsive navbar brand - prevent hamburger from wrapping */
.navbar-brand {
display: flex !important;
align-items: center;
gap: 8px;
min-width: 0; /* Allow shrinking */
}
.navbar-brand .icon-text {
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progressive text shrinking to prevent hamburger wrap */
@media only screen and (max-width: 480px) {
.navbar-brand .icon-text {
font-size: 1.3rem;
}
.main-icon {
width: 32px;
height: 32px;
}
}
@media only screen and (max-width: 420px) {
.navbar-brand .icon-text {
font-size: 1.1rem;
}
.main-icon {
width: 28px;
height: 28px;
}
}
@media only screen and (max-width: 380px) {
.navbar-brand .icon-text {
font-size: 1rem;
}
.main-icon {
width: 24px;
height: 24px;
}
}
@media only screen and (max-width: 340px) {
.navbar-brand .icon-text {
font-size: 0.9rem;
}
.main-icon {
width: 20px;
height: 20px;
}
}
/* Ultra-small screens - hide text, keep icon */
@media only screen and (max-width: 320px) {
.navbar-brand .icon-text {
display: none;
}
.main-icon {
width: 24px;
height: 24px;
}
}
.nav-icon {
vertical-align: middle;
font-size: 2rem !important;
@@ -2,6 +2,13 @@
if (window.isDownloadScriptInitialized) return; // Prevent re-execution
window.isDownloadScriptInitialized = true;
// Global PDF processing count tracking for survey system
window.incrementPdfProcessingCount = function() {
let pdfProcessingCount = parseInt(localStorage.getItem('pdfProcessingCount') || '0');
pdfProcessingCount++;
localStorage.setItem('pdfProcessingCount', pdfProcessingCount.toString());
};
const {
pdfPasswordPrompt,
multipleInputsForSingleRequest,
@@ -312,6 +319,11 @@
pdf_pages: pageCount,
});
}
// Increment PDF processing count for survey tracking
if (success && typeof window.incrementPdfProcessingCount === 'function') {
window.incrementPdfProcessingCount();
}
}
}
@@ -271,6 +271,11 @@ class PdfContainer {
pdf_pages: pageCount,
});
}
// Increment PDF processing count for survey tracking
if (success && typeof window.incrementPdfProcessingCount === 'function') {
window.incrementPdfProcessingCount();
}
} catch { }
}
@@ -66,19 +66,16 @@ document.addEventListener('DOMContentLoaded', function () {
const dontShowAgain = document.getElementById('dontShowAgain');
const takeSurveyButton = document.getElementById('takeSurvey');
const viewThresholds = [5, 10, 15, 22, 30, 50, 75, 100, 150, 200];
const pdfProcessingThresholds = [8, 15, 22, 35, 50, 75, 100, 150];
// Check if survey version changed and reset page views if it did
// Check if survey version changed and reset PDF processing count if it did
const storedVersion = localStorage.getItem('surveyVersion');
if (storedVersion && storedVersion !== surveyVersion) {
localStorage.setItem('pageViews', '0');
localStorage.setItem('pdfProcessingCount', '0');
localStorage.setItem('surveyVersion', surveyVersion);
}
let pageViews = parseInt(localStorage.getItem('pageViews') || '0');
pageViews++;
localStorage.setItem('pageViews', pageViews.toString());
let pdfProcessingCount = parseInt(localStorage.getItem('pdfProcessingCount') || '0');
function shouldShowSurvey() {
if(!window.showSurvey) {
@@ -90,11 +87,11 @@ document.addEventListener('DOMContentLoaded', function () {
}
// If survey version changed and we hit a threshold, show the survey
if (localStorage.getItem('surveyVersion') !== surveyVersion && viewThresholds.includes(pageViews)) {
if (localStorage.getItem('surveyVersion') !== surveyVersion && pdfProcessingThresholds.includes(pdfProcessingCount)) {
return true;
}
return viewThresholds.includes(pageViews);
return pdfProcessingThresholds.includes(pdfProcessingCount);
}
if (shouldShowSurvey()) {
@@ -1838,7 +1838,7 @@
height: 100%;
}
.annotationEditorLayer freeTextEditor .overlay.enabled {
.annotationEditorLayer .freeTextEditor .overlay.enabled {
display: block;
}
@@ -1852,7 +1852,7 @@
height:100%;
}
.annotationEditorLayer freeTextEditor .overlay.enabled{
.annotationEditorLayer .freeTextEditor .overlay.enabled{
display:block;
}
@@ -35,7 +35,7 @@
const browserZoom = currentDPR / systemDPR;
// Counter-scale to maintain same visual size
const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isMobile = window.innerWidth <= 768 && /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
let baseScale = isMobile ? 3 : (isHighDPI ? 2.2 : 1.1); // Prioritize mobile scaling over high DPI
const navScale = baseScale / currentDPR;
// Dropdowns at 80% (20% smaller)
@@ -81,6 +81,12 @@
console.log('DPR:', currentDPR, 'isHighDPI:', isHighDPI, 'baseScale:', baseScale, 'navScale:', navScale, 'effective width:', effectiveWidth);
}
// Set CSS custom property for mobile navbar scaling (for sidebar positioning)
// Use the ACTUAL scaled height, not a fixed assumption
const baseHeight = 60;
const actualScaledHeight = baseHeight * navScale;
document.documentElement.style.setProperty('--navbar-height', `${actualScaledHeight}px`);
setTimeout(() => {
const dropdowns = document.querySelectorAll('.dropdown-menu');
dropdowns.forEach(dropdown => {
@@ -379,10 +385,10 @@
Browse
</label>
<div class="d-flex flex-column align-items-center">
<div class="d-flex justify-content-start align-items-center" id="fileInputText">
<div th:text="#{fileChooser.click}" style="margin-right: 5px"></div>
<div th:text="#{fileChooser.or}" style="margin-right: 5px"></div>
<div th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></div>
<div class="text-center" id="fileInputText" style="word-wrap: break-word; hyphens: auto; line-height: 1.2;">
<span th:text="#{fileChooser.click}"></span>
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
@@ -33,18 +33,16 @@
</div>
<div id="selected-pages-display" class="selected-pages-container hidden">
<div style="display:flex; height:3rem; margin-right:1rem">
<h5 th:text="#{multiTool.selectedPages}" style="white-space: nowrap; margin-right: 1rem;">Selected
Pages</h5>
<input type="text" id="csv-input" class="form-control" style="height:2.5rem" placeholder="1,3,5-10"
value="">
<div class="selected-pages-header">
<h5 th:text="#{multiTool.selectedPages}">Selected Pages</h5>
<input type="text" id="csv-input" class="form-control" placeholder="1,3,5-10" value="">
</div>
<ul id="selected-pages-list" class="pages-list"></ul>
</div>
</div>
<div class="multi-tool-container">
<div class="d-flex flex-wrap" id="pages-container-wrapper">
<div id="pages-container-wrapper">
<div id="pages-container">
</div>
</div>
@@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpSession;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
@@ -138,7 +139,7 @@ class JobControllerTest {
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
mockResult.completeWithSingleFile(fileId, originalFileName, contentType, fileContent.length);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent);
@@ -215,7 +216,7 @@ class JobControllerTest {
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
mockResult.completeWithSingleFile(fileId, originalFileName, contentType, 1024L);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found"));