Compare commits

...
Author SHA1 Message Date
Anthony Stirling f44b6e5b3f test 2025-11-25 12:27:05 +00:00
Anthony Stirling ceb1027a93 fix settings 2025-11-25 12:20:09 +00:00
James Brunton d599a07a66 Automatically switch to Viewer when going to exactly 1 file (#4973)
# Description of Changes
Automatically switch to Viewer when going to exactly 1 file
2025-11-24 17:09:41 +00:00
Reece Browne 861e4394df Bug/v2/fix rtl (#4958) 2025-11-24 16:57:36 +00:00
ConnorYohandConnor Yoh 5d18184e46 V2 Payment Features (#4974)
* Added ability to add seats to enterprise
* first logged in date on people page
* Remove Premium config section				
* Cleanup add seat flow					
* Shrink numbers in plan 					
* Make editing text a server feature in the highlights
* default to dollar pricing				
* clear checkout logic when crash				
* Recongnise location and find pricing			
* Payment successful page

---------

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

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

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

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

---------

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

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-24 14:00:43 +00:00
Reece Browne 30bcc38c04 Feature/v2/add text (#4951)
Refactor sign to separate out add text and add image functions. 
Implement add text as standalone tool
2025-11-24 13:37:35 +00:00
James Brunton e8e98128d2 Allow login to SaaS for desktop instead of offline mode (#4941)
# Description of Changes
Makes the desktop options to sign in with your Stirling account, or sign
into self-hosted:

<img width="608" height="456" alt="image"
src="https://github.com/user-attachments/assets/a49988ab-db3f-4333-b242-790aee5c07c6"
/>

The first option still runs everything locally, just enforces that
you've signed in for now. Future work will enable sending operations
that can't be run locally to the server.
2025-11-22 00:38:59 +00:00
Anthony Stirling e1a879a5f6 Differentiate unavailable tools by reason (#4916)
## Summary
- track endpoint disable reasons server-side and expose them through a
new `/api/v1/config/endpoints-availability` API that the frontend can
consume
- refresh the web UI tool management logic to cache endpoint details,
compute per-tool availability metadata, and show reason-specific
messaging (admin disabled vs missing dependency) when a tool cannot be
launched
- add the missing en-GB translations for the new unavailability labels
so the UI copy reflects the new distinction
<img width="1156" height="152" alt="image"
src="https://github.com/user-attachments/assets/b54eda37-fe5c-42f9-bd5f-9ee00398d1ae"
/>


<img width="930" height="168" alt="image"
src="https://github.com/user-attachments/assets/47c07ffa-adb7-4ce3-910c-b6ff73f6f993"
/>


## Testing
- `npm run typecheck:core` *(fails:
frontend/src/core/components/shared/LocalIcon.tsx expects
../../../assets/material-symbols-icons.json, which is not present in
this environment)*

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6919af7a493c8328bb5ac3d07e65452b)
2025-11-21 13:19:53 +00:00
Reece Browne 4fd336c26c Feature/v2/bookmarks (#4949)
bookmark panel on viewer
2025-11-21 11:33:49 +00:00
428 changed files with 31471 additions and 1785 deletions
+4 -2
View File
@@ -306,8 +306,10 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
+2
View File
@@ -312,6 +312,8 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
+15
View File
@@ -33,6 +33,7 @@ SwaggerDoc.json
# Gradle
.gradle
.gradle-home
.lock
# External tool builders
@@ -213,3 +214,17 @@ node_modules/
test_batch.json
*.backup.*.json
frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
scripts/pdf-collection/
**/tmp/
*.backup
# Type3 development data
docs/type3/signatures/
# Type3 sample PDFs (development only)
**/type3/samples/
+2
View File
@@ -37,6 +37,8 @@ dependencies {
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class EndpointConfiguration {
public enum DisableReason {
CONFIG,
DEPENDENCY,
UNKNOWN
}
public static class EndpointAvailability {
private final boolean enabled;
private final DisableReason reason;
public EndpointAvailability(boolean enabled, DisableReason reason) {
this.enabled = enabled;
this.reason = reason;
}
public boolean isEnabled() {
return enabled;
}
public DisableReason getReason() {
return reason;
}
}
private static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
processEnvironmentConfigs();
}
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true);
log.debug("Enabled endpoint: {}", endpoint);
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
endpointDisableReasons.remove(normalized);
log.debug("Enabled endpoint: {}", normalized);
}
public void disableEndpoint(String endpoint) {
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
log.debug("Disabling endpoint: {}", endpoint);
disableEndpoint(endpoint, DisableReason.CONFIG);
}
public void disableEndpoint(String endpoint, DisableReason reason) {
String normalized = normalizeEndpoint(endpoint);
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
log.debug("Disabling endpoint: {}", normalized);
}
endpointStatuses.put(endpoint, false);
endpointStatuses.put(normalized, false);
endpointDisableReasons.put(normalized, reason);
}
public boolean isEndpointEnabled(String endpoint) {
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
}
public void disableGroup(String group) {
disableGroup(group, DisableReason.CONFIG);
}
public void disableGroup(String group, DisableReason reason) {
if (disabledGroups.add(group)) {
if (isToolGroup(group)) {
log.debug(
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
group);
}
}
groupDisableReasons.put(group, reason);
// Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::disableEndpoint);
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
}
}
}
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group);
}
groupDisableReasons.remove(group);
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::enableEndpoint);
}
}
public EndpointAvailability getEndpointAvailability(String endpoint) {
boolean enabled = isEndpointEnabled(endpoint);
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
return new EndpointAvailability(enabled, reason);
}
private DisableReason determineDisableReason(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
}
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
Set<String> endpoints = entry.getValue();
if (!disabledGroups.contains(group) || endpoints == null) {
continue;
}
if (endpoints.contains(normalized)) {
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
}
}
return DisableReason.UNKNOWN;
}
public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups);
}
@@ -261,6 +334,10 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
addEndpointToGroup("Convert", "pdf-to-json");
addEndpointToGroup("Convert", "json-to-pdf");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -394,6 +471,10 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
@@ -41,7 +41,7 @@ public class CustomPDFDocumentFactory {
// Memory thresholds and limits
private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
// Files smaller than this threshold are loaded entirely in memory for better performance.
// These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
// No temp files are created for document data, reducing I/O operations but consuming more
@@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -37,6 +38,9 @@ public class JobExecutorService {
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
@@ -98,11 +102,17 @@ public class JobExecutorService {
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Generate base UUID
String baseJobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
// Store the scoped job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
request.setAttribute("jobId", scopedJobKey);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
@@ -116,11 +126,13 @@ public class JobExecutorService {
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(jobId);
log.debug("Added job ID {} to user session", jobId);
userJobIds.add(scopedJobKey);
log.debug("Added scoped job ID {} to user session", scopedJobKey);
}
}
String jobId = scopedJobKey;
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
@@ -149,17 +161,31 @@ public class JobExecutorService {
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
final String capturedJobIdForQueue = jobId;
Supplier<Object> wrappedWork =
() -> {
try {
// Set jobId in ThreadLocal context for the queued job
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
log.debug(
"Set jobId {} in JobContext for queued job execution",
capturedJobIdForQueue);
Object result = work.get();
processJobResult(jobId, result);
processJobResult(capturedJobIdForQueue, result);
return result;
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
"Error executing queued job {}: {}",
capturedJobIdForQueue,
e.getMessage(),
e);
taskManager.setError(capturedJobIdForQueue, e.getMessage());
throw e;
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
};
@@ -171,21 +197,36 @@ public class JobExecutorService {
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
// Capture the jobId for the async thread
final String capturedJobId = jobId;
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
"Running async job {} with timeout {} ms",
capturedJobId,
timeoutToUse);
// Set jobId in ThreadLocal context for the async thread
stirling.software.common.util.JobContext.setJobId(capturedJobId);
log.debug(
"Set jobId {} in JobContext for async execution",
capturedJobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
processJobResult(capturedJobId, result);
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
}
});
@@ -194,6 +235,10 @@ public class JobExecutorService {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Make jobId available to downstream components on the worker thread
stirling.software.common.util.JobContext.setJobId(jobId);
log.debug("Set jobId {} in JobContext for sync execution", jobId);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
@@ -213,6 +258,8 @@ public class JobExecutorService {
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
} finally {
stirling.software.common.util.JobContext.clear();
}
}
}
@@ -466,8 +513,23 @@ public class JobExecutorService {
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
String currentJobId = stirling.software.common.util.JobContext.getJobId();
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
java.util.concurrent.CompletableFuture.supplyAsync(
() -> {
if (currentJobId != null) {
stirling.software.common.util.JobContext.setJobId(currentJobId);
}
try {
return supplier.get();
} finally {
if (currentJobId != null) {
stirling.software.common.util.JobContext.clear();
}
}
},
executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
@@ -483,4 +545,18 @@ public class JobExecutorService {
throw new Exception("Execution was interrupted", e);
}
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
}
@@ -0,0 +1,42 @@
package stirling.software.common.service;
import java.util.Optional;
/**
* Service interface for managing job ownership and access control. Implementations can provide
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
*/
public interface JobOwnershipService {
/**
* Get the current authenticated user's identifier.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
Optional<String> getCurrentUserId();
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
String createScopedJobKey(String jobId);
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
boolean validateJobAccess(String scopedJobKey);
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
String extractJobId(String scopedJobKey);
}
@@ -0,0 +1,18 @@
package stirling.software.common.util;
/** Thread-local context for passing job ID across async boundaries */
public class JobContext {
private static final ThreadLocal<String> CURRENT_JOB_ID = new ThreadLocal<>();
public static void setJobId(String jobId) {
CURRENT_JOB_ID.set(jobId);
}
public static String getJobId() {
return CURRENT_JOB_ID.get();
}
public static void clear() {
CURRENT_JOB_ID.remove();
}
}
@@ -96,6 +96,7 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getOcrMyPdfSessionLimit();
case CFF_CONVERTER -> 1;
};
long timeoutMinutes =
@@ -150,6 +151,7 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes();
case CFF_CONVERTER -> 5L;
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
});
@@ -302,7 +304,8 @@ public class ProcessExecutor {
TESSERACT,
QPDF,
GHOSTSCRIPT,
OCR_MY_PDF
OCR_MY_PDF,
CFF_CONVERTER
}
@Setter
@@ -78,6 +78,23 @@ class JobExecutorServiceTest {
verify(request).setAttribute(eq("jobId"), anyString());
}
@Test
void shouldExposeJobIdInJobContextDuringSyncExecution() throws Exception {
// Given
Supplier<Object> work = stirling.software.common.util.JobContext::getJobId;
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
var requestJobIdCaptor = ArgumentCaptor.forClass(String.class);
verify(request).setAttribute(eq("jobId"), requestJobIdCaptor.capture());
assertEquals(requestJobIdCaptor.getValue(), response.getBody());
}
@Test
void shouldRunAsyncJobSuccessfully() throws Exception {
// Given
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
if (affectedGroups != null) {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures),
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
int exitCode = process.waitFor();
if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationContext;
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
@@ -200,4 +205,19 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
@@ -196,9 +196,9 @@ public class StampController {
resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
case "korean" -> "static/fonts/malgun.ttf";
case "chinese" -> "static/fonts/SimSun.ttf";
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
case "roman" -> "static/fonts/NotoSans-Regular.ttf";
default -> "static/fonts/NotoSans-Regular.ttf";
@@ -171,9 +171,9 @@ public class WatermarkController {
resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
case "korean" -> "static/fonts/malgun.ttf";
case "chinese" -> "static/fonts/SimSun.ttf";
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
case "korean" -> "static/fonts/NotoSansKR-Regular.ttf";
case "chinese" -> "static/fonts/NotoSansSC-Regular.ttf";
case "thai" -> "static/fonts/NotoSansThai-Regular.ttf";
default -> "static/fonts/NotoSans-Regular.ttf";
};
@@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -24,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
import stirling.software.common.util.RegexPatternUtils;
@@ -41,6 +43,9 @@ public class JobController {
private final JobQueue jobQueue;
private final HttpServletRequest request;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
/**
* Get the status of a job
*
@@ -50,6 +55,13 @@ public class JobController {
@GetMapping("/job/{jobId}")
@Operation(summary = "Get job status")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job status: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -79,6 +91,13 @@ public class JobController {
@GetMapping("/job/{jobId}/result")
@Operation(summary = "Get job result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job result: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -144,13 +163,8 @@ public class JobController {
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
log.debug("Request to cancel job: {}", jobId);
// Verify that this job belongs to the current user
// We can use the current request's session to validate ownership
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
if (sessionJobIds == null
|| !(sessionJobIds instanceof java.util.Set)
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
// Either no jobs in session or jobId doesn't match user's jobs
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to cancel job: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to cancel this job"));
@@ -210,6 +224,13 @@ public class JobController {
@GetMapping("/job/{jobId}/result/files")
@Operation(summary = "Get job result files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job files: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@@ -330,4 +351,26 @@ public class JobController {
return "attachment; filename=\"" + fileName + "\"";
}
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @return true if user has access, false otherwise
*/
private boolean validateJobAccess(String jobId) {
// If JobOwnershipService is available (security enabled), use it
if (jobOwnershipService != null) {
try {
return jobOwnershipService.validateJobAccess(jobId);
} catch (SecurityException e) {
log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage());
return false;
}
}
// Security disabled - allow all access (backwards compatibility)
// When security is not enabled, any user can access any job by jobId
return true;
}
}
@@ -1,5 +1,6 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
@@ -7,6 +8,9 @@ logging.level.org.eclipse.jetty=WARN
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
logging.level.stirling.software.common.service.JobExecutorService=INFO
logging.level.stirling.software.common.service.TaskManager=INFO
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
server.error.path=/error
@@ -174,6 +174,23 @@ 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)
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,592 @@
[
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "1867"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "1888"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "2029"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "2069"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "2089"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "2116"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSansMono",
"encoding": "2174"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans-Oblique",
"encoding": "2192"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "2209"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "Cmsy10",
"encoding": "2228"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "STIXSizeThreeSym-Regular",
"encoding": "2233"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSansDisplay",
"encoding": "2239"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4403"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4438"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4519"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4685"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4733"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4782"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4813"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4834"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSansMono",
"encoding": "4878"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4906"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4929"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "4971"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5001"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5030"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5052"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5083"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5116"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5143"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5175"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5207"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5243"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "Cmr10",
"encoding": "5263"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "Cmex10",
"encoding": "5270"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "Cmsy10",
"encoding": "5275"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "Cmmi10",
"encoding": "5280"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5295"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans-Oblique",
"encoding": "5313"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSansDisplay",
"encoding": "5319"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5334"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5370"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5399"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5427"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5459"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5486"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5513"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5554"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5601"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5647"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5694"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5732"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5771"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5803"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5861"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5904"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5924"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "5951"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "6084"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "6445"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7195"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7409"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7474"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7708"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7747"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7885"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "9029"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "9617"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "10460"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "11445"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans-Bold",
"encoding": "11486"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "11497"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "11543"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12280"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12301"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12350"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12372"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12395"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "12416"
},
{
"source": "01_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "13324"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "3214"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "3251"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "7190"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "9937"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "10792"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "10852"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "14712"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "18396"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "18719"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans-Bold",
"encoding": "18741"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "18778"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "18804"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "20974"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "20993"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "21093"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "21117"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "21141"
},
{
"source": "02_Matplotlib.pdf",
"fontName": "DejaVuSans",
"encoding": "21174"
},
{
"source": "03_handout-beginner.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "17"
},
{
"source": "03_handout-beginner.pdf",
"fontName": "EVICAO+DejaVuSans-Bold",
"encoding": "133"
},
{
"source": "03_handout-beginner.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "152"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "13"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "85"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "104"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "121"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "135"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "159"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "179"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "198"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "NVMZUP+SourceCodePro-Regular",
"encoding": "208"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "BMQQDV+DejaVuSans",
"encoding": "231"
},
{
"source": "04_handout-intermediate.pdf",
"fontName": "NVMZUP+SourceCodePro-Regular",
"encoding": "241"
},
{
"source": "07_matplotlib.pdf",
"fontName": "SauceCodePowerline-Bold",
"encoding": "22"
},
{
"source": "07_matplotlib.pdf",
"fontName": "SauceCodePowerline-Regular",
"encoding": "47"
},
{
"source": "07_matplotlib.pdf",
"fontName": "SauceCodePowerline-Regular",
"encoding": "65"
},
{
"source": "07_matplotlib.pdf",
"fontName": "SauceCodePowerline-Bold",
"encoding": "110"
},
{
"source": "08_matplotlib.pdf",
"fontName": "F36",
"encoding": "12"
},
{
"source": "08_matplotlib.pdf",
"fontName": "F59",
"encoding": "42"
}
]

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